Spring 循环依赖的失效场景与底层机制解析

作者:4042026.11.07 02:28浏览量:0

简介:Spring 通常能解决部分循环依赖,但当涉及异步代理、构造器注入或特定生命周期扩展时,缓存机制可能失效并抛出异常。本文从三级缓存的设计目标出发,结合 Bean 创建时序、代理生成时机和常见报错信息,分析循环依赖被“看似解决”与“实际失败”的边界条件,帮助读者建立更准确的判断框架。

一、从一个看似矛盾的例子说起

在 Spring 应用中,循环依赖并不罕见。例如两个 Service 互相注入:

  1. @Service
  2. public class ServiceA {
  3. @Autowired
  4. private ServiceB serviceB;
  5. public void doA() {
  6. }
  7. }
  8. @Service
  9. public class ServiceB {
  10. @Autowired
  11. private ServiceA serviceA;
  12. public void doB() {
  13. }
  14. }

上述代码通常可以正常启动。Spring 容器在创建 ServiceA 时,发现它依赖 ServiceB,于是转而创建 ServiceB;而 ServiceB 又依赖 ServiceA,此时 Spring 会将尚未完全初始化完成的 ServiceA 提前暴露出去,用于填充 ServiceB 的依赖,最终完成两个 Bean 的创建。

但如果给其中一个方法加上异步执行注解:

  1. @Service
  2. public class ServiceA {
  3. @Autowired
  4. private ServiceB serviceB;
  5. @Async
  6. public void doA() {
  7. }
  8. }

再次启动应用,可能会看到类似如下错误:

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

同样的循环依赖结构,为什么加了一个注解就失败?这背后并不是 Spring 循环依赖支持时好时坏,而是代理对象生成时机发生了变化。

二、Spring 解决循环依赖的基本前提

Spring 并非无条件支持所有循环依赖。它能够处理的主要场景是:

  1. 单例 Bean;
  2. 通过属性注入或 Setter 注入形成的依赖;
  3. Bean 的代理生成发生在属性填充之后;
  4. 循环依赖链中没有完全不可提前暴露的构造器依赖。

换句话说,Spring 解决的循环依赖本质上是一种“提前暴露半成品引用”的策略。这个半成品随后被替换为最终对象,依赖方能否拿到最终对象,取决于代理或增强处理发生在哪个阶段。

对于纯粹的属性注入,并且没有额外代理逻辑时,提前暴露的原始 Bean 引用与最终 Bean 引用通常是同一个对象,因此不会出现问题。

三、三级缓存的设计意图

Spring 在 DefaultSingletonBeanRegistry 中维护了三个关键的 Map,通常称为三级缓存:

  1. 一级缓存:存放完全初始化完成的单例 Bean;
  2. 二级缓存:存放提前暴露的 Bean 实例,用于解决循环依赖;
  3. 三级缓存:存放 ObjectFactory,用于在需要时生成早期引用。

核心流程可以概括为:

  • 创建 Bean 前,先从一级缓存查找;
  • 未命中时,开始创建 Bean;
  • 在 Bean 实例化之后、属性填充之前,将当前 Bean 包装为 ObjectFactory 放入三级缓存;
  • 当另一个 Bean 需要注入当前 Bean 时,从三级缓存中获取早期引用;
  • 如果当前 Bean 需要 AOP 代理,则此时可能是生成代理对象的关键时机;
  • 初始化完成后,将最终 Bean 放入一级缓存,并清理二、三级缓存。

可以看出,三级缓存的主要作用是允许在 Bean 完全初始化前暴露一个可用的引用,同时保留后续生成代理或执行后置处理的可能。

四、普通循环依赖为什么能成功

以最初的 ServiceA 与 ServiceB 为例,创建过程大致如下:

  1. 创建 ServiceA 实例;
  2. 将 ServiceA 的 ObjectFactory 放入三级缓存;
  3. 填充 ServiceA 的属性,发现需要 ServiceB;
  4. 创建 ServiceB 实例;
  5. 将 ServiceB 的 ObjectFactory 放入三级缓存;
  6. 填充 ServiceB 的属性,发现需要 ServiceA;
  7. 从三级缓存中获取 ServiceA 的早期引用;
  8. ServiceB 完成初始化;
  9. ServiceA 获得 ServiceB 完成初始化。

在这个流程中,ServiceA 始终是同一个 Java 对象。即使后续执行了一些后置处理器,但没有改变 Bean 的对象身份,因此 ServiceB 中注入的引用与最终暴露给外部的 ServiceA 是同一个实例。

五、异步代理如何破坏预期

当 ServiceA 的方法被标注为异步执行时,Spring 通常需要为该 Bean 生成一个代理对象。也就是说,容器中最终暴露的 ServiceA Bean 不是原始的 ServiceA 实例,而是经过包装的代理类。

此时创建流程变为:

  1. 创建 ServiceA 原始实例;
  2. 将 ServiceA 的 ObjectFactory 放入三级缓存;
  3. 填充 ServiceA 属性,触发创建 ServiceB;
  4. ServiceB 依赖 ServiceA,从三级缓存获取到原始 ServiceA 引用;
  5. ServiceB 完成初始化,它持有的是原始 ServiceA;
  6. ServiceA 属性填充完成;
  7. 执行初始化后置处理,发现需要生成异步代理;
  8. 最终向容器注册的是 ServiceA 代理对象。

此时容器中存在两个“版本”的 ServiceA:代理版本和原始版本。ServiceB 注入的是原始版本,而外部从容器中获取的是代理版本。Spring 检测到这种不一致后,选择抛出异常而不是静默允许。因为在异步、事务、缓存等场景中,如果依赖方拿到了未增强的原始对象,就可能导致功能失效。

六、异常信息的真实含义

报错中的关键句是:

  1. has been injected into other beans in its raw version as part of a circular reference,
  2. but has eventually been wrapped.

它表达的核心含义是:

  • 某个 Bean 在循环依赖期间被以原始版本注入给了其他 Bean;
  • 但最终该 Bean 被包装成了代理对象;
  • 其他 Bean 持有的引用并不是最终版本;
  • 这种状态可能引发错误行为,因此容器选择快速失败。

这其实是 Spring 的一种保护机制。它并不试图在运行时猜测这种不一致是否可接受,而是直接抛出异常,提示开发者调整结构。

七、构造器注入的循环依赖

除了代理问题,构造器注入也会导致循环依赖无法解决。例如:

  1. @Service
  2. public class ServiceC {
  3. private final ServiceD serviceD;
  4. public ServiceC(ServiceD serviceD) {
  5. this.serviceD = serviceD;
  6. }
  7. }
  8. @Service
  9. public class ServiceD {
  10. private final ServiceC serviceC;
  11. public ServiceD(ServiceC serviceC) {
  12. this.serviceC = serviceC;
  13. }
  14. }

这种情况下,无论是否使用三级缓存,都无法提前拿到 Bean 引用。因为构造器是对象创建的第一道关口,必须完成依赖注入后才能进行实例化。Spring 无法在构造器调用前暴露引用,因此会直接抛出 BeanCurrentlyInCreationException。

这类循环依赖通常说明对象设计本身存在问题,应当优先考虑重新划分职责,而不是依赖框架的容错能力。

八、如何判断循环依赖能否被解决

可以从以下几个维度判断:

  • 是否单例 Bean;
  • 是否属性或 Setter 注入;
  • Bean 是否存在代理或包装需求;
  • 代理生成是否发生在循环依赖暴露之后;
  • 是否存在构造器循环依赖。

如果前两项满足,且 Bean 不需要被包装,通常可以解决。如果涉及异步、事务、缓存、监听等需要生成代理的场景,就需要关注代理生成时机。一旦早期引用与最终 Bean 不是同一对象,就可能触发异常。

九、应对策略

针对上述问题,常见处理方式包括:

  1. 打破循环依赖:重新审视对象职责,将公共逻辑抽取到新的 Bean 中;
  2. 使用延迟加载:在注入点使用延迟初始化机制,使依赖关系从创建期转移到使用期;
  3. 调整注入方式:将某些依赖改为方法参数或事件驱动;
  4. 统一代理生成时机:通过显式配置提前创建代理,使早期引用与最终引用保持一致;
  5. 使用启动期检测:结合容器启动日志和异常堆栈,快速定位循环依赖链。

其中,打破循环依赖是长期最稳定的方案。框架层面的缓存机制只能解决结构简单、生命周期明确的场景,不应作为设计的首要依赖。

十、总结

Spring 的循环依赖支持是有边界的。三级缓存解决的是“实例创建与属性填充”之间的时间差,而无法消除对象身份变化所带来的不一致。当 Bean 需要被代理、包装或增强时,原始引用与最终引用可能不再是同一个对象,此时 Spring 选择抛出异常以避免静默错误。

理解这一机制后,再去看“为什么加一个异步注解就启动失败”这类问题,就会发现原因并不神秘:不是循环依赖从可解变为不可解,而是代理对象的生成使“提前暴露的引用”与“最终 Bean”发生了分离。开发者在遇到类似问题时,应当优先检查 Bean 的代理需求、注入方式和依赖方向,而不是简单归因于框架能力不足。