简介:本文从一个真实异常切入,剖析Spring“能解决循环依赖”与“能正常运行”之间的差距:三级缓存只能保证引用可注入,无法保证注入的是最终代理对象。文章系统讲解三级缓存的协作机制、代理包装引发的语义错位、早期引用与最终Bean不一致的判定条件,并给出构造器注入、延迟注入、事件解耦、接口分层等治理策略,帮助读者建立可落地的循环依赖排查与预防方法。
很多开发者第一次遇到下面这段异常信息时,都会产生一种“认知冲突”:
BeanCurrentlyInCreationException: Error creating bean with name 'testService1':Bean with name 'testService1' has been injected into other beans [testService2]in its raw version as part of a circular reference, but has eventually been wrapped.This means that said other beans do not use the final version of the bean.
异常的核心信息可以概括为三句话:
testService1 在创建过程中,把自己“原始版本”的引用注入给了 testService2;testService1 最终被包装(wrapped)成了一个代理对象;testService2 持有的引用,与容器中最终对外提供的 Bean 并不是同一个对象。这段报错很容易让人困惑:容器不是号称能处理循环依赖吗?为什么还会失败?
更耐人寻味的是,把 TestService1 中某个方法上的 @Async 注解去掉之后,同样的双向注入结构却可以正常启动。这说明问题并不在于“存在循环依赖”本身,而在于循环依赖与代理包装叠加之后,产生了语义上的不一致。
要理解这个现象,需要先厘清一个关键区分:Spring 解决的是“引用可获取”,而不是“引用语义正确”。这两件事在普通 Bean 上等价,在需要代理的 Bean 上就会分叉。
Spring 的单例 Bean 创建流程中,有三个与循环依赖密切相关的缓存:
| 缓存 | 名称 | 存放内容 | 作用 |
|---|---|---|---|
| 一级 | singletonObjects | 完全初始化好的成品 Bean | 对外提供最终引用 |
| 二级 | earlySingletonObjects | 提前暴露的早期引用 | 避免重复创建早期引用 |
| 三级 | singletonFactories | 生成早期引用的工厂 | 支持按需生成代理 |
创建 A 时若发现依赖 B,而 B 又依赖 A,流程大致如下:
ObjectFactory 放入三级缓存;getObject() 得到 A 的早期引用;这套机制的关键在于第 4 步:getObject() 并不是简单返回原始实例,它会经过 SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference,也就是给早期引用一次被代理包装的机会。
如果只有二级缓存,早期引用必须在“实例化后立刻生成”。但有些代理的生成依赖 Bean 的完整状态,过早生成可能出错,也会造成不必要的代理开销。三级缓存用工厂把“是否生成早期引用”推迟到真正被依赖时才决定,兼顾了性能与正确性。
以 @Async 为例,它通过后置处理器为 Bean 生成一个代理对象。代理对象与原始实例不是同一个对象:原始实例被代理持有,外部调用经过代理转发。
循环依赖场景下会出现两条路径:
如果这两者不是同一个对象,就会出现“B 持有的 A”与“容器中的 A”不一致。Spring 在 AbstractAutowireCapableBeanFactory 的初始化收尾阶段会做一致性校验:若发现某个 Bean 的早期引用已被注入到其他 Bean 中,但自身最终又被包装成不同对象,就抛出 BeanCurrentlyInCreationException。
这正是异常文案中 “injected … in its raw version … but has eventually been wrapped” 的含义。它不是“没解决循环依赖”,而是解决了引用获取,却无法保证引用指向最终形态。
去掉 @Async 后,A 不再需要被代理包装。此时早期引用与最终 Bean 是同一个对象,一致性校验自然通过,项目正常启动。
这也解释了一个常见误区:“能启动”不等于“依赖关系正确”。在需要代理的场景下,即使容器放行,也可能出现调用未经过代理、增强逻辑失效的问题。因此更稳妥的判断标准不是“有没有报错”,而是“注入的引用是否为最终形态”。
并非所有代理场景都会触发该异常,它取决于三个条件是否同时成立:
若代理在早期引用阶段就已生成,且最终不再二次包装,则早期引用与最终 Bean 可能一致,校验通过。反之,若早期引用是原始实例、最终却是代理,就必然不一致。
由此可以提炼出一条实践原则:凡是可能被代理的 Bean,尽量不要让它参与循环依赖。这比事后排查异常更有效。
最彻底的方案是重构依赖关系。常见手段包括:
当重构成本较高时,可以把“创建期依赖”改为“使用期依赖”:
@Servicepublic class TestService1 {@Lazy@Autowiredprivate TestService2 testService2;public void test1() {testService2.doSomething();}}
@Lazy 注入的是延迟解析的代理,创建 A 时不需要真正创建 B,环在创建阶段被打断。需要注意:延迟代理同样会改变对象身份,涉及 @Async 等增强时仍需确认调用是否经过目标代理。
构造器注入天然不支持循环依赖,因为对象在构造完成前无法暴露引用。这看似是限制,实则是保护:它能在启动阶段就暴露环的存在,迫使开发者正面处理。若业务确实需要双向关系,可对其中一侧改用 setter 注入或方法参数注入,明确表达“该依赖可后置”。
@Async 等代理型注解@Async、事务、缓存等注解都依赖代理。让这类 Bean 参与循环依赖,容易产生“注入的是原始实例、调用未走代理”的隐蔽问题。建议:
@Lazy 明确延迟解析;遇到相关异常时,可按以下顺序排查:
Spring 的三级缓存解决的是“循环依赖下引用仍可获取”,它并不承诺“获取到的引用一定是最终形态”。当循环依赖与代理包装叠加时,早期引用与最终 Bean 的身份差异就会暴露,容器通过一致性校验主动报错,本质上是在提示语义风险。
因此,正确的应对思路不是寻找“让报错消失”的技巧,而是把循环依赖当作设计信号:优先通过职责拆分、接口分层、事件解耦消除环;在无法消除时,用延迟注入、构造器约束、注解位置调整来控制影响范围,并在启动后验证增强逻辑是否真正生效。只有把“能注入”推进到“语义正确”,循环依赖才算真正被解决。