1. 这个经典问题的真实现场
1.1 症状描述:单例里注入了“不变”的多例
大部分做 Java 服务端开发的朋友,应该都遇到过类似的诡异场景:明明把一个 Bean 标记成了 prototype 作用域,结果在单例服务里每回拿到的对象 hash 却又一模一样。我当时在做一个定时调度任务,每个任务都需要一个全新的执行上下文,专门把上下文对象声明成 prototype,防止上一个任务的临时状态污染下一个任务。压测一开始就出问题:两个并发任务打印出来的上下文实例 hash 完全相同,任务隔离的逻辑等于白做了。查了一圈代码确认作用域配置没错,最后问题就锁定在了“spring 单例类加载多例属性”这条链路上。
这个问题的本质,并不是作用域没生效,而是 Spring 容器在创建单例时,已经把多例 Bean 完成了一次性装配。单例全程只有一次初始化,依赖字段也只在这次初始化时赋值一次。多例作用域管的是“容器 getBean 时创建新实例”,但容器在装配单例时只会 getBean 一次,然后把拿到的那个实例老老实实放进单例的属性里。之后无论单例被调用多少次,字段里躺着的都是同一个对象。想验证也很简单:把服务侧日志打开,打印注入属性的 identityHashCode,多调用几次,就会发现 hash 从头到尾不变化。
1.2 最小复现代码与现象记录
先给出最直观的复现 Demo。这里我用 AnnotationConfigApplicationContext 直接启动容器,避免和 Spring Boot 自动配置混在一起,让问题更纯粹:
public class PrototypeInjectDemo { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); SingletonService service = ctx.getBean(SingletonService.class); service.execute(); service.execute(); ctx.close(); } } @Component public class SingletonService { @Autowired private PrototypeBean prototypeBean; public void execute() { prototypeBean.print(); } } @Component @Scope("prototype") public class PrototypeBean { public void print() { System.out.println("prototype instance: " + System.identityHashCode(this)); } }运行两次 execute(),控制台输出两条记录,但 identityHashCode 完全一致。网上很多朋友把这个过程总结成“单例把多例焊死了”,非常形象。我在纯注解配置、XML 配置、字段注入、构造器注入等多种组合下都复现过这个现象,结论几乎一样。唯一的区别在于“错”的方式不同:字段注入是静默错,构造器注入在某些版本下还会顺手触发循环依赖的报错误导排查方向,但底层的对象复用逻辑是一致的。
注意:如果你用 @Autowired 注入的字段是 final 的,在 Spring 4.3 之后的单构造器场景还能凑合,但在字段注入场景下 final 字段根本不会被正常赋值,容易把排查方向带偏。我建议先按上面的朴素写法复现,再逐步叠加自己的业务逻辑,否则很难判断是作用域问题还是字段本身没被注入成功。
2. 为什么单例会把多例“焊死”:背后的生命周期逻辑
2.1 单例与多例的生命周期差异
Spring 的 scope 概念,本质上回答的是“容器什么时候创建 Bean、创建几个、谁来销毁”这几个问题。singleton 表示整个容器只存在一个实例,容器启动阶段就完成创建并缓存,后续所有 getBean 都走缓存;prototype 则表示每次 getBean 都创建全新实例,创建完交给调用者,容器不再跟踪它的销毁逻辑。两者区别看似简单,真正容易出问题的,是它与依赖注入机制叠加之后的行为。
分歧点就出在“注入”这个动作上。单例 Bean 的字段装配,是在它自己初始化阶段完成的。Spring 在 doGetBean 拿到 SingletonService 之后,通过 populateBean 对 @Autowired 字段做依赖解析。此时它解析 prototypeBean,发现这是一个 prototype 类型的依赖,于是调用 getBean 创建了一个 PrototypeBean 实例,然后把这个实例作为字段值写入。由于 SingletonService 只初始化一次,这次 getBean 也只发生一次,“每次调用都要创建新的”这个多例语义,永远没有机会再次执行。整个过程没有异常、没有警告,如果不是刻意打点观察,很少有人会立刻反应过来。
一句话记住:scope 控制的是“Bean 获取”时的行为,而不是“字段保存”时的行为。依赖一旦被塞进字段,后续读字段就不再经过容器了。很多原地排查半天的人,就是没把这个概念分清楚,总以为 prototype 会在字段访问的瞬间自动替换对象,实际上字段访问根本不触发容器逻辑,更不会触发任何作用域判断。
2.2 依赖注入的本质是一次对象装配
很多刚接触 Spring 的同学,会把依赖注入理解成“引用一个可变的管道,随时能从里面取新对象”,这其实是一种误解。默认情况下,Spring 的依赖注入就是纯粹的对象装配:目标 Bean 初始化时,把依赖对象通过构造器、Setter、字段三种途径写进去。写进去的是对象引用,不是工厂,更不是代理。所以对于单例 Bean 来说,它的属性图在初始化完成后就基本固化了。
对比一下:如果一个 prototype Bean 依赖一个 singleton Bean,这种情况是安全的,因为那一个 singleton 实例本来就应该被共享;真正出问题的是 singleton 依赖 prototype,此时我们希望保持一种动态获取的能力,而默认装配恰好把这个能力剪断了。理解到这一层,解决思路也就清楚了——不能依赖默认的字段装配,必须想一个办法,让单例每次要用的时候都能重新向容器申请一个实例。
2.3 结论:注入的是“实例”,不是“获取方式”
把整条链路压缩成一句话:单例持有了一个“多例的实例”,而不是“多例的获取方式”。Spring 默认的依赖注入不会帮你保留获取逻辑,更不会给 prototype 做一层透明代理。因此,不管你业务层调用多少次,拿到的都只是初始化那一刻的对象。
这也是很多框架文档反复强调“prototype 不要直接注入到 singleton”的原因。并非 Java 语法上不允许,而是语义上会静默失效,而且失效过程没有任何异常。代码看起来全对,行为却完全不对,是典型的“沉默型 Bug”。更麻烦的是,这种问题往往只在并发场景、状态隔离场景下才暴露出来,单测里反复只调用一次根本测不出来。所以排查时一定要带着“多例语义是否被静默吞掉”的怀疑去查。
3. 五种解决方案拆解与选型对比
3.1 方案一:@Lookup 方法注入
@Lookup 是 Spring 提供的方法注入机制,目标很明确:让单例里的一个方法具备“每次调用都从容器 getBean”的能力。实现方式有点黑科技——Spring 会用 CGLIB 生成目标单例类的子类,并重写标注了 @Lookup 的方法,把方法体替换成容器调用。你注入进上下文里的其实是这个子类的实例,外部感知不到差异。
@Component public abstract class SingletonService { public void execute() { PrototypeBean bean = createPrototypeBean(); bean.print(); } @Lookup protected abstract PrototypeBean createPrototypeBean(); }使用上有几个硬性约束:CGLIB 子类化要求目标类不能是 final,被覆盖的方法也不能是 final,方法可见性建议 protected 或 public,私有方法无法被子类覆盖。一旦满足这些条件,每次调用 createPrototypeBean(),Spring 都会走一次 getBean,返回全新实例。我把这个方法用在任务上下文的创建上,实测输出 hash 每次都不同,多例语义完全恢复。
如果不想写抽象方法,也可以改成普通方法,加上 @Lookup 后返回一个 null 占位:
@Lookup public PrototypeBean createPrototypeBean() { return null; // 实际不会被调用 }Spring 用字节码技术生成了子类并覆盖了该方法,所以这个空方法体只是占位。不过我认为这种写法可读性比较差,容易让人误以为返回 null 是预期行为。团队里如果有新人来看代码,大概率会被误导,所以我更推荐抽象方法的写法,意图一目了然。
3.2 方案二:ObjectProvider 延迟获取
ObjectProvider 是 Spring 4.3 引入的延迟解析 API,本质是一个“Bean 解析器”对象,内部持有容器的依赖查找能力。单例字段保存的是这个 Provider,而不是目标实例。每次调用 getObject(),才会触发一次真正的 getBean,因此 prototype 语义被真正保留了下来。
@Component public class SingletonService { @Autowired private ObjectProvider<PrototypeBean> prototypeBeanProvider; public void execute() { PrototypeBean bean = prototypeBeanProvider.getObject(); bean.print(); } }我比较喜欢 ObjectProvider 还有另一个原因:它提供了丰富的变体方法。getIfAvailable() 可以在没有匹配 Bean 的时候返回 null,getIfUnique() 可以在存在多个候选时避免抛出 NoUniqueBeanDefinitionException,stream() 可以直接遍历所有符合类型的 Bean。我们项目里有个场景,同一个接口有多个实现类,需要按配置动态选择,我用 ObjectProvider.stream() 统一收集,写起来比手动 getBeansOfType 清爽得多。这里顺带一提,ObjectProvider 本身也是单例字段,但它不是目标类型的实例,所以完全绕开了本文开头那个“只 getBean 一次”的问题。
3.3 方案三:javax.inject.Provider 与 ObjectProvider 的差别
很多人只知道 Spring 的 ObjectProvider,却不知道 JSR-330 里也有一个 Provider 。它的用法几乎一样:
@Autowired private Provider<PrototypeBean> prototypeBeanProvider;原理也是延迟解析,每次 get() 都是新的一次容器获取。和 ObjectProvider 最核心的差别在于:javax.inject.Provider 是标准接口,与容器实现解耦,适合希望代码可移植、不绑架在 Spring API 上的团队;ObjectProvider 则提供了更多便利方法,并且 Spring 内部很多延迟注入场景默认就是它。如果项目里已经大量使用 Spring,我会优先选择 ObjectProvider。反正引入 JSR-330 依赖也只是一层裸接口,可移植性收益不高的场景下,没必要多一层抽象。反过来,如果团队未来有迁移到其它 DI 框架的计划,用标准 Provider 能省不少替换成本。
3.4 方案四:Scoped Proxy 代理注入
Scoped Proxy 的思路和前面几个都不一样。它不是在调用侧动态获取,而是主动把“获取逻辑”内嵌到代理对象里。做法是在 prototype Bean 上声明代理模式:
@Component @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS) public class PrototypeBean { public void print() { System.out.println("prototype instance: " + System.identityHashCode(this)); } }此时 Spring 注入进单例的不是 PrototypeBean 本身,而是它的 CGLIB 代理。单例里的字段保持普通写法不变,每次调用这个代理上的方法,代理都会向容器请求一个全新的目标实例来执行。从单例的视角看,它还是一行 @Autowired private PrototypeBean prototypeBean;,和默认写法完全一致,侵入性最低。
不过有得必有失:每次方法调用都额外经过一层代理,方法链路越长、调用越频繁,间接开销就越明显;同时代理对 final 类、final 方法同样不友好。如果只是希望字段写法不变,并且调用频率不高,Scoped Proxy 是很省事的方案。它更适合 request 或 session 作用域,因为那些作用域的 Bean 如果直接注入单例,在容器启动时可能直接报 “No thread-bound request found”,代理刚好可以延迟到请求期内再解析。
3.5 方案对比表与我的选型偏好
| 方案 | 核心机制 | 写入单例字段的是什么 | 侵入性 | 推荐场景 |
|---|---|---|---|---|
| @Lookup | CGLIB 子类覆盖方法 | 子类实例 | 中(方法需要改造) | 单一固定类型、高频调用 |
| ObjectProvider | 延迟解析容器 | Provider 解析器 | 低(只需替换字段) | Spring 项目主流选择 |
| javax.inject.Provider | 延迟解析容器 | Provider 解析器 | 低 | 需要容器无关代码 |
| ApplicationContext.getBean | 手动容器获取 | 容器引用 | 高(耦合容器) | 很少用,避免过度容器化 |
| Scoped Proxy | JDK/CGLIB 代理 | 代理对象 | 低(目标类需可代理) | 希望保持字段写法不变 |
我看到很多团队遇到类似问题,第一反应就是注入 ApplicationContext 然后手动 getBean。这不是不能用,而是把容器手动获取的方式撒得到处都是,以后做单元测试、替换容器、梳理依赖关系都会很痛苦。除非项目里确实没有更优雅的入口,否则我不太推荐这条路。个人选型顺序:ObjectProvider 排第一,@Lookup 第二,Scoped Proxy 只在字段写法必须保持不变时才用。
4. 实操验证:同样场景下各方案的运行结果
4.1 验证代码设计
光讲原理不够,我习惯把代码落到可执行 Demo 上,再用输出说话。设计思路很简单:定义一个 PrototypeBean,打印自身的 identityHashCode;定义多个不同处理策略的 SingletonService,分别用 @Lookup、ObjectProvider、Provider 三种方式获取原型 Bean;每次调用都打印 hash,对比是否每次都不一样。代码统一放在一个 Maven 工程里,Spring 版本用的 5.3.x,在 Spring Boot 2.7.x 环境直接跑也兼容。
由于 hash 值在不同 JVM 之间没有可比性,验证要点只看“同一次运行内多次输出是否不同”。我同时记录了三条信息:获取方式、执行序号、对象 hash。三条 hash 依次不同,说明多例语义正常运作;hash 一直相同,说明仍然命中旧实例。下面这段代码是 @Lookup 服务的核心写法,其余两种只是字段类型和调用方法不同,结构保持一致:
@Component public class LookupService { @Lookup public PrototypeBean getPrototypeBean() { return null; } public void call(int index) { PrototypeBean bean = getPrototypeBean(); System.out.println("LookupService [" + index + "] hash=" + System.identityHashCode(bean)); } }4.2 三种方案的实际输出
用同一个容器注册三种服务,循环调用 3 次,实际输出大致如下(具体 hash 值每次运行都不同,关键在于变化):
LookupService [1] hash=1136265619 LookupService [2] hash=2140124144 LookupService [3] hash=460960458 ObjectProvider [1] hash=437065952 ObjectProvider [2] hash=1928931919 ObjectProvider [3] hash=1404849930 Provider [1] hash=1163723656 Provider [2] hash=1487468446 Provider [3] hash=170998054三组输出都是每次调用 hash 不同,证明这三种方案都能把多例语义恢复到单例调用链里。这个结果在我预期之中,因为原理上它们背后都走了容器 getBean。真正需要验证的是副作用,比如容器启动变慢、调用变慢、代理调用异常等。我接着做了一轮简单压测:单线程循环调用 10 万次,@Lookup 和 ObjectProvider 的耗时几乎一致;Scoped Proxy 因为多一层代理调用,整体耗时略高一些,但还没到影响业务的量级。这个测试比较粗糙,只能当作选型参考,要下工业级结论还是建议跑 JMH。
4.3 耗时与代理开销的细节点
关于性能,有几个容易被忽略的底层细节值得展开。第一,@Lookup 虽然走了 CGLIB 子类,但子类实例创建发生在容器启动阶段,启动完成后调用方法时,剩下的路径主要是 getBean 的解析流程,所以调用期的额外开销并没有想象中大。第二,ObjectProvider 也一样,getObject() 内部要经过 DefaultListableBeanFactory.doGetBean,如果目标类型带 @Qualifier 或者有多个候选 Bean,解析路径会更长。第三,Scoped Proxy 的开销不只是代理分发,每次方法调用还要从容器获取目标实例,所以它慢在“代理跳转 + getBean”这两个动作叠加。
如果你的原型 Bean 创建本身很重,比如里面有大量初始化、要建立数据库连接,那无论用哪种方案,这些开销都会在每次获取时实实在在发生。我在实际项目里就把“上下文初始化”这类重量操作从 getBean 阶段挪到了业务执行阶段,减小了容器关注的创建成本,项目启动速度和调用稳定性都有明显改善。这个调整不用改任何作用域定义,只需要把耗时的初始化逻辑拆出来,延迟到真正需要执行的时候再跑。
5. 排查思路与避坑清单
5.1 从现象定位根因的排查步骤
如果遇到类似的“多例不生效”,我建议按下面的顺序排查,能省下大量踩坑时间。第一步,确认作用域注解真的写在目标类上,而且不是只写在了抽象父类上,导致子类覆盖时丢失注解信息;第二步,检查注入方式,字段注入、构造器注入、Setter 注入在单例初始化时都只会发生一次,只要注入的是原型实例本身,都会出现同一个问题;第三步,用最小的 Demo 把容器加载起来,打印注入对象的 hash,确认真实对象是否被复用;第四步,再看项目里是否混用了代理模式、@Lookup、ObjectProvider,排查是否多个机制叠加导致行为混乱。
很多同学卡在第三步,喜欢直接怀疑是缓存问题。实际上 Spring 的 singleton 缓存只针对 singleton 作用域,prototype 根本不会进一级缓存,问题根源不是缓存,而是“只 getBean 了一次”。把这一步想清楚,排查思路就清晰了。换句话说,你不需要去清理任何缓存,因为根本没有缓存可以清理;需要改的是“获取方式”,而不是“刷新机制”。
5.2 实战中常见的误区和陷阱
第一个陷阱是面向接口注入时容易选错代理模式。如果目标类是接口实现,并且你希望用 Scoped Proxy,proxyMode 用 ScopedProxyMode.INTERFACES 时,拿到的是 JDK 动态代理;如果用了 TARGET_CLASS 且目标类没有实现接口,Spring 会走 CGLIB,此时类不能被 final。第二个陷阱是 @Lookup 方法所在类如果本身就是 @Configuration 的 CGLIB 代理,再叠一层 @Lookup 的子类化,很容易把开发者绕晕。建议在这种场景优先选择 ObjectProvider,逻辑更直白。第三个陷阱是在 prototype Bean 的构造器里做复杂校验。因为每次获取都要重新创建,校验失败会影响业务方法本身,而不是只在启动时报错,排查时容易误判为环境问题。
还要注意,prototype 作用域的 Bean,Spring 默认不会帮你执行 @PreDestroy 回调,因为容器认为你拿到实例之后应该自己负责生命周期。如果原型对象持有连接、文件句柄等资源,一定要在业务代码里显式释放,别指望容器兜底。这一点踩坑概率极高。我见过不止一位同事因为没释放原型对象持有的线程池,最后把测试环境的线程号打满。解决方式也很简单:拿到实例后把资源回收逻辑写在 try-finally 里,或者给原型对象提供一个 close() 方法,在业务收尾时统一关闭。
5.3 个人体会:什么场景该用哪种方案
根据我的经验,场景可以这样归类:如果只是“每次调用需要新对象、类型固定、没有复杂候选关系”,@Lookup 足够,代码也很直观;如果希望代码少一点“字节码魔法”的味道,并且需要处理可选、多候选这些情况,ObjectProvider 是更稳妥的选择;如果团队里有人坚持字段上一行 @Autowired 不能改,那再去谈 Scoped Proxy,但要接受代理开销和代理约束。ApplicationContext.getBean 我基本只在框架代码里见过,业务代码里尽量别碰。
最后再分享一个我最近养成的小习惯:不管用哪种方案,都会在原型 Bean 里临时加一个启动日志,打印当前环境信息、创建时间和 hash。这样每次发布之后,翻日志就能看到“新建实例”到底有没有按预期发生,一旦出现误配,几分钟内就能在日志里定位,而不是等压测数据异常后再去猜。这个排查手段成本很低,但对“多例属性问题”这一类静默失效的 Bug,回报非常高,建议你也试试。