Spring三级缓存如何化解代理循环依赖
作者:梅琳marlin2026.11.07 02:26浏览量:0简介:本文从一道真实报错切入,剖析Spring为何在@Async场景下抛出BeanCurrentlyInCreationException,揭示三级缓存的设计动机与代理对象提前暴露的时序矛盾,并给出拆分依赖、延迟注入等可落地的规避方案,帮助读者彻底理解循环依赖的边界与最佳实践。
Spring 三级缓存如何化解代理循环依赖
一、从一个真实的启动失败说起
一次项目启动时的异常,让“Spring 能解决循环依赖”这句常识受到了挑战。两个服务类互相注入,结构上构成了典型的循环依赖:
@Servicepublic class TestService1 {@Autowiredprivate TestService2 testService2;@Asyncpublic void test1() {// 异步执行逻辑}}@Servicepublic class TestService2 {@Autowiredprivate TestService1 testService1;public void test2() {// 普通业务逻辑}}
启动后抛出如下异常(节选关键信息):
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.
异常的核心含义是:testService2 注入到的是 testService1 的“原始版本”,而 testService1 最终被包装成了代理对象,两者不一致,容器因此拒绝完成装配。
而只要去掉 test1() 上的 @Async 注解,同样的互相注入却能顺利启动。这个对比是理解三级缓存设计边界的最佳入口。
二、循环依赖的解决基石:提前暴露“半成品”
要理解报错原因,先要理解 Spring 解决循环依赖的基本思路。
Bean 的创建大致分为三步:实例化(调用构造器生成对象)、属性填充(注入依赖)、初始化(执行 Aware 回调、BeanPostProcessor 增强、AOP 代理等)。循环依赖的死结在于:A 填充属性时需要 B,B 填充属性时又需要 A,而 A 尚未完成初始化。
Spring 的破局思路是提前暴露引用:在 A 实例化完成、尚未填充属性时,就把这个“半成品”的对象引用登记到一个早期引用缓存中。当 B 需要 A 时,直接从缓存拿到这个引用完成注入;B 创建完成后回填给 A,A 再继续走完后续生命周期。这样环形依赖就被“拉直”成一条链。
三、三级缓存各自的职责
Spring 用三个层级(Map)来管理不同阶段的 Bean 引用:
| 缓存层级 | 名称 | 存放内容 |
|---|---|---|
| 一级 | singletonObjects | 完成全部生命周期、可直接使用的成品 Bean |
| 二级 | earlySingletonObjects | 已提前暴露、但尚未完成初始化的早期引用 |
| 三级 | singletonFactories | 用于生成早期引用的对象工厂(ObjectFactory) |
关键差异在于:二级缓存存的是对象本身,三级缓存存的是“能生产对象的工厂”。为什么不能只用两级?因为如果只用二级缓存直接暴露实例,那么当这个 Bean 后续需要被 AOP 代理时,其他 Bean 已经持有的就是原始对象而非代理对象,最终容器里会同时存在两个语义不同的实例——一个原始对象被注入到别人身上,一个代理对象留在一级缓存里。这正是前文异常描述的场景。
三级缓存把“是否要生成代理、生成什么样的代理”这一决策延迟到真正被依赖的那一刻。工厂的 getObject() 在需要时执行,可以通过 BeanPostProcessor 的 getEarlyBeanReference 提前触发代理创建,从而保证暴露出去的就是最终形态(或至少与最终形态一致)的引用。这就是三级缓存不可省略的根本原因:它不是简单的“多一层缓存”,而是把代理创建的时机与依赖暴露的时机对齐。
四、为什么 @Async 会打破这个机制
@Async 的代理通常由 AsyncAnnotationBeanPostProcessor 在初始化阶段(postProcessAfterInitialization)创建。它走的是常规初始化后置处理链路,而不是 getEarlyBeanReference 这条早期引用链路。也就是说:
testService1实例化后,三级缓存的工厂被登记;testService2创建时依赖testService1,触发工厂getObject(),此时拿到的是原始对象(因为异步代理尚未创建);testService1继续初始化,@Async后置处理器把它包装成代理对象,放入一级缓存;- 容器做一致性校验时发现:
testService2持有的原始对象与一级缓存中的代理对象不是同一个,且该 Bean 已被注入到其他 Bean 中,于是抛出BeanCurrentlyInCreationException。
本质矛盾是:早期暴露发生在代理生成之前,而该代理又无法在早期引用阶段被提前创建。普通 AOP 场景之所以没问题,是因为其代理创建逻辑实现了 getEarlyBeanReference,能在早期暴露时就把代理对象交出去;而部分后置处理器(如异步注解处理器)并不具备这一能力,导致时序错位。
五、可落地的规避与治理方案
理解了机制,解决方案就清晰了。目标不是“绕开报错”,而是消除对同一 Bean 的“原始引用”与“代理引用”并存。
方案一:拆分职责,打破环。 把 test1() 的异步逻辑抽到独立服务中,让 TestService1 不再同时承担“被注入”和“被代理”的双重角色。这是最推荐的做法,因为循环依赖本身往往意味着职责边界不清。
方案二:延迟获取依赖。 将注入改为 ObjectProvider 或 @Lazy,让依赖在首次真正使用时才解析,此时对方已完成初始化,拿到的是最终代理对象:
@Servicepublic class TestService2 {@Autowired@Lazyprivate TestService1 testService1;}
方案三:改用 setter/字段注入的时机控制。 若必须保留环,可让异步代理作用在不会形成环的那个 Bean 上,把 @Async 从被循环引用的类上移走。
方案四:调整代理创建时机。 对自研的后置处理器,实现 getEarlyBeanReference 使其支持早期代理暴露,从根本上对齐时序。这一方案适合框架层改造,业务代码中应谨慎使用。
方案五:允许提前引用。 通过 @Lazy 配合配置调整早期引用策略,仅在明确知晓副作用的前提下使用,不建议作为默认手段。
六、实践建议与总结
三级缓存的设计精巧之处,在于它把“对象何时可用”与“对象最终长什么样”这两个问题解耦,用一个工厂推迟了代理决策。但它并非万能:当代理创建逻辑不参与早期引用链路时,循环依赖仍会失败。这提醒我们:
- 循环依赖能解决,不等于应该存在。它常是耦合过重的信号,优先考虑重构;
- 涉及 AOP、异步、事务等代理增强的 Bean,尽量避免参与循环依赖;
- 遇到
BeanCurrentlyInCreationException时,先判断是“代理时序问题”还是“作用域/构造器注入问题”,再对症处理; - 构造器注入的循环依赖无法靠三级缓存解决,需改用其他注入方式或重构。
把三级缓存理解为“提前暴露 + 延迟决策”的组合机制,而非单纯的缓存堆叠,才能真正掌握 Spring 装配的边界,并在设计阶段就规避这类隐蔽的启动期故障。

登录后可评论,请前往 登录 或 注册