作为常年跟Spring打交道的人,一定遇到过这种诡异情况:明明把某个Bean定义成了@Scope("prototype"),信心满满地注入到另一个单例Bean里,结果每次调用拿到的却是同一个对象,内部状态乱得让人怀疑人生。我最早掉进这个坑的时候,整整排查了一个下午,最后才发现问题的根源不在多例Bean本身,而在“单例Bean的属性注入时机”这个看似不起眼的细节上。
这其实是一个特别经典的Spring陷阱,网上叫法很多,有人叫“单例注入多例失效”,有人叫“单例类加载多例属性问题”。表面看是Bean作用域没生效,实际上涉及Spring IoC容器的Bean生命周期、代理机制、依赖注入时机三个层面的知识点。当年我还在用XML配置的时代就踩过,后来换成纯注解又踩了一次,每次都能骗到一批经验不太够的开发者。
这篇文章就把这个问题的来龙去脉从头到尾拆一遍,包括我实测过的四种解决方案:ApplicationContextAware手动获取、@Lookup方法注入、ObjectProvider延迟查找、Scoped Proxy代理模式。每种方案的原理、代码、适用场景、坑点都会讲到,最后附上我排查这类问题时的固定套路。如果你正在被“多例Bean不生效”折磨,或者想彻底搞懂Spring的Bean作用域机制,这篇文章应该能帮你省下不少时间。
1. 问题现象:明明配置了多例,调用时却是同一个实例
1.1 一个让人摸不着头脑的复现案例
先看一个最典型的场景。假设我有一个价格计算器,因为每次计算都会带上不同的税率、折扣上下文,所以我把它定义成多例Bean。然后在一个订单服务里注入它,每次来新订单都希望拿到一个全新的计算器实例:
@Component @Scope("prototype") public class PriceCalculator { private BigDecimal taxRate; private BigDecimal discount; public void setContext(BigDecimal taxRate, BigDecimal discount) { this.taxRate = taxRate; this.discount = discount; } public BigDecimal calculate(BigDecimal amount) { return amount.multiply(taxRate).subtract(discount); } }@Component public class OrderService { @Autowired private PriceCalculator priceCalculator; public BigDecimal processOrder(BigDecimal amount) { // 每个订单设置不同的税率和折扣 priceCalculator.setContext(new BigDecimal("0.13"), new BigDecimal("10")); return priceCalculator.calculate(amount); } }看起来天衣无缝:PriceCalculator是多例,OrderService每次处理订单时给它设置不同的上下文,得到不同的结果。但实际运行起来你会发现,OrderService里注入的priceCalculator从头到尾都是同一个对象。
怎么验证?加个构造方法或者打印对象地址就知道了:
public class PriceCalculator { public PriceCalculator() { System.out.println("PriceCalculator 实例化:" + this.hashCode()); } }启动Spring容器,你会发现PriceCalculator的构造函数只打印了一次。而后端接口不管被调用了多少次,打印出来的hashCode永远不变。问题就在这:多例Bean的“多例”只对getBean()有效,对@Autowired注入的属性无效。
1.2 我先查了什么:方向错了越查越远
后来我复盘这次排错过程,发现自己一开始的思路跑偏了。我先去查@Scope注解是不是写错了,又查是不是包扫描漏掉了某些配置,甚至怀疑是Spring版本的问题。其实这些全都不是原因。
真正关键的点在于:Spring容器启动时创建OrderService这个单例Bean,只创建一次,属性注入也只发生一次。而PriceCalculator作为它的依赖属性,是在OrderService实例化时被“拉”进来塞进去的。这个动作发生在容器启动阶段,不是每次调用processOrder时发生。所以OrderService里持有的priceCalculator引用,从启动那一刻就固定死了。
把它理解成生活中的场景就很容易想通:你有一台打印机(单例),打印机只有一个进纸盒(多例属性)。你在启动时往进纸盒里塞了一张纸,然后每次想打印新内容时,都指望进纸盒能自动换一张新纸——但这台打印机只在启动时被装填过一次,后续打印用的都是同一张纸。
注意:不是
@Scope("prototype")写错了,而是“属性注入的时机决定了实例的固定性”。@Autowired注入的依赖,本质上是在创建宿主Bean时取一次,之后不再变化。
2. 原理深挖:单例Bean和原型Bean的加载差异
2.1 Spring容器创建Bean的黑盒过程
想要彻底解决这个问题,得先看懂Spring IoC容器到底是怎么创建和管理Bean的。简化一下,Spring容器的核心工作分三步:
- 根据配置元数据(XML、注解、Java Config)创建Bean定义,也就是
BeanDefinition。 - 实例化Bean:通过反射调用构造方法创建对象。
- 初始化Bean:填充属性(
populateBean)、执行Aware回调、调用init-method、应用后置处理器。
单例和多例的差异在第二步和第三步之间体现得非常明显。单例Bean在容器启动时创建一次,存放在单例缓存池(一个ConcurrentHashMap)中,后续所有getBean()都从缓存里拿。多例Bean则不同,每次调用getBean()时,容器都会重新执行“创建实例 -> 填充属性 -> 初始化”这套流程,返回一个全新对象,Spring不会缓存它。
那@Autowired工作在哪里?在初始化阶段的“填充属性”环节。Spring扫描到OrderService里的@Autowired标记,就去容器里找PriceCalculator类型的Bean。因为OrderService是单例,它整个生命周期只执行一次“填充属性”,所以PriceCalculator也只被获取一次。
2.2 多例属性的生命周期“幻觉”
我见过不少初学Spring的人,脑子里会把“多例”理解成“每次用到的时候都是新的”。这个理解大方向没错,但它隐含了一个前提:你是通过ApplicationContext.getBean()或依赖查找去获取Bean的。
如果走依赖注入(@Autowired/@Resource/ 构造器注入),那容器的语义完全不同。依赖注入是“创建宿主时帮你拉好依赖”,不是“每次调用宿主方法时帮你拉依赖”。宿主是单例,整个注入动作只发生一次,注入进来的属性自然就只可能是一个固定引用。
这里有个更深的坑:如果注入进来的多例Bean是可变的(有状态),那么所有调用方共享这同一个实例,就会出现状态串扰。比如订单A设置的税率是0.13,订单B进来时设置的税率是0.06,但因为它们是同一个对象,订单A计算时用的税率可能已经被订单B覆盖了。这种bug比“多例不生效”更隐蔽,因为不是每次都报错,而是偶发性计算出错,线上查起来特别痛苦。
2.3 为什么大家都会踩:设计层面的误导
坦白说,这个坑之所以大多数人都会踩,和Spring早期的文档引导有一定关系,也和很多项目的分层习惯有关。
很多团队习惯把Service设计成无状态的单例,把工具类、计算器、线程上下文等设计成有状态的原型Bean。这个设计方向本身没问题,但问题在于:有状态的原型Bean往往被当成“临时工作对象”用,开发者天然认为每次使用都应该new一个。结果在Spring里用@Autowired注入,行为却等同于new了一次但永远复用同一个引用,和直觉完全相反。
另外一个因素是IDE的自动提示。写@Autowired时,Spring插件不会告诉你“宿主是单例,多例注入会失效”,它在编译期和启动期都不会报错,行为完全正常,只有运行到业务逻辑时才会露出马脚。因为你拿到的确实是一个合法的PriceCalculator实例,只是不是新的而已。
从原理层面搞清楚之后,你就能理解为什么解决方案不是“改配置”,而是“改变获取方式”。要么每次用到时主动去容器里拿新实例,要么让Spring注入一个“能帮你拿新实例”的间接层,要么注入一个代理对象,让代理在每次方法调用时代理转发到新实例上。
3. 解决方案逐个拆解:五种经过验证的可靠写法
3.1 方案一:注入ApplicationContext,手动getBean
最直白也最好理解的方案,就是直接注入ApplicationContext,每次需要时手动调用getBean()获取新实例:
@Component public class OrderService { @Autowired private ApplicationContext applicationContext; public BigDecimal processOrder(BigDecimal amount) { PriceCalculator calculator = applicationContext.getBean(PriceCalculator.class); calculator.setContext(new BigDecimal("0.13"), new BigDecimal("10")); return calculator.calculate(amount); } }这个方案的原理极简:getBean()每次都会走一遍创建流程,自然每次都返回新对象。用这种方式,多例的语义完全由容器保证,你不需要理解任何代理机制或方法注入的黑魔法。
但我一般不推荐靠这个方案撑起整个项目,原因有两个。第一,每次使用都手动getBean(),代码里到处都是ApplicationContext相关调用,侵入性强,也破坏了依赖注入带来的“声明式”美感。第二,ApplicationContext本身是Spring容器的大门,把它注入到业务代码里,相当于业务类和容器框架强行耦合了,单元测试时还要费劲mock这个上下文对象。
更适合的场景是:只在某个特定的工厂类、或者在启动阶段做动态获取时用。比如需要按照不同的策略名称动态获取处理器的场景,这种“容器查找”本身就是业务逻辑的一部分,手动getBean()反而合理。
注意:
getBean()每次都会创建新实例,这必然带来反射创建的开销。如果调用频率极高(单次请求内循环10000次),性能会有明显损耗,这种情况下建议回到单例无状态方案,或者用对象池。
3.2 方案二:@Lookup方法注入,让Spring帮你完成查找
@Lookup是Spring提供的更优雅的方案。它的思路是:容器会为一个标记了@Lookup的方法生成重写实现,每次调用这个方法时,自动执行getBean()逻辑并返回新实例:
@Component public class OrderService { public BigDecimal processOrder(BigDecimal amount) { PriceCalculator calculator = getPriceCalculator(); calculator.setContext(new BigDecimal("0.13"), new BigDecimal("10")); return calculator.calculate(amount); } @Lookup public PriceCalculator getPriceCalculator() { // 方法体可以留空,Spring会通过CGLIB生成子类来重写这个方法 return null; } }用的时候有三个关键点必须注意。
第一,@Lookup方法所在的宿主Bean在运行时会被CGLIB动态生成一个子类,所以宿主Bean的类不能被final修饰,方法本身也不能是final或private的。如果你把OrderService声明成final class,启动时会直接报错。
第二,方法返回null是正常的。你写的这个空方法体只是一个“占位符”,Spring会覆盖它,真正执行的是容器内部的getBean()逻辑。如果你在方法里写了实际逻辑,反而会被覆盖掉,白写。
第三,@Lookup方法修饰符要尽量用public或protected,让CGLIB可以正常重写。用private是不行的,因为子类无法重写私有方法。
实测下来,@Lookup比手动getBean()清爽很多,调用处看起来就像是调一个普通方法,没有容器相关的代码暴露到业务层。但有一个不太方便的点:它在单元测试里不好mock。当你直接new OrderService()测试时,方法体是空的,返回null,你得先给OrderService创建CGLIB代理,测试环境配置起来比较麻烦。
3.3 方案三:ObjectProvider延迟获取,最均衡的写法
Spring 4.3引入了ObjectProvider,我觉得这是目前最适合大多数场景的解法。它本质上是一个“注入进去的Bean工厂”,在你真正需要对象时才执行查找动作:
@Component public class OrderService { @Autowired private ObjectProvider<PriceCalculator> priceCalculatorProvider; public BigDecimal processOrder(BigDecimal amount) { PriceCalculator calculator = priceCalculatorProvider.getIfAvailable(); calculator.setContext(new BigDecimal("0.13"), new BigDecimal("10")); return calculator.calculate(amount); } }ObjectProvider有好几个获取方法,我用得最多的是getIfAvailable()和getIfUnique():
getIfAvailable():如果容器里存在该类型的Bean就返回,不存在返回null。getIfUnique():如果容器里恰好只有一个该类型的Bean就返回,否则返回null。getObject():和getBean()等价,拿不到直接抛异常。stream():返回所有该类型的Bean,适合处理策略模式。
为什么说它均衡?因为它在调用处只暴露了一个“获取器”对象,不暴露ApplicationContext,业务代码里你感知不到容器存在。同时测试时特别好处理:直接mock一个ObjectProvider,让它返回你期望的实例就行,不需要处理CGLIB代理、不需要处理空方法体。
有个细节要注意:ObjectProvider本身是单例状态,它持有的“获取器”逻辑是固定的,但它每次调用getIfAvailable()都会重新走一遍容器查找。所以即使你把ObjectProvider注入到单例Bean里,每次获取到的依然是全新实例,多例语义完全成立。
// 把ObjectProvider当成注入型的延迟工厂来理解 // 单例Bean 持有一个 工厂引用,工厂每次产出新对象如果你还在Spring Boot项目里,用ObjectProvider还能玩出更多花样。比如配置项可选的时候,用ObjectProvider<DataSource> dataSourceProvider,启动时判断有没有注入数据源,没有就不初始化某些逻辑,比@Autowired(required = false)更灵活。
3.4 方案四:Scoped Proxy代理模式,让代理决定对象归属
最后一种方案理解起来稍微绕一些,但性能和使用体验都不错:通过@Scope配合proxyMode,让Spring注入一个代理对象,而不是真实对象。每次调用代理的方法时,代理会从容器里获取一个真实的原型Bean实例,然后转发方法调用:
@Component @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS) public class PriceCalculator { private BigDecimal taxRate; // 业务方法 }@Component public class OrderService { @Autowired private PriceCalculator priceCalculator; public BigDecimal processOrder(BigDecimal amount) { // 这里的 priceCalculator 其实是代理对象 // 每次调用方法时,代理从容器获取真实实例并转发 } }这个方案的核心在于:OrderService注入的根本不是PriceCalculator本身,而是一个PriceCalculator的代理子类(因为指定了TARGET_CLASS,会使用CGLIB创建代理)。每次你调用代理对象的方法,代理内部会执行类似getBean()的动作,拿到一个新实例,然后把调用转发给它。
这里有个容易混淆的点:ScopedProxyMode.TARGET_CLASS和ScopedProxyMode.INTERFACES的区别。前者是为类生成CGLIB代理,要求你的类不能被final修饰;后者是基于JDK动态代理,要求你的类实现至少一个接口。没有特殊需求的话,我建议直接TARGET_CLASS,避免为了一个代理去额外抽象接口。
这方案的优点是调用方完全无感,就是正常的@Autowired注入赋值,代码最干净,业务逻辑完全不需要关心“怎么拿到新实例”这件事。缺点是比较隐蔽——代理的生成和使用都是Spring在背后完成的,一旦团队里有人不了解这个机制,排查问题时很容易被“代理对象地址为什么变了”搞晕。
另外注意,加了proxyMode = ScopedProxyMode.TARGET_CLASS以后,PriceCalculator的每次方法调用都会触发一次代理到目标的转发。虽然Spring容器缓存了单例元数据,CGLIB代理的性能损耗其实很小(实测在普通业务规模下可以忽略不计),但如果你在一个循环里调用这个方法100万次,代理损耗就会变得肉眼可见,这时候就该考虑换ObjectProvider方案。
3.5 四种方案的综合对比与选型
我用一个表格把这四种方案的关键差异列出来,方便你对照选择:
| 方案 | 机制 | 调用处写法 | 单元测试友好度 | 性能损耗 | 推荐场景 |
|---|---|---|---|---|---|
| ApplicationContext手动getBean | 容器查找 | 需要传容器对象 | 低 | 每次getBean全流程 | 工厂类、策略动态获取 |
| @Lookup方法注入 | CGLIB重写方法 | 方法调用 | 低 | 每次方法调用一次getBean | 简单场景、不追求测试性 |
| ObjectProvider延迟获取 | 注入获取器 | getIfAvailable() | 高 | 每次获取一次getBean | 日常业务、条件装配、可测试优先 |
| Scoped Proxy代理模式 | 代理转发 | 透明使用 | 中 | 每次方法调用转发 | 几乎无感的场景、追求代码整洁 |
我个人现在的主力方案是ObjectProvider。原因很简单:它兼顾了代码可读性和可测试性,也不依赖CGLIB代理这种“编译期看不见的东西”。@Lookup适合快速修复问题,Scoped Proxy适合那种调用方代码已经写死了、不想改动太多的情况。
4. 实战踩坑记录与排查思路汇总
4.1 我踩过的三个典型坑
这里说的都是我在真实项目里踩过、并且花了不少时间才定位成功的问题,整理出来给你提个醒。
第一个坑:在构造器里用多例Bean,结果拿到了一个半初始化的对象。我那时候图省事,把PriceCalculator通过构造器注入OrderService。因为构造器注入发生在属性填充之前,如果PriceCalculator内部还依赖了其他Bean,那部分依赖可能还没完成装配,就提前被塞进来了。这个场景不是必现的,但如果PriceCalculator构造方法里访问了某个@Value配置,很可能取到null。
第二个坑:滥用@Scope("prototype")且忘记加proxyMode。我在代码里写@Scope("prototype"),然后通过@Autowired注入,发现不生效,于是改成proxyMode,却又因为类被final修饰导致启动直接失败。这个组合很折磨人——问题一个接一个,原因却分散在“作用域配置、注入方式、类定义结构”三个位置。
第三个坑:@Lookup方法里写了返回值。我一开始不信任Spring的魔法,方法体里写了return new PriceCalculator(),结果这个方法被覆盖,返回值逻辑没生效,反而自己new出来的对象被丢弃了。后来看了生成的字节码才明白,@Lookup方法体里写什么都没用,它就是一个给CGLIB占位的壳。
4.2 排查定位的标准流程
如果你接手了一个不熟悉的Spring项目,刚好遇到类似的多例失效问题,按照下面这个顺序排查,效率最高:
- 先确认这个Bean是单例还是多例:查看类上的
@Scope注解,或者去.class文件里看是不是有proxyMode。 - 确认宿主Bean是不是单例:
@Service、@Component默认都是单例,唯一的例外是加了@Scope("prototype")的宿主。 - 确认依赖注入的方式:
@Autowired是“启动时一次注入”,不是“使用时查询”。 - 判断业务是不是真的需要“每次新实例”:如果只是要一个无状态工具类,那多例本身就是伪需求;如果有状态需要隔离,那就用前面提到的方案去替换。
- 验证方案是否生效:打印
hashCode(),或者临时在构造函数里加日志,确认每次获取都是新对象。
还有一种情况容易误判:你在同一个线程的for循环里连续调用,发现对象的hashCode变了,于是以为多例生效了。其实如果宿主方法用了@Async或线程池,可能每次调用都会被不同的代理拦截,表现上像多例,实际并不是。所以验证时最好确认一下宿主方法的执行线程,避免被表象误导。
4.3 排查过程中最常用的几个配置与命令
我平时排查这类问题,会看两类信息:一类是Bean的定义信息,另一类是注入点的实际类型。
Spring Boot项目里,可以在启动类上临时加一个CommandLineRunner,把容器的Bean信息打印出来:
@Bean public CommandLineRunner printBeanInfo(ApplicationContext context) { return args -> { String[] beanNames = context.getBeanNamesForType(PriceCalculator.class); for (String name : beanNames) { System.out.println(name); System.out.println(context.getBean(name).getClass()); } }; }如果PriceCalculator被代理了,打印出的getClass()会是一个包含$$EnhancerBySpringCGLIB$$的类名,说明它已经是代理对象,不是原始类。这个细节帮助我判断到底是代理问题还是注入问题,非常有用。
另外,可以开启Spring的debug日志,看DefaultListableBeanFactory创建Bean时的Creating shared instance of singleton bean或者Creating instance of bean这类输出。单例Bean出现“Creating shared instance of singleton bean”日志,而多例Bean每次创建都会出现“Creating instance of bean”且没有“shared”字样。日志频率和打印时机能直接反映Bean的创建行为。
4.4 从设计层面避免再踩这个坑
排查归排查,真正有效的还是从设计上绕开这个坑。说实话,多例Bean在Spring里面的存在感一直很微妙:它提供了一个“每次新建”的语义,但这个语义只对getBean()生效,对依赖注入不负责。这就导致它特别容易和“注入”产生语义冲突。
我现在的习惯是:优先考虑无状态设计。绝大多数业务场景根本不需要有状态的Bean,把状态设计成方法参数传进去,既无单例多例的烦恼,又容易做并发处理。只有真正需要“每个调用方拥有独立状态”的场景,我才会引入多例,并且强制配套使用ObjectProvider或@Lookup,绝不让多例Bean直接出现在@Autowired的字段上。
经验之谈:如果团队里有新人,写代码时最好规定“多例Bean只允许通过
ObjectProvider或@Lookup获取”。直接在字段上注多例,靠自觉和代码review拦截,终归是不够保险的。
我个人整理了一套简单的自查清单,写代码时顺手过一遍:
- 当前类是有状态的(成员变量会变化)吗?如果不是,用单例就行。
- 如果是,这个状态是“每次调用独立”还是“调用方独立”?
- 每次调用独立:用无状态方法参数传递,或者每次
getBean()。 - 调用方独立:用
ObjectProvider或@Lookup,不要直接把多例Bean注入字段。
4.5 常见问题速查表
我把这类问题的高频疑问整理成一张速查表,平时群里有同事问,我基本直接丢这张表过去:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 多例Bean注入到单例后表现为单例 | 属性注入发生在宿主Bean创建时 | 改用ObjectProvider、@Lookup或getBean() |
| 启动报错:Cannot subclass final class | @Lookup或CGLIB代理无法作用于final类 | 去掉final修饰,或改为ObjectProvider方案 |
| 多例Bean每次调用方法都生成新实例,但状态仍串扰 | 代理对象方法转发时,状态存储位置不对 | 确认状态字段在真实实例上,而不是在代理类上 |
| 方法体内返回null,但调用处拿到了对象 | @Lookup方法被CGLIB重写,方法体执行了容器查找逻辑 | 方法体无需实现,保持返回null即可 |
| 同一个类既想单例又想多例 | 依赖注入方式混用 | 用@Lookup或ObjectProvider分开获取 |
每次调用getBean()耗时长 | 原型Bean创建过程重(如依赖多、初始化逻辑重) | 检查是否存在循环依赖或过重的初始化逻辑 |
这张表不完整,但覆盖了90%以上的日常问题。遇到没覆盖的新问题,回到第4.2节的排查流程走一遍,比自己瞎试快得多。
5. 写在最后的几个小建议
这篇文章讲得比较长了,最后聊几句实在话。
单例注入多例这个问题,本质上不是Spring的缺陷,而是开发者对“依赖注入时机”理解不够造成的使用误用。你只要想清楚一点:@Autowired是“创建时一次性给值”,不是“每次使用时现取”,大部分坑都能躲开。
如果你问我排这种问题最深刻的体会是什么,我会说:不要迷信注解的名字。@Scope("prototype")不代表“你注入的一定是新的”,它只代表“容器每次创建时是新的”。至于注入动作本身发生在什么时候,取决于宿主Bean的作用域,这一点才是关键。把这两件事在脑子里分开,所有迷惑都迎刃而解了。
最后分享一个实用的小技巧:如果碰到不明不白的Spring bean行为异常,先在临时环境里写一个ApplicationContextRunner或者直接用AnnotationConfigApplicationContext做最小复现,把问题从业务上下文里剥离出来。大多数时候,你会在剪枝后的代码里一眼看到问题本质,比在庞大的项目里瞎翻配置高效得多。