ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring单例Bean注入prototype失效?5种多实例注入方案全解析

Spring单例Bean注入prototype失效?5种多实例注入方案全解析 前一阵子有个朋友去大厂面试回来跟我吐槽“问我Spring默认的Bean是单例还是多实例这我知道。回头又问——单例Bean里注入一个prototype的Bean每次调用为什么拿到的还是同一个对象我当时答得含糊回来一验证还真是这样。”他说完我笑了这个问题看着基础其实是一道经典的分水岭题背过面试题的人能答出“默认单例、可以用prototype”但对Spring依赖注入的“解析时机”没有感知的人第一次在代码里遇到多实例注入失效时十有八九都是懵的。这篇博文就把“Spring多实例注入”这件事从头到尾讲透。我会从singleton和prototype的作用域区别讲起说清楚为什么在单例Bean里注入原型Bean会“失效”然后给出5种真正可靠的多实例注入方案包括ObjectProvider、Lookup、ApplicationContext手动获取、Scoped Proxy等等每一种都附上代码和适用场景。再用源码级别的方式拆一遍Spring创建Bean的链路最后整理一份踩坑排查清单。不管你是写业务代码时被这个问题坑过还是在准备Spring面试或者单纯想搞明白IoC容器到底是怎么管Bean的这篇文章都值得看完。1. 先说清楚Spring容器里Bean的单例和多实例到底怎么区分1.1 默认单例省资源和线程安全的取舍Spring容器创建Bean时默认Scope是singleton。这个概念指的不是GoF那套“整个JVM里类只有一个实例”的单例模式而是“在同一个Spring容器里一个BeanDefinition只产生一个实例”。这个实例在容器启动阶段或者第一次获取时取决于是否设置了lazy-init被创建出来然后放进一个Map里——具体对应到源码就是DefaultSingletonBeanRegistry的singletonObjects字段。后续所有通过getBean、依赖注入、各种依赖引用去拿这个Bean的地方拿到的都是这个缓存的同一个对象。这个设计的好处非常明显省内存、省创建开销。一个Spring Boot项目动辄几百上千个Bean如果每次注入都复制一份内存开销会大得离谱。而且实际业务里绝大多数的Bean比如Service、Mapper、配置类、工具类本身都是无状态的——没有可变的成员变量或者只有被设计成线程安全的成员变量。既然没有状态多实例就毫无意义反而会造成资源浪费。但“无状态”这个前提非常关键。如果你写了一个单例Bean却给它加了可变字段比如一个List或者Map用来临时存储上下文数据那么在并发请求下这个共享实例就会成为竞态条件发生的温床。所以做单例Bean时要养成一个习惯成员变量尽量是不可变的要么用final要么只存线程安全的对象。早期很多人刚从new对象转到Spring开发时习惯性地在Bean里塞共享的缓冲区结果线上出现各种诡异的数据错乱排查到最后都是单例Bean持有可变状态惹的祸。1.2 prototype的适用场景带状态的“一次性组件”与singleton相对的是prototype这种Scope的含义是每次从容器获取这个BeanSpring都会创建一个全新的实例交给调用方。创建完之后容器就不再跟踪它了——不缓存、不负责后续的销毁管理这个实例的整个生命周期由获取它的代码自己掌控。那什么场景真正需要prototype我的经验是两类。第一类是有状态的工作组件。比如你要设计一个“一次订单导出任务”用的Excel导出器它内部需要记录当前导出到哪一行、累计处理了多少条、总共需要几步。如果这个导出器是单例的两个用户同时触发导出时这些状态就会互相覆盖。把它声明为prototype让每个任务拿到一个独立的导出器状态隔离就天然达成了。第二类是重量级但线程不安全的资源。比如SimpleDateFormat、某些非线程安全的加密器、数据库连接池中需要独立维护连接上下文的会话对象等等。每次获取都整个新副本代码里就可以放心修改它的内部状态不用在外面加锁。给你一个容易理解的类比单例Bean就像办公室公共区域的那台打印机谁拿来都能打但你要是往里面放自己的草稿纸下一个用的人就会看到你的东西。prototype则像是文具柜里的一次性便签本谁领一本自己用写写画画随便造用完扔掉也不心疼。理解了这层语义很多设计决策就会清晰很多。1.3 除了单例和原型还有哪些Scope除了singleton和prototypeSpring针对Web环境还提供了request、session、application这些Scope。request的意思是一次HTTP请求范围内共享一个实例session是一次用户会话范围内共享一个实例application则是ServletContext级别的单例基本等同于singleton但作用域范围更贴合Web容器的语义。这些Web Scope有一个共同特点它们不是“每次获取都是新对象”而是“每次事件请求/会话范围内是同对象跨事件各自独立”。为了实现这个效果Spring通常配合Scoped Proxy来注入——给一个普通Bean外面套一层代理每次调用代理方法时代理内部再根据当前请求上下文去拿真正的目标实例。这个机制跟后面讲到的prototype注入口径是相通的理解了prototype的注入陷阱对这些Web Scope也会无师自通。2. 最经典的坑单例Bean里注入prototype Bean为什么“失效”2.1 现场还原一个看起来没问题的注入先看一段很典型的错误代码。假设我有一个MessageBuilder被声明为prototype它有内部状态需要被复用之后重置Component Scope(prototype) public class MessageBuilder { private StringBuilder content new StringBuilder(); public void append(String text) { content.append(text); } public String build() { return content.toString(); } } Component public class MessageService { Autowired private MessageBuilder messageBuilder; public String createMessage(String prefix, String suffix) { messageBuilder.append(prefix); messageBuilder.append(suffix); String result messageBuilder.build(); // 期望每次调用都是一个新的Builder所以这里重置就没有必要了 return result; } }写这段代码的同事当时的想法是MessageBuilder不是写了Scope(prototype)吗那我每次调用createMessage注入进来的messageBuilder不都应该是新的吗结果呢完全不是这么回事。你连续调用十次createMessage拿到的result会越来越长——因为messageBuilder始终是同一个实例内部那个可变StringBuilder一直在累积内容。2.2 为什么会这样依赖注入的“时机”问题问题的根源在于Spring的依赖注入不是每次调用方法时都“现场取数”而是在创建MessageService这个单例Bean的那一刻就把当时能拿到的messageBuilder实例给固定下来了。具体流程是这样的。容器启动时Spring发现MessageService是一个singleton的Bean于是开始实例化它。在实例化过程中Spring会对它的Autowired字段做依赖解析去容器里找MessageBuilder。因为MessageBuilder的Scope是prototype所以容器就现场创建了一个新实例然后把这个实例注入给MessageService。关键点来了这个注入动作只发生一次而且发生在容器启动阶段。MessageService创建完成之后它的字段messageBuilder就永远指向那个已经被创建出来的原型实例了。之后无论你调用多少次createMessage用的都是同一个固定引用。这正是“prototype作用域”和“依赖注入时机”叠加在一起产生的坑。prototype语义只约束“Spring每次解析这个依赖时都会新建实例”但MessageService的依赖解析已经结束了Spring不可能在每次方法调用时再帮你重新解析一遍。用代码来理解的话Spring内部做的事其实等价于MessageService service new MessageService(); service.messageBuilder beanFactory.getBean(messageBuilder); // 只执行一次所以如果你是想“每次调用都拿新实例”那么把prototype声明放在被注入的Bean上是不够的必须让“获取动作”本身延迟到方法调用时。2.3 这个锅该不该让三级缓存背聊到这个问题时很多人会联想到Spring三级缓存。这里我必须先泼一盆冷水三级缓存和prototype注入完全不是一回事。Spring的三级缓存singletonObjects、earlySingletonObjects、singletonFactories解决的是单例Bean之间的循环依赖问题比如A依赖B、B又依赖A两个都是singleton时容器能让它们顺利创建出来。它的边界非常清晰只服务于singleton作用域的Bean。prototype Bean压根不进入三级缓存的体系。你在源码里可以发现getSingleton方法只会处理singleton的Bean而prototype Bean每次都是直接调用createBean去新建实例既不会检查一级缓存里的单例池也不会有早期引用暴露给它。换句话讲如果你写了一个prototype Bean它又跟另外一个prototype Bean互相循环依赖Spring不会给你任何缓存的“迂回通道”会直接抛出BeanCurrentlyInCreationException。关于这一点后面第4节讲源码时会再展开。所以遇到“多实例注入失效”的问题正确的排查方向不是去看三级缓存而是回到依赖解析时机本身进入容器之前就知道“要新实例”和每次调用都“现场要新实例”这是两个完全不同的需求对应的是不同的实现手段。3. 多实例注入的5种可靠方案从推荐到兜底既然前面的普通注入方式靠不住那真正想实现“每次获取都是新实例”应该怎么写我在实战中用的最多的有5种方案按推荐程度和适用场景逐一介绍。3.1 ObjectProvider最优雅的延迟获取方式首选推荐的是ObjectProvider。它是Spring 4.3引入的一个注入点类型专门用来表达“我不知道是否一定有这个Bean也不希望依赖被过早锁定等我真正要用的时候再去容器里取”。其实它内部核心就是封装了一个“获取Bean的工厂方法”你在需要对象时才去调用。Component public class MessageService { private final ObjectProviderMessageBuilder builderProvider; public MessageService(ObjectProviderMessageBuilder builderProvider) { this.builderProvider builderProvider; } public String createMessage(String prefix, String suffix) { // 每次调用 getObject() 都会触发一次新的解析 MessageBuilder builder builderProvider.getObject(); builder.append(prefix); builder.append(suffix); return builder.build(); } }这代码干净、易懂并且还带来了一个额外好处如果MessageBuilder的实例创建需要依赖当前上下文里的某些参数ObjectProvider还提供了getIfAvailable和getIfUnique这些条件方法多实例搭配可选依赖的场景也能优雅处理。这里唯一要提醒的是getObject()拿到的实例Spring是按照“常规依赖解析”创建的所以如果MessageBuilder自身又依赖了别的Bean那会正常装配没有问题。3.2 Lookup方法注入Spring官方文档推荐的经典方案如果你不想在类里多放一个Provider成员变量也可以对方法标注Lookup。这是Spring官方文档里专门介绍过的做法。具体写法是这样的Component public abstract class MessageService { public String createMessage(String prefix, String suffix) { MessageBuilder builder getMessageBuilder(); builder.append(prefix); builder.append(suffix); return builder.build(); } Lookup protected abstract MessageBuilder getMessageBuilder(); }这里有几个细节需要注意。首先标注了Lookup的方法会被Spring重写——Spring在创建这个Bean的时候如果发现Bean类里有Lookup方法就会生成一个子类通过CGLIB覆盖掉这个Lookup方法覆写后的方法体内部逻辑就是“去容器里拿getBean(MessageBuilder.class)”。换句话说这就是把“手动getBean”的逻辑藏到了框架生成的子类里然后每次调用这个抽象方法时都会执行一次新的获取。Lookup方法的限制同样要清楚方法不能是private不能是final类本身也不能是final否则CGLIB无法实现子类覆盖。还有你如果已经把类声明为抽象类没问题如果不想改类为抽象类方法有实体也行——Spring支持把Lookup写在有方法体的方法上只是那个方法体里的逻辑会被覆盖掉所以平时没必要写实现。这种方法适合不想引入额外Provider字段的场合不过可读性略差很多初级同事看到抽象方法加Lookup会愣一下。3.3 ApplicationContext手动获取简单粗暴的兜底如果项目里你已经能拿到ApplicationContext最直接的方式就是自己调用getBean方法。我之前在一些老项目里见过这样写的代码Component public class MessageService implements ApplicationContextAware { private ApplicationContext applicationContext; Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext applicationContext; } public String createMessage(String prefix, String suffix) { MessageBuilder builder applicationContext.getBean(MessageBuilder.class); builder.append(prefix); builder.append(suffix); return builder.build(); } }这种写法最大的优点就是直白没有任何“魔法”。但直白也意味着耦合——业务代码直接依赖了Spring容器的API后续做单元测试的时候mock这个ApplicationContext会比较麻烦。我的建议是如果你不介意类里多一个容器引用那么用它做兜底没问题但如果你是在写一个需要被复用的库或者某个模块未来可能要移植到其他框架那最好还是用ObjectProvider。3.4 Scope proxyMode把代理伪装成“普通Bean”前面提到的几种方案都是在获取时动手脚。还有一种思路是让你“持有”的对象本身就是一个代理——每次调用代理上的方法代理再去找容器要一个原型实例来执行。这个方案就是设置Scope的proxyMode参数Component Scope(value prototype, proxyMode ScopedProxyMode.TARGET_CLASS) public class MessageBuilder { ... } Component public class MessageService { Autowired private MessageBuilder messageBuilder; public String createMessage(String prefix, String suffix) { // messageBuilder 其实是一个CGLIB代理 messageBuilder.append(prefix); messageBuilder.append(suffix); return messageBuilder.build(); } }这方案最大的优点是调用方代码瞬间就变成普通的字段注入了不用写Provider也不用写Lookup看着就跟注入一个普通Bean一样。缺点也很明显——你注进来的不是真正的MessageBuilder而是一个CGLIB代理这对很多人的心智模型是个挑战。而且这里有个特别值得注意的坑prototype的语义本来就是“每次获取一个新实例”给prototype加代理之后你每次调用代理方法时代理内部都会去获取一个新的目标实例所以多个方法调用可能操作的是不同实例这就会导致“一次业务逻辑里成员变量A的值在另一个方法里看不到了”。举个具体例子如果MessageBuilder里有个setContext方法来设置上下文后续的append方法依赖这个上下文代理模式就无法保证状态在同一业务操作中的连贯性。所以proxyMode更适合request、session这类“同一范围内需要共享一个实例”的场景用在prototype上时要非常谨慎。这也是我把它排在第4位的原因。3.5 有效方案对比与选型建议把5种方案摆在一起做个对比你可以直接根据自己的场景挑方案代码侵入性是否支持每次新建适合场景注意点Autowired 普通字段注入低否让我死心用现在这个不推荐用于多实例ObjectProvider中是绝大多数业务场景依赖注入点要构造器或字段Lookup抽象方法中是不想加Provider字段时方法不能private/final类不能finalApplicationContext.getBean高是紧急兜底、老项目改造耦合容器API测试不友好Scope(proxyMode)最低是但状态不连续需要兼容普通字段注入时代理目标和scope语义要小心如果让我给一个优先级我会这样排能用ObjectProvider就用ObjectProvider它语义清晰、官方推荐、测试也方便如果代码结构不允许你再塞Provider那就用Lookup只有当你已经拿到了ApplicationContext、且懒得再做任何改造时才考虑getBean。Scoped Proxy的proxyMode要放在最后因为你得先想明白代理的语义细节否则会在生产上踩到更隐蔽的问题。4. 源码视角Prototype Bean到底是怎么创建出来的4.1 核心入口doGetBean里的一次“分叉”要真正理解多实例注入绕不开Spring的Bean创建入口。AbstractBeanFactory里有个著名的doGetBean方法作用就是根据Bean名称解析出一个Bean实例。我简化一下这个方法的骨架逻辑重点看单例和原型的区别protected T T doGetBean(...) { // 1. 尝试从单例缓存中获取 Object sharedInstance getSingleton(beanName); if (sharedInstance ! null) { bean getObjectForBeanInstance(sharedInstance, ...); } // 2. 如果缓存中没有说明Bean尚未创建或者是prototype else { if (mbd.isSingleton()) { // 单例走这里lambda被送入getSingleton方法里面负责创建并缓存 sharedInstance getSingleton(beanName, () - { return createBean(beanName, mbd, args); }); bean getObjectForBeanInstance(sharedInstance, ...); } else if (mbd.isPrototype()) { // 原型走这里根本不进缓存直接创建直接返回 Object prototypeInstance createBean(beanName, mbd, args); bean getObjectForBeanInstance(prototypeInstance, ...); } else { // 其余Scope比如request、session会走scope.get(...)分支 } } }这段代码只要看懂一个核心差异就行单例Bean的创建过程中Spring调用了getSingleton方法里面不仅负责new对象还会把创建好的实例放进singletonObjects缓存。而prototype分支没有走任何缓存每次都是裸调用一次createBean创建完直接返回使用。这就是prototype语义在源码层面的最终落点——没有三级缓存也没有实例复用的任何设计。4.2 依赖解析也是一个“递归的getBean”你可能会问那为什么单例Bean里注入prototype注入时每次只来了一次这个问题的答案同样藏在doGetBean里。当Spring创建MessageService这个单例时它会解析构造器参数或字段依赖对messageBuilder执行一次getBean(MessageBuilder.class)。因为MessageBuilder是prototype所以那次getBean就当场创建了一个实例。名字关键的地方在于——这次getBean是在创建MessageService的过程中递归调用发生的只有一次调用完就没有后续了。MessageService本身随后进入缓存它的字段被固定指向那个实例。这也解释了为什么Autowired、构造器注入、Setter注入这些常规注入方式对于prototype都没用它们都发生在“为宿主Bean做依赖解析”这一个时间点既不能影响宿主创建完成之后的行为更不能在每次宿主方法被调用时重新触发。Spring作为IoC容器它的IoC语义是“你声明了依赖关系我帮你装配”而不是“你每次执行逻辑时我都帮你调一次服务”。4.3 原型Bean的循环依赖为什么直接报错第2节里提过三级缓存只管单例这里展开说。还记得三级缓存里最精髓的entry是什么吗一级缓存是完整对象二级缓存是早期对象三级缓存是“对象工厂”。当A和B两个单例Bean互相依赖时A在创建过程中发现自己需要B就去创建BB又发现自己需要A这时A虽然还没彻底初始化完成但三级缓存里已经有A的ObjectFactory了可以提前暴露一个早期引用给B这样链子就能接上。但对prototype Bean来说这个机制完全不存在。它每次都是“从零创建”不会把实例放入任何缓存。所以当prototype的A依赖prototype的B、prototype的B又依赖prototype的A时A创建里去创建BB创建里又去创建A双方都找不到对方只能一路递归到底最后直接触发StackOverflowError或者BeanCurrentlyInCreationException。这个现象我在本地实验过好多次每次一旦在prototype里写出循环依赖Spring连一点容错的意思都没有。所以如果你在设计与重构上遇到“多实例Bean循环依赖”的问题别指望框架帮你兜底解决办法只有两个方向要么把其中一个改成singleton要么把互相依赖的部分拆出去独立为第三个Bean。5. 线上排查实录当“多实例注入”出了问题时怎么查5.1 排查“scopeprototype但拿到的仍是同一个实例”的思路这种问题出现时不要慌按以下步骤排查基本能定位。第一确认Bean定义本身到底是不是prototype。很多时候我们把Scope写在了接口的实现类上但注入类型是接口Spring会按照那个接口对应的BeanDefinition去判断scope如果中间代理或者包装过scope的判断可能和你预期的不是同一个Bean。第二检查注入点类型是否被AOP代理过。比如被Transactional、Async等切面拦截过的BeanSpring会生成代理对象而代理对象实际持有的目标可能是一个单例。第三确认是不是从“同一个容器”里拿的。如果你在一个多容器应用比如Spring Boot的父子容器体系里容器A和容器B各自维护自己的单例缓存跨容器注入是很常见的混淆点。5.2 一个真实案例明明用的是ObjectProvider还是出现串状态之前有个朋友的项目里代码已经用了ObjectProvider但线上还是出现了数据串场。我看了代码之后发现问题不在注入方式而在取用方式。他是这么写的Component public class ReportService { private final ObjectProviderReportBuilder provider; Autowired public ReportService(ObjectProviderReportBuilder provider) { this.provider provider; } public void process(ListLong ids) { ReportBuilder builder provider.getObject(); // 这是对的 // 但他在别的地方把这个builder 塞进了ThreadLocal ExportContext.holder.set(builder); // ... } }问题出在另一个同事在ExportContext里用ThreadLocal缓存了builder。这样同一个线程的后续操作复用同一个builder跨用户时线程池里的线程一复用状态就串了。这不是Spring的注入问题是应用程序自己做的长期缓存。排查的时候要记住Spring只管到getObject()这一下拿到对象之后怎么存、怎么传全部是你自己的代码责任。如果对象是有状态的务必不要在ThradLocal、全局Map、静态变量里长期持有它。5.3 与Async线程池结合时的特别警告还有一个更容易踩坑的地方prototype Async。Async默认情况下是通过代理把方法提交到线程池执行的如果你注入的是一个原型Bean然后在一个Async方法里使用了这个Bean你会发现这个“原型实例”虽然是由Spring创建的但它是在创建代理方法参数时生成的而不是在异步方法体内部生成的。也就是说异步线程里看到的实例仍然是在进入代理方法之前就固定下来的那个跟“每次调用都是新实例”的预期又产生了偏差。所以我自己的最佳实践是这样的prototype只用于“同步方法内部、直接获取并立即使用”的场景一旦涉及线程池、异步、消息队列这种执行边界先把要传递的状态数据拷贝出来再基于这些数据在子线程内部另起一个prototype实例。不要试图让一个prototype实例跨线程传递那基本等于跟Spring的作用域语义对着干。5.4 常见问题速查表现象可能原因解决动作Scope(prototype)后注入的还是同一个对象普通注入方式被依赖解析时机固定了改用ObjectProvider或Lookup报BeanCurrentlyInCreationExceptionprototype Bean中存在循环依赖改为singleton或拆分依赖用了proxyMode方法调用间状态丢失prototype代理每次方法调用都获取新实例proxyMode仅建议用于request/sessionprototype慎用线程池中proto实例状态串了ThreadLocal/静态变量长期持有实例删除缓存跨线程时重建实例用Lookup时报错方法或类是private/finalCGLIB无法覆盖改为protected/public且非final容器关闭时proto实例资源未释放Spring不管理prototype的销毁手动管理生命周期或使用可关闭的包装类排查工具上我一般建议在怀疑scope相关问题时直接在方法里用ApplicationContext打印当前对象的class类型看是不是CGLIB代理以及打印对象的System.identityHashCode看是不是同一个。identityHashCode是一个特别快的判断手段连续调用两次如果hashCode一样说明是同一个实例不一样说明确实每次新建了。6. 结尾一点个人经验最后说点实在的。我在带团队时遇到过很多次因为多实例注入理解不清引发的线上问题但踩过几次坑之后我自己的习惯已经固化成了一套非常简单的心法默认全部写单例、保持无状态真遇到“必须要独立状态”的场景就先判断这个状态的生命周期是“一次调用”还是“一个会话”。一次调用就优先用ObjectProvider做延迟获取一个会话就让Session级别的工具去管理。慎用Scoped Proxy尤其不要在原型的代理上做太复杂的业务逻辑否则代码读起来会像在猜盲盒。另外如果你在准备Spring面试我特别建议你不要只背结论可以自己写两个极小的类——一个单例Service、一个prototype的Builder然后分别试Autowired注入、ObjectProvider注入、Lookup注入三种写法再用identityHashCode对比一下输出。这个过程30分钟都用不到但体验过后你对Spring依赖注入“何时解析、何时创建、何时缓存”的理解就会扎实很多远比记住一百个面试题有效。下次面试官如果再问“Spring多实例注入为什么失效”你就可以从依赖解析时机讲到doGetBean判断作用域的分叉再从三级缓存的边界讲到Scoped Proxy的语义这套链路下来基本就是加分答案了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进