Spring循环依赖治理:从能注入到可运行

作者:demo2026.11.07 02:01浏览量:0

简介:本文从一个真实异常切入,剖析Spring“能解决循环依赖”与“能正常运行”之间的差距:三级缓存只能保证引用可注入,无法保证注入的是最终代理对象。文章系统讲解三级缓存的协作机制、代理包装引发的语义错位、早期引用与最终Bean不一致的判定条件,并给出构造器注入、延迟注入、事件解耦、接口分层等治理策略,帮助读者建立可落地的循环依赖排查与预防方法。

Spring 循环依赖治理:从“能注入”到“能运行”

一、问题的起点:一个看似矛盾的异常

很多开发者第一次遇到下面这段异常信息时,都会产生一种“认知冲突”:

  1. BeanCurrentlyInCreationException: Error creating bean with name 'testService1':
  2. Bean with name 'testService1' has been injected into other beans [testService2]
  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.

异常的核心信息可以概括为三句话:

  1. testService1 在创建过程中,把自己“原始版本”的引用注入给了 testService2;
  2. 但 testService1 最终被包装(wrapped)成了一个代理对象;
  3. 于是 testService2 持有的引用,与容器中最终对外提供的 Bean 并不是同一个对象。

这段报错很容易让人困惑:容器不是号称能处理循环依赖吗?为什么还会失败?

更耐人寻味的是,把 TestService1 中某个方法上的 @Async 注解去掉之后,同样的双向注入结构却可以正常启动。这说明问题并不在于“存在循环依赖”本身,而在于循环依赖与代理包装叠加之后,产生了语义上的不一致。

要理解这个现象,需要先厘清一个关键区分:Spring 解决的是“引用可获取”,而不是“引用语义正确”。这两件事在普通 Bean 上等价,在需要代理的 Bean 上就会分叉。

二、三级缓存:循环依赖的解决机制

2.1 三个缓存各司其职

Spring 的单例 Bean 创建流程中,有三个与循环依赖密切相关的缓存:

缓存 名称 存放内容 作用
一级 singletonObjects 完全初始化好的成品 Bean 对外提供最终引用
二级 earlySingletonObjects 提前暴露的早期引用 避免重复创建早期引用
三级 singletonFactories 生成早期引用的工厂 支持按需生成代理

创建 A 时若发现依赖 B,而 B 又依赖 A,流程大致如下:

  1. 创建 A,实例化完成但尚未填充属性,此时 A 处于“正在创建”状态;
  2. 把 A 的 ObjectFactory 放入三级缓存;
  3. 填充 A 的属性时发现需要 B,转去创建 B;
  4. 创建 B 时发现需要 A,从三级缓存取出工厂,调用 getObject() 得到 A 的早期引用;
  5. 将该早期引用放入二级缓存,并注入给 B;
  6. B 创建完成进入一级缓存,A 继续完成剩余初始化,最终也进入一级缓存。

这套机制的关键在于第 4 步:getObject() 并不是简单返回原始实例,它会经过 SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference,也就是给早期引用一次被代理包装的机会。

2.2 为什么需要三级而不是两级

如果只有二级缓存,早期引用必须在“实例化后立刻生成”。但有些代理的生成依赖 Bean 的完整状态,过早生成可能出错,也会造成不必要的代理开销。三级缓存用工厂把“是否生成早期引用”推迟到真正被依赖时才决定,兼顾了性能与正确性。

三、异常真正产生的位置:早期引用与最终 Bean 不一致

3.1 代理包装改变了对象身份

以 @Async 为例,它通过后置处理器为 Bean 生成一个代理对象。代理对象与原始实例不是同一个对象:原始实例被代理持有,外部调用经过代理转发。

循环依赖场景下会出现两条路径:

  • 路径一:B 注入的是 A 的早期引用(可能是原始实例,也可能是早期代理);
  • 路径二:A 完成初始化后,容器对外暴露的是最终 Bean(通常是经过完整后置处理的代理)。

如果这两者不是同一个对象,就会出现“B 持有的 A”与“容器中的 A”不一致。Spring 在 AbstractAutowireCapableBeanFactory 的初始化收尾阶段会做一致性校验:若发现某个 Bean 的早期引用已被注入到其他 Bean 中,但自身最终又被包装成不同对象,就抛出 BeanCurrentlyInCreationException。

这正是异常文案中 “injected … in its raw version … but has eventually been wrapped” 的含义。它不是“没解决循环依赖”,而是解决了引用获取,却无法保证引用指向最终形态。

3.2 去掉注解为什么就好了

去掉 @Async 后,A 不再需要被代理包装。此时早期引用与最终 Bean 是同一个对象,一致性校验自然通过,项目正常启动。

这也解释了一个常见误区:“能启动”不等于“依赖关系正确”。在需要代理的场景下,即使容器放行,也可能出现调用未经过代理、增强逻辑失效的问题。因此更稳妥的判断标准不是“有没有报错”,而是“注入的引用是否为最终形态”。

四、一致性校验的触发条件

并非所有代理场景都会触发该异常,它取决于三个条件是否同时成立:

  1. 存在循环依赖,且某个 Bean 的早期引用被其他 Bean 实际持有;
  2. 该 Bean 最终被包装成与早期引用不同的对象;
  3. 早期引用已被注入并记录,使得容器能在收尾阶段检测到不一致。

若代理在早期引用阶段就已生成,且最终不再二次包装,则早期引用与最终 Bean 可能一致,校验通过。反之,若早期引用是原始实例、最终却是代理,就必然不一致。

由此可以提炼出一条实践原则:凡是可能被代理的 Bean,尽量不要让它参与循环依赖。这比事后排查异常更有效。

五、治理策略:从“绕过”到“消除”

5.1 优先消除循环依赖

最彻底的方案是重构依赖关系。常见手段包括:

  • 职责拆分:把双向调用中真正属于对方的能力抽成独立组件,让两个服务都依赖它,而不是互相依赖;
  • 接口分层:定义更窄的接口,让依赖方只依赖自己需要的能力,降低耦合;
  • 事件解耦:用应用内事件替代直接调用,把“同步依赖”变为“异步通知”,从结构上断开环。

5.2 用延迟注入打断创建期依赖

当重构成本较高时,可以把“创建期依赖”改为“使用期依赖”:

  1. @Service
  2. public class TestService1 {
  3. @Lazy
  4. @Autowired
  5. private TestService2 testService2;
  6. public void test1() {
  7. testService2.doSomething();
  8. }
  9. }

@Lazy 注入的是延迟解析的代理,创建 A 时不需要真正创建 B,环在创建阶段被打断。需要注意:延迟代理同样会改变对象身份,涉及 @Async 等增强时仍需确认调用是否经过目标代理。

5.3 构造器注入与 setter 注入的取舍

构造器注入天然不支持循环依赖,因为对象在构造完成前无法暴露引用。这看似是限制,实则是保护:它能在启动阶段就暴露环的存在,迫使开发者正面处理。若业务确实需要双向关系,可对其中一侧改用 setter 注入或方法参数注入,明确表达“该依赖可后置”。

5.4 谨慎使用 @Async 等代理型注解

@Async、事务、缓存等注解都依赖代理。让这类 Bean 参与循环依赖,容易产生“注入的是原始实例、调用未走代理”的隐蔽问题。建议:

  • 把异步、事务等横切能力放在不参与循环依赖的 Bean 上;
  • 若必须共存,确保早期引用阶段即生成代理,或通过 @Lazy 明确延迟解析;
  • 启动后做一次调用验证,确认增强逻辑确实生效,而不是只看启动是否成功。

5.5 排查与验证清单

遇到相关异常时,可按以下顺序排查:

  1. 定位异常 Bean,确认它是否被代理(异步、事务、缓存、自定义后置处理器);
  2. 画出依赖环,找出环上哪个 Bean 被提前注入;
  3. 判断早期引用与最终 Bean 是否同一对象;
  4. 选择重构、延迟注入或调整注解位置;
  5. 启动后验证代理增强是否生效,避免“启动成功但语义错误”。

六、总结

Spring 的三级缓存解决的是“循环依赖下引用仍可获取”,它并不承诺“获取到的引用一定是最终形态”。当循环依赖与代理包装叠加时,早期引用与最终 Bean 的身份差异就会暴露,容器通过一致性校验主动报错,本质上是在提示语义风险。

因此,正确的应对思路不是寻找“让报错消失”的技巧,而是把循环依赖当作设计信号:优先通过职责拆分、接口分层、事件解耦消除环;在无法消除时,用延迟注入、构造器约束、注解位置调整来控制影响范围,并在启动后验证增强逻辑是否真正生效。只有把“能注入”推进到“语义正确”,循环依赖才算真正被解决。