深入解析Spring循环依赖:从原理到@Async场景的异常剖析

作者:da吃一鲸8862026.11.07 02:18浏览量:0

简介:本文从一个实际项目中的报错案例出发,深入剖析Spring容器解决循环依赖的核心机制——三级缓存与早期引用暴露。文章不仅解释了常规构造器与Setter注入下循环依赖被解决的原理,更重点分析了当循环依赖与@Async注解结合时为何会抛出BeanCurrentlyInCreationException异常。通过阅读本文,读者将透彻理解Spring Bean生命周期、代理创建时机以及循环依赖处理的边界条件,掌握此类问题的排查思路与解决方案。

深入解析Spring循环依赖:从原理到@Async场景的异常剖析

一、从一个意想不到的报错说起

在日常开发中,Spring框架凭借其强大的依赖注入能力,极大地简化了对象之间的装配工作。然而,当业务逻辑变得复杂时,我们偶尔会遇到一些看似矛盾的现象:Spring官方宣称其能够处理循环依赖问题,但某些特定场景下,启动容器却会抛出异常。

先看一段典型的业务代码:

  1. @Service
  2. public class TestService1 {
  3. @Autowired
  4. private TestService2 testService2;
  5. @Async
  6. public void test1() {
  7. // 异步业务逻辑
  8. }
  9. }
  10. @Service
  11. public class TestService2 {
  12. @Autowired
  13. private TestService1 testService1;
  14. public void test2() {
  15. // 同步业务逻辑
  16. }
  17. }

在这段代码中,TestService1注入了TestService2,而TestService2又反向注入了TestService1,二者构成了典型的循环依赖关系。唯一显得有些特殊的地方在于,TestService1的test1()方法上标注了@Async注解,表明该方法需要异步执行。

当我们尝试启动项目时,控制台却无情的抛出了如下异常:

  1. org.springframework.beans.factory.BeanCurrentlyInCreationException:
  2. Error creating bean with name 'testService1':
  3. Bean with name 'testService1' has been injected into other beans [testService2]
  4. in its raw version as part of a circular reference,
  5. but has eventually been wrapped.
  6. This means that said other beans do not use the final version of the bean.

这个异常信息告诉我们:testService1在创建过程中,其原始实例(raw version)被注入到了testService2中作为循环引用的一部分,但最终该Bean却被包装(wrapped)了。这意味着其他Bean没有使用到Bean的最终版本。

这个报错令人困惑——因为在常规理解中,Spring是可以解决循环依赖的。让我们做一个对照实验:将@Async注解从test1()方法上移除,保持循环依赖关系不变,再次启动项目,一切正常,容器能够顺利启动。

这究竟是为什么?同样是循环依赖,为什么添加了@Async注解就出现问题?要解答这个疑惑,我们必须从Spring解决循环依赖的底层机制说起。

二、Spring解决循环依赖的核心机制

2.1 依赖注入的两种常见方式

在Spring中,解决循环依赖的能力与Bean的注入方式密切相关。常见的依赖注入方式分为构造器注入和Setter/字段注入两种。

如果我们使用的全部是构造器注入:

  1. @Service
  2. public class ServiceA {
  3. private final ServiceB serviceB;
  4. @Autowired
  5. public ServiceA(ServiceB serviceB) {
  6. this.serviceB = serviceB;
  7. }
  8. }

在这种情况下,Spring无法解决循环依赖。原因在于,构造器注入要求在Bean实例化(即调用构造方法)的瞬间就获取到依赖的完整对象,而构建一个完整对象的前提是它的依赖也已经被完整构建。这就形成了一个无解的死锁。

而如果我们使用的是Setter注入或基于@Autowired字段注入(如开头案例所示),Spring则可以通过提前暴露早期引用的方式来打破僵局。

2.2 三级缓存:Spring解决循环依赖的基石

Spring容器内部维护了一个三层缓存结构,用来在Bean的生命周期不同阶段暂存对象。这三层缓存分别是:

  1. 一级缓存(singletonObjects):存放已经完成全部初始化流程、随时可供外部使用的成品Bean。
  2. 二级缓存(earlySingletonObjects):存放已经实例化但尚未完成属性填充和初始化的早期Bean对象。
  3. 三级缓存(singletonFactories):存放Bean的工厂方法,该工厂方法能够生成Bean的早期引用。这里的工厂通常是一个ObjectFactory对象,其getObject()方法负责生成早期引用。

整个流程的核心思路如下:

  1. 当Spring开始创建一个单例Bean时,首先从缓存中查找是否存在。若不存在,则进行实例化流程。
  2. Bean先通过构造函数被实例化为一个原始对象(此时还未填充属性)。
  3. Spring随即将该原始对象包装为一个ObjectFactory,放入三级缓存中。这一步的目的是为了“暴露”该Bean的早期引用,以便其他依赖它的Bean能够在它彻底完成初始化之前就拿到它的引用。
  4. 接下来,Spring继续进行属性填充(调用populateBean方法)。在此过程中,如果发现需要注入其他Bean,而该Bean又依赖当前Bean,那么对方就可以通过三级缓存中的ObjectFactory获取到当前Bean的早期引用。
  5. 当所有属性填充完成、初始化方法执行完毕后,Spring再将成品Bean放入一级缓存中,同时清理二三级缓存。

2.3 常规循环依赖的解决过程回放

基于上述三级缓存机制,我们可以梳理一下常规Setter注入循环依赖的完整解决过程。假设存在ServiceA和ServiceB互相依赖:

  1. Spring开始创建ServiceA。
  2. ServiceA被实例化为原始对象a。
  3. 将a封装为ObjectFactory,放入三级缓存。
  4. 开始对ServiceA进行属性填充,发现需要注入ServiceB。
  5. Spring尝试获取ServiceB,发现其尚未创建,于是开始创建ServiceB。
  6. ServiceB被实例化为原始对象b,同样将其固化到三级缓存。
  7. 对ServiceB进行属性填充,发现需要注入ServiceA。
  8. Spring查找ServiceA——此时在一级缓存中找不到,但可以从三级缓存中拿到ServiceA的ObjectFactory,调用其getObject()得到ServiceA的早期引用a。
  9. 将早期引用a注入到ServiceB中,并继续完成ServiceB的后续初始化流程,最后将成品ServiceB放入一级缓存。
  10. 回到ServiceA的创建流程,将从一级缓存中取到的ServiceB注入其中。
  11. 完成ServiceA的整个初始化,将成品ServiceA放入一级缓存。

整个过程环环相扣,保证了容器能够顺利启动。

三、@Async注解为什么会打破平衡

3.1 代理的引入:容器过程中的“包装”

要理解@Async导致的异常,我们必须先认识到Spring AOP(面向切面编程)在容器创建过程中的作用。当某个类上或某个方法上标注了@Async注解时,Spring会认为这个Bean需要被增强,以便在调用该方法时能异步执行。这种增强是通过代理(Proxy)来实现的。

Spring在Bean的生命周期中存在一个关键阶段,即initializeBean方法中包含了applyBeanPostProcessorsAfterInitialization步骤。在这个步骤中,各个BeanPostProcessor会对初始化的Bean进行后置处理。AsyncAnnotationBeanPostProcessor会检查Bean是否需要应用异步代理,如果需要,它会返回一个代理对象来替代原始对象。

这里的关键点在于:代理对象的生成时机通常是在Bean完成属性填充之后、初始化完成之后的阶段,而不是在实例化完成之后就立刻生成。

3.2 事情为什么会败露:代理替换与早期引用的矛盾

现在,我们将三级缓存机制与@Async代理结合起来,重新审视开头那个失败案例。

  1. TestService1开始创建。实例化得到原始对象rawA。
  2. rawA被包装为ObjectFactory放入三级缓存。注意:此时放入的ObjectFactory默认返回的就是原始对象rawA。
  3. 对TestService1进行属性填充,发现需要注入TestService2,于是开始创建TestService2。
  4. TestService2实例化得到rawB,同样将其工厂放入三级缓存。
  5. 对TestService2进行属性填充,发现需要注入TestService1。
  6. TestService2尝试获取TestService1。从三级缓存中取得ObjectFactory,调用其getObject()方法,得到的仍然是原始对象rawA,而非代理对象。
  7. 将原始对象rawA注入到TestService2中,继续完成TestService2的后续创建流程。
  8. 回到TestService1的创建流程,完成其属性填充。到了initializeBean阶段,AsyncAnnotationBeanPostProcessor发现TestService1需要异步增强,于是生成了代理对象proxyA,并将该代理对象作为最终的Bean返回。
  9. Spring将proxyA放入一级缓存,但此时TestService2内部持有的仍然是原始对象rawA。

此时,容器发现了一个严重的状态:TestService2中注入的TestService1并不是最终的代理版本proxyA。这意味着在后续运行时,testService2调用testService1的方法时,将无法触发异步逻辑,因为原始对象不具备异步能力。这种不一致会破坏整个容器的核心语义。

于是,Spring的安全机制被触发——它抛出BeanCurrentlyInCreationException异常,明确告知开发者:testService1已被作为原始版本注入到其他Bean中,最终却被包装了,其他Bean无法使用到Bean的最终版本。Spring宁可拒绝提供服务,也不允许产生这种状态不一致的隐患。

四、为什么去掉@Async就能正常运行

当我们将@Async注解去掉之后,TestService1在整个创建过程中不需要生成任何代理对象。它从三级缓存中暴露出来的ObjectFactory.getObject()返回的就是原始对象,并且这个原始对象就是最终要放入一级缓存的回结果。

因此,TestService2拿到的早期引用和最终的成品是同一个对象,状态完全一致,不存在代理替换的问题。三级缓存机制完美闭环,Spring当然可以安全地解决这个循环依赖。

但如果被循环依赖的Bean恰好涉及AOP(如@Async、@Transactional等需要在初始化后生成代理的切面),我们就必须考虑代理生成时机与循环依赖如何处理之间的冲突了。

五、深入思考:为什么三级缓存不直接存代理

一个值得思考的问题是:为什么Spring不直接在三级缓存的ObjectFactory中返回代理对象,从而避免后续的“包装不一致”问题?

答案是:代理的生成需要依赖于Bean的完整状态,而这个完整状态只有在Bean完成属性填充后才能获得。如果在实例化之后就立刻生成代理,此时Bean的成员变量都还没有注入,生成的代理也是无效的,因为它无法正确委托给一个半成品的目标对象。

另外,并非所有循环依赖中的Bean都需要代理。三级缓存的精妙之处在于延迟了“是否产生代理”的决策。Spring使用ObjectFactory而不是直接返回早期实例,就是为了留出余地:如果后续发现这个Bean需要增强,并且该增强必须在循环依赖处理之前完成,则可以通过自定义的SmartInstantiationAwareBeanPostProcessor来干预getEarlyBeanReference方法,提前生成代理。

但@Async的实现机制并未参与到早期引用的生成逻辑中,其代理生成被延后到了初始化阶段,因此就导致了上述的问题。

六、遇到此类问题的排查与解决方案

6.1 问题识别特征

当一个循环依赖相关的报错信息包含以下关键词时:

  • BeanCurrentlyInCreationException
  • has been injected into other beans in its raw version
  • but has eventually been wrapped
  • does not use the final version of the bean

我们应当立即怀疑,循环依赖链中的某个Bean可能涉及代理增强(如@Async、@Transactional、自定义AOP拦截等)。

6.2 可行解决方案

针对此类问题,业内通常有以下几种处理策略:

  1. 打破循环依赖:这是最根本的解决方案。重新梳理业务依赖关系,通过引入中间层(如一个独立的Service或事件发布机制)解除两个Bean之间的双向依赖。

  2. 使用@Lazy注解:在注入点添加@Lazy注解。例如在TestService1中注入TestService2时使用@Lazy,这会让Spring生成一个代理占位符,推迟对TestService2的实例化直至真正调用时,从而打破启动时的循环依赖链。

  3. 将@Async方法迁移到独立的Bean中:将被@Async修饰的方法抽取到一个专门的异步处理Service中,让循环依赖的两个Bean只依赖这个异步Service,而不是直接互相依赖。

  4. 调整代理创建时机:如果无法从架构上规避,可以考虑实现SmartInstantiationAwareBeanPostProcessor,重写getEarlyBeanReference方法,在早期引用阶段就返回代理对象。但这种方式复杂度高,且需要深入理解Spring内部机制,一般不建议在业务代码中贸然使用。

  5. 使用ObjectProvider或ApplicationContext进行延迟获取:不在字段注入时直接获取依赖对象,而是在实际需要时再从容器中按需获取,从而避免初始化阶段的强循环依赖。

七、总结

Spring的循环依赖解决机制是三级缓存设计与Bean生命周期精细编排的典范。理解这一机制,不仅有助于解决启动过程中出现的疑难杂症,更能帮助我们深入把握Spring的设计哲学。

需要特别强调的是,Spring自始至终都没有承诺解决“所有”循环依赖问题。构造器循环依赖、涉及代理增强的字段循环依赖等场景,都是Spring故意暴露限制的地方。对于开发者而言,最重要的是要认识到:循环依赖本身就是一种设计上的坏味道,正确做法是从根源上减少和避免这类依赖关系,才能让系统架构更加清晰、可维护。

最后,当我们在开发过程中遇到框架行为与直觉不符的情况时,不妨深入源码层面,探究其运行机制背后的权衡取舍。这种探究不仅能解答眼前的问题,更能拓宽技术视野,帮助我们成为一名真正的资深开发者。