一个工具类被标了 @Scope("prototype"),期望"每次注入一个新实例",而实际"三次注入拿到同一个对象"。原因很朴素:它被注入到"单例 Bean"里——而"单例在启动时创建一次",于是"prototype 只在"那一次"生效。
Spring 的"作用域"不是"注入时决定",而是"由'取 Bean 的那一刻'的容器决定"。而"注入到单例"就是"只取了一次".
1. 五种作用域的实际效果
| 作用域 | 谁决定生命周期 |
|---|---|
| singleton(默认) | 容器(一个) |
| prototype | 调用方(每次取都新建) |
| request/session | Web 请求(需 Web 环境) |
| application | ServletContext |
第二项的"每次取"是关键字:"取"= getBean() 或"注入"——而"注入"只在"构造/赋值"时发生一次.
2. "prototype 失效"的三种情形
| 情形 | 机制 |
|---|---|
| 注入到单例 | 只取一次 |
| 注入到"更短"的作用域但未代理 | 同上 |
自己 new 了一个 |
与容器无关 |
第三项常被忽略:**"自己 new"的实例"完全不受容器管"**第三项的"表现"是"依赖全是 null":自己 new 的实例"没有经过容器",于是"@Autowired 字段没人填"——**而"代码看起来完全正常".
——于是"加了注解也没用".
"@Lookup"是"容器注入"的老办法,而它要求"能被代理"的方法:
// @Lookup:每次调用"由容器返回新实例"
@Component
abstract class Runner {
abstract Worker newWorker(); // 抽象方法:容器生成实现
void run() { newWorker().work(); }
}
// 前提:类"可被 CGLIB 代理"(不能是 final class/方法)
3. "要每次新建"的三种做法
| 做法 | 成本 |
|---|---|
注入 ObjectProvider<T> / Provider<T> |
低(推荐) |
用 @Lookup 方法 |
中(需抽象类/方法) |
手动 getBean() |
高(耦合容器) |
第一项的"前提"是"prototype 语义正确":被取的 Bean 必须"标了 prototype"——否则 getObject() 每次返回"同一个单例",而"看起来也像"正确".
第一项是"现代做法":provider.getObject() 每次"向容器要一个新的"**——于是"单例里也能拿到新实例".
// 反例:注入 prototype 到单例 —— 只取一次
@Service class A { A(Worker w) { this.w = w; } } // w 永远是同一个
// 正解:注入 Provider,用时再取
@Service class B {
private final ObjectProvider<Worker> workers;
void run() { workers.getObject().work(); } // 每次新实例
}
"释放"这件事要"显式",否则"用一次漏一次":
// 反例:prototype 里开了资源,没人关
@Component @Scope("prototype")
class Session { Session() { conn = open(); } } // @PreDestroy 不会被调用
// 正解:调用方"用完就关"(或用池)
try (var s = provider.getObject()) { s.use(); } // 实现 AutoCloseable
// 判据:prototype 的"资源"必须"由调用方关闭"
4. "prototype 的销毁"由谁管
| 事实 | 说明 |
|---|---|
| 容器"创建"prototype | 是 |
| 容器"销毁"prototype | 否(不调 @PreDestroy) |
| 谁负责释放 | 调用方 |
第二项是"资源泄漏"的来源:**"prototype Bean 里开了连接/线程,而没人关"**第二项的"典型"是"每请求 new 一个 client":连接池外再开连接——**于是"连接数线性涨",而"没有一条被归还".
——因为"容器不追踪它们".
5. "该不该用 prototype"的三条判据
| 判据 | 结论 |
|---|---|
| 有"每次都要重置"的状态 | 用(或"无状态 + 参数") |
| 有"昂贵资源"(连接/线程) | 慎用(需自己管释放) |
| 只是"想要新对象" | 用"局部变量/工厂"更简单 |
第三项的"判据"是"它需不需要"注入依赖"":需要(要用仓库/客户端),交给容器;不需要(纯计算、值对象),new 更清楚".
第三项是"最常被忽略"的:"需要一个新对象"不等于"需要一个 Bean"**——普通类 new 更直接.
"request 作用域"在"异步/线程"里最容易出事,而它有个"显式"的解法:
// 三种"跨线程"的坑
1 异步任务里读 request Bean → "no thread-bound request"
2 线程池复用 → 拿到"上一个请求"的实例
3 定时任务里 → 无请求上下文
// 正解:在"请求线程内"取好值,传给异步任务(不要跨线程持有 Bean)
6. 排错:五种"作用域问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| prototype 拿到同一个 | 注入到单例 | 用 Provider |
@PreDestroy 不执行 |
prototype 不销毁 | 自己管释放 |
| request 作用域报错 | 无 Web 上下文 | 检查环境 |
| 状态串味 | 单例里存了可变状态 | 改无状态 |
| 每次 new 但"依赖没注入" | 手动 new | 用工厂/Provider |
"作用域选错的成本"在"资源"上最直观,而它常常"延迟暴露":
// 三个"延迟暴露"的信号
1 连接数"随请求数"线性涨 → prototype 未关
2 内存"缓慢上涨" → prototype 被缓存/持有
3 线程数"只增不减" → prototype 起了线程未停
// 判据:这三个数"随时间"的变化,就是"作用域错"的证据
7. 边界:什么情况"singleton + 参数"更好
| 场景 | 为什么 | 做法 |
|---|---|---|
| 无状态工具(纯函数) | 单例足够 | 单例 + 参数 |
| 有状态但"每次调用传入" | 状态可外置 | 单例 + 方法参数 |
| 需要"池化"(连接/线程) | 不是"每次新建" | 用池 |
| 短生命周期对象(值对象) | 不需要容器 | 直接 new |
判断口径一句话:"这个对象的状态,是"每次使用都要新的",还是"可以由调用方传进去"。最后一条与"调试"有关的边界:"prototype 是否生效"可以"打印实例哈希"验证:System.identityHashCode(bean)——**两次取到"同一个哈希"就说明"它其实是单例".
能传(无状态服务),单例 + 参数最省;不能(有内部状态且每次重置),才用 prototype——而"注入到单例"这个坑,会让 prototype 静默失效.
最后一个"设计"视角:"作用域"本质上是"所有权"问题——单例是"容器拥有",prototype 是"调用方拥有"。而"拥有"意味着"要负责它的创建与销毁":容器不销毁 prototype,等于"把责任移交给了你"。所以"用 prototype"之前该问的不是"我要不要新实例",而是"我愿不愿意管它的生命周期"——不愿意,就用"单例 + 参数"或"池",而不是"prototype + 忘记关"。这个视角能把"作用域选择"从"语法"变成"责任划分",而它也能解释"为什么 prototype 用得少、坑却多"。
再补一条"团队约定":"禁止把 prototype Bean 注入到单例"应当写成"可检查的规则"——因为它是"静默失效",代码看起来完全正常。实现很轻:一个测试扫描所有标了 @Scope("prototype") 的类型,检查"是否被其他 Bean 以字段/构造参数持有"——命中就报错。于是"以为新实例、实际同一个"这类问题,在 CI 里就被拦住,而不是等"用户数据串味"。
而"扫描 + 报错"这条规则,写起来不过二十行,却能挡住"最难查的一类串味"。
所以"约定"这件事,在"静默失效"的问题上,比"培训"有效得多。
而"培训"只在"记得的人"身上生效。
这就是"可执行约束"与"口头约定"的差别。
8. 自测
- 作用域由"什么时刻"决定?为什么"注入到单例"会让 prototype 失效?
- "自己 new"的实例为什么"加注解也没用"?
ObjectProvider为什么能在单例里拿到新实例?- prototype Bean 的"销毁"由谁负责?不负责会造成什么?
- "需要一个新对象"为什么不等于"需要一个 Bean"?
- 判断"用 prototype 还是单例 + 参数"的那个问题是什么?