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 从”无”到”可用”大致经历哪些阶段:
- 实例化(Instantiate):通过构造器或工厂方法创建对象实例,此时对象在堆中已存在,但字段尚未填充。
- 属性填充(Populate):注入依赖的 Bean,处理
@Autowired、@Resource等注解。 - 初始化(Initialize):执行
Aware接口回调、BeanPostProcessor的前置处理、@PostConstruct、InitializingBean、自定义init-method,以及BeanPostProcessor的后置处理。 - 代理包装:如果该 Bean 匹配了某个切面(AOP),在后置处理阶段会生成代理对象替换原始对象。
- 放入单例池:最终形态的 Bean 被放入一级缓存,供后续使用。
关键点在于:属性填充发生在初始化与代理包装之前。 也就是说,当一个 Bean 被注入到别的 Bean 中时,它可能还是”裸对象”,而不是最终的代理对象。
三、三级缓存机制详解
Spring 解决循环依赖的核心是三级缓存。可以这样理解这三个容器:
| 缓存层级 | 名称 | 存放内容 |
|---|---|---|
| 一级 | singletonObjects | 完全初始化好的成品 Bean |
| 二级 | earlySingletonObjects | 提前暴露的半成品 Bean(可能是代理) |
| 三级 | singletonFactories | 用于生成早期引用的对象工厂 |
3.1 为何需要三级而不是两级?
很多人会问:既然二级缓存能存半成品,为什么还要三级?
答案在于 AOP 代理的时机问题。如果只使用二级缓存,那么半成品一旦被放入,就必须在放入时就决定”是原始对象还是代理对象”。但此时 Bean 的属性还没填充、初始化回调还没执行,贸然生成代理并不合适——因为代理通常需要拦截目标对象的完整行为。
三级缓存的精妙之处在于:它存放的是 ObjectFactory,也就是一个”延迟决策”的工厂。当其他 Bean 需要这个依赖时,才调用工厂生成一个早期引用。如果该 Bean 需要被代理,工厂会返回代理对象;否则返回原始对象。这样就实现了按需生成代理。
3.2 解决循环依赖的完整时序
以 A、B 互相依赖为例:
- 创建 A → 实例化 A → 将 A 的工厂放入三级缓存。
- 填充 A 的属性,发现需要 B。
- 创建 B → 实例化 B → 将 B 的工厂放入三级缓存。
- 填充 B 的属性,发现需要 A。
- 从三级缓存中取出 A 的工厂,调用得到早期 A 引用,放入二级缓存,同时移除三级缓存中的 A 工厂。
- B 完成属性填充、初始化和代理包装,进入一级缓存。
- A 拿到 B 的成品,继续完成自己的属性填充、初始化和代理包装,进入一级缓存。
这套流程之所以能闭环,是因为它假设早期引用与最终成品在”类型上兼容”,也就是最终被注入方拿到的对象,与最终放入单例池的对象保持一致。
四、@Async 场景为何打破了这个假设
现在回到最初的问题:为什么加了 @Async 就报错,去掉之后就正常?
4.1 @Async 引入了额外的代理
@Async 注解通常由异步执行相关的后置处理器处理,它会为该 Bean 生成一个代理对象,用于把方法调用提交到线程池执行。这本身就意味着:该 Bean 最终需要被包装成代理。
4.2 时序冲突的产生
在有 @Async 的情况下,流程可能变成这样:
- A 实例化后,其三级缓存工厂被放入。
- B 需要 A,触发工厂生成早期引用。但此时 A 尚未被异步后置处理器包装,工厂可能返回了 A 的原始对象(或一个尚未定型的代理)。
- B 持有这个早期引用,完成自身创建。
- A 继续执行初始化,此时异步后置处理器介入,把 A 包装成代理对象。
- Spring 检测到:A 的最终形态与之前暴露给 B 的早期引用不是同一个对象。
这就触发了文章开头那段异常——Spring 发现”你之前给别人注入的是旧版本,但你自己最终变成了新版本”,为了保证一致性,它选择抛出异常而不是默默容忍。
4.3 为什么去掉 @Async 就好了
去掉 @Async 后,A 不再需要被额外的后置处理器包装成代理。此时早期引用与最终成品在对象身份上保持一致,三级缓存的假设成立,循环依赖自然被正常化解。
换句话说:普通循环依赖可以靠”提前暴露引用”解决;但一旦这个引用本身要经历”替换成代理”的质变,提前暴露就失去了意义。
五、其他会触发同类问题的场景
除了异步代理,以下情形也可能导致类似的失败:
- 自定义 BeanPostProcessor 在后置处理阶段返回新对象:只要你替换了原始实例,早期引用就会失效。
- AOP 切面在初始化后期才织入:如果切点匹配是被动态决定的,代理生成时机可能晚于依赖注入。
@Transactional与循环依赖叠加:事务代理同样属于包装型增强,若代理生成在注入之后,也会出现类似冲突。- 构造器注入形式的循环依赖:这种连实例化都无法完成,三级缓存无从施展,会直接失败。
- 原型(Prototype)作用域的循环依赖:原型 Bean 不进入单例缓存体系,Spring 无法提前暴露引用。
由此可见,”Spring 能解决循环依赖”这句话本身就是有边界的——它只对单例、且最终形态与早期引用一致的场景有效。
六、工程上的规避与最佳实践
与其纠结于让框架强行处理,不如从设计层面消解问题:
- 打破循环本身:抽取公共逻辑到第三个 Bean,让依赖关系变成单向。这是最彻底的方案。
- 使用
@Lazy:在其中一个注入点加上延迟加载,让 Spring 注入一个代理占位,真正使用时才解析目标 Bean,从而打断创建链。 - 避免在同一 Bean 上叠加多种代理增强:如果一个类既需要异步、又需要事务、还需要切面,考虑拆分职责。
- 优先使用 setter 或字段注入而非构造器注入:构造器注入的循环依赖无法被三级缓存化解。
- 合理设置代理模式:明确使用基于接口还是基于类的代理,避免运行期因类型不匹配引发额外包装。
- 借助启动期诊断手段:在排查阶段可以关闭某些提前初始化行为,观察 Bean 的创建顺序,定位到底是哪一步引入了代理替换。
七、总结
Spring 通过三级缓存巧妙地解决了大部分单例循环依赖,其核心思想是”提前暴露引用 + 延迟决定代理”。但这个方案有一个隐含前提:暴露出去的早期引用,必须与最终的成品在对象身份上保持一致。
一旦引入 @Async、事务代理或自定义后置处理器等会在初始化后期替换实例的机制,这个前提就被打破,于是便有了那句令人困惑的”raw version 与 wrapped version 不一致”的异常。
理解这一点后,面对循环依赖问题,我们就不会简单地认为”框架应该能搞定”,而是能够判断:当前场景是普通循环依赖,还是循环依赖与代理增强的叠加冲突。 前者可以依赖框架,后者则应从设计层面主动规避。这才是真正掌握 Spring Bean 生命周期后的工程判断力。

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