logo

Spring 循环依赖:三级缓存与代理陷阱

作者:rousong2026.11.07 02:20浏览量:1

简介:循环依赖是 Spring 面试与实战中的高频难题。本文从一次典型的 BeanCurrentlyInCreationException 出发,剖析三级缓存机制为何能化解普通循环依赖、却在 @Async 等代理场景下失效,并给出可落地的排查思路与规避方案,帮助读者真正理解 Spring Bean 的创建时序与代理包装过程。

Spring 循环依赖:三级缓存与代理陷阱

一、从一个”诡异”的报错说起

在实际项目开发中,很多工程师都遇到过这样的场景:两个 Service 互相注入对方实例,项目启动时抛出一段冗长的异常信息,核心含义大致如下:

创建名为 testService1 的 Bean 时出错:该 Bean 以”原始版本”的形式被提前注入到了其他 Bean 中,但最终它又被包装成了另一个对象。这意味着那些注入了它的 Bean 持有的并不是最终版本。

这段话的潜台词是:Spring 确实处理了循环依赖,但处理得”不彻底”——它注入的是一个尚未完成最终装配的半成品。

这就引出了一个经典疑问:Spring 不是号称能解决循环依赖吗?为什么还会失败?

更让人困惑的是,如果把某个方法上的 @Async 注解去掉,同样的循环依赖结构却能正常启动。这说明问题并不在于”循环依赖本身”,而在于循环依赖与代理增强叠加后产生的时序冲突。

下面我们从 Spring 的 Bean 创建流程讲起,逐层揭开这个问题的本质。

二、理解循环依赖的前提:Bean 的创建阶段

要理解循环依赖的解决方案,必须先明确一个 Bean 从”无”到”可用”大致经历哪些阶段:

  1. 实例化(Instantiate):通过构造器或工厂方法创建对象实例,此时对象在堆中已存在,但字段尚未填充。
  2. 属性填充(Populate):注入依赖的 Bean,处理 @Autowired、@Resource 等注解。
  3. 初始化(Initialize):执行 Aware 接口回调、BeanPostProcessor 的前置处理、@PostConstruct、InitializingBean、自定义 init-method,以及 BeanPostProcessor 的后置处理。
  4. 代理包装:如果该 Bean 匹配了某个切面(AOP),在后置处理阶段会生成代理对象替换原始对象。
  5. 放入单例池:最终形态的 Bean 被放入一级缓存,供后续使用。

关键点在于:属性填充发生在初始化与代理包装之前。 也就是说,当一个 Bean 被注入到别的 Bean 中时,它可能还是”裸对象”,而不是最终的代理对象。

三、三级缓存机制详解

Spring 解决循环依赖的核心是三级缓存。可以这样理解这三个容器:

缓存层级 名称 存放内容
一级 singletonObjects 完全初始化好的成品 Bean
二级 earlySingletonObjects 提前暴露的半成品 Bean(可能是代理)
三级 singletonFactories 用于生成早期引用的对象工厂

3.1 为何需要三级而不是两级?

很多人会问:既然二级缓存能存半成品,为什么还要三级?

答案在于 AOP 代理的时机问题。如果只使用二级缓存,那么半成品一旦被放入,就必须在放入时就决定”是原始对象还是代理对象”。但此时 Bean 的属性还没填充、初始化回调还没执行,贸然生成代理并不合适——因为代理通常需要拦截目标对象的完整行为。

三级缓存的精妙之处在于:它存放的是 ObjectFactory,也就是一个”延迟决策”的工厂。当其他 Bean 需要这个依赖时,才调用工厂生成一个早期引用。如果该 Bean 需要被代理,工厂会返回代理对象;否则返回原始对象。这样就实现了按需生成代理。

3.2 解决循环依赖的完整时序

以 A、B 互相依赖为例:

  1. 创建 A → 实例化 A → 将 A 的工厂放入三级缓存。
  2. 填充 A 的属性,发现需要 B。
  3. 创建 B → 实例化 B → 将 B 的工厂放入三级缓存。
  4. 填充 B 的属性,发现需要 A。
  5. 从三级缓存中取出 A 的工厂,调用得到早期 A 引用,放入二级缓存,同时移除三级缓存中的 A 工厂。
  6. B 完成属性填充、初始化和代理包装,进入一级缓存。
  7. A 拿到 B 的成品,继续完成自己的属性填充、初始化和代理包装,进入一级缓存。

这套流程之所以能闭环,是因为它假设早期引用与最终成品在”类型上兼容”,也就是最终被注入方拿到的对象,与最终放入单例池的对象保持一致。

四、@Async 场景为何打破了这个假设

现在回到最初的问题:为什么加了 @Async 就报错,去掉之后就正常?

4.1 @Async 引入了额外的代理

@Async 注解通常由异步执行相关的后置处理器处理,它会为该 Bean 生成一个代理对象,用于把方法调用提交到线程池执行。这本身就意味着:该 Bean 最终需要被包装成代理。

4.2 时序冲突的产生

在有 @Async 的情况下,流程可能变成这样:

  1. A 实例化后,其三级缓存工厂被放入。
  2. B 需要 A,触发工厂生成早期引用。但此时 A 尚未被异步后置处理器包装,工厂可能返回了 A 的原始对象(或一个尚未定型的代理)。
  3. B 持有这个早期引用,完成自身创建。
  4. A 继续执行初始化,此时异步后置处理器介入,把 A 包装成代理对象。
  5. Spring 检测到:A 的最终形态与之前暴露给 B 的早期引用不是同一个对象。

这就触发了文章开头那段异常——Spring 发现”你之前给别人注入的是旧版本,但你自己最终变成了新版本”,为了保证一致性,它选择抛出异常而不是默默容忍。

4.3 为什么去掉 @Async 就好了

去掉 @Async 后,A 不再需要被额外的后置处理器包装成代理。此时早期引用与最终成品在对象身份上保持一致,三级缓存的假设成立,循环依赖自然被正常化解。

换句话说:普通循环依赖可以靠”提前暴露引用”解决;但一旦这个引用本身要经历”替换成代理”的质变,提前暴露就失去了意义。

五、其他会触发同类问题的场景

除了异步代理,以下情形也可能导致类似的失败:

  • 自定义 BeanPostProcessor 在后置处理阶段返回新对象:只要你替换了原始实例,早期引用就会失效。
  • AOP 切面在初始化后期才织入:如果切点匹配是被动态决定的,代理生成时机可能晚于依赖注入。
  • @Transactional 与循环依赖叠加:事务代理同样属于包装型增强,若代理生成在注入之后,也会出现类似冲突。
  • 构造器注入形式的循环依赖:这种连实例化都无法完成,三级缓存无从施展,会直接失败。
  • 原型(Prototype)作用域的循环依赖:原型 Bean 不进入单例缓存体系,Spring 无法提前暴露引用。

由此可见,”Spring 能解决循环依赖”这句话本身就是有边界的——它只对单例、且最终形态与早期引用一致的场景有效。

六、工程上的规避与最佳实践

与其纠结于让框架强行处理,不如从设计层面消解问题:

  1. 打破循环本身:抽取公共逻辑到第三个 Bean,让依赖关系变成单向。这是最彻底的方案。
  2. 使用 @Lazy:在其中一个注入点加上延迟加载,让 Spring 注入一个代理占位,真正使用时才解析目标 Bean,从而打断创建链。
  3. 避免在同一 Bean 上叠加多种代理增强:如果一个类既需要异步、又需要事务、还需要切面,考虑拆分职责。
  4. 优先使用 setter 或字段注入而非构造器注入:构造器注入的循环依赖无法被三级缓存化解。
  5. 合理设置代理模式:明确使用基于接口还是基于类的代理,避免运行期因类型不匹配引发额外包装。
  6. 借助启动期诊断手段:在排查阶段可以关闭某些提前初始化行为,观察 Bean 的创建顺序,定位到底是哪一步引入了代理替换。

七、总结

Spring 通过三级缓存巧妙地解决了大部分单例循环依赖,其核心思想是”提前暴露引用 + 延迟决定代理”。但这个方案有一个隐含前提:暴露出去的早期引用,必须与最终的成品在对象身份上保持一致。

一旦引入 @Async、事务代理或自定义后置处理器等会在初始化后期替换实例的机制,这个前提就被打破,于是便有了那句令人困惑的”raw version 与 wrapped version 不一致”的异常。

理解这一点后,面对循环依赖问题,我们就不会简单地认为”框架应该能搞定”,而是能够判断:当前场景是普通循环依赖,还是循环依赖与代理增强的叠加冲突。 前者可以依赖框架,后者则应从设计层面主动规避。这才是真正掌握 Spring Bean 生命周期后的工程判断力。

发表评论

活动