logo

Spring三级缓存如何化解代理循环依赖

作者:梅琳marlin2026.11.07 02:26浏览量:0

简介:本文从一道真实报错切入,剖析Spring为何在@Async场景下抛出BeanCurrentlyInCreationException,揭示三级缓存的设计动机与代理对象提前暴露的时序矛盾,并给出拆分依赖、延迟注入等可落地的规避方案,帮助读者彻底理解循环依赖的边界与最佳实践。

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. }

启动后抛出如下异常(节选关键信息):

  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.

异常的核心含义是: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 这条早期引用链路。也就是说:

  1. testService1 实例化后,三级缓存的工厂被登记;
  2. testService2 创建时依赖 testService1,触发工厂 getObject(),此时拿到的是原始对象(因为异步代理尚未创建);
  3. testService1 继续初始化,@Async 后置处理器把它包装成代理对象,放入一级缓存;
  4. 容器做一致性校验时发现:testService2 持有的原始对象与一级缓存中的代理对象不是同一个,且该 Bean 已被注入到其他 Bean 中,于是抛出 BeanCurrentlyInCreationException。

本质矛盾是:早期暴露发生在代理生成之前,而该代理又无法在早期引用阶段被提前创建。普通 AOP 场景之所以没问题,是因为其代理创建逻辑实现了 getEarlyBeanReference,能在早期暴露时就把代理对象交出去;而部分后置处理器(如异步注解处理器)并不具备这一能力,导致时序错位。

五、可落地的规避与治理方案

理解了机制,解决方案就清晰了。目标不是“绕开报错”,而是消除对同一 Bean 的“原始引用”与“代理引用”并存。

方案一:拆分职责,打破环。 把 test1() 的异步逻辑抽到独立服务中,让 TestService1 不再同时承担“被注入”和“被代理”的双重角色。这是最推荐的做法,因为循环依赖本身往往意味着职责边界不清。

方案二:延迟获取依赖。 将注入改为 ObjectProvider 或 @Lazy,让依赖在首次真正使用时才解析,此时对方已完成初始化,拿到的是最终代理对象:

  1. @Service
  2. public class TestService2 {
  3. @Autowired
  4. @Lazy
  5. private TestService1 testService1;
  6. }

方案三:改用 setter/字段注入的时机控制。 若必须保留环,可让异步代理作用在不会形成环的那个 Bean 上,把 @Async 从被循环引用的类上移走。

方案四:调整代理创建时机。 对自研的后置处理器,实现 getEarlyBeanReference 使其支持早期代理暴露,从根本上对齐时序。这一方案适合框架层改造,业务代码中应谨慎使用。

方案五:允许提前引用。 通过 @Lazy 配合配置调整早期引用策略,仅在明确知晓副作用的前提下使用,不建议作为默认手段。

六、实践建议与总结

三级缓存的设计精巧之处,在于它把“对象何时可用”与“对象最终长什么样”这两个问题解耦,用一个工厂推迟了代理决策。但它并非万能:当代理创建逻辑不参与早期引用链路时,循环依赖仍会失败。这提醒我们:

  • 循环依赖能解决,不等于应该存在。它常是耦合过重的信号,优先考虑重构;
  • 涉及 AOP、异步、事务等代理增强的 Bean,尽量避免参与循环依赖;
  • 遇到 BeanCurrentlyInCreationException 时,先判断是“代理时序问题”还是“作用域/构造器注入问题”,再对症处理;
  • 构造器注入的循环依赖无法靠三级缓存解决,需改用其他注入方式或重构。

把三级缓存理解为“提前暴露 + 延迟决策”的组合机制,而非单纯的缓存堆叠,才能真正掌握 Spring 装配的边界,并在设计阶段就规避这类隐蔽的启动期故障。

发表评论

活动