Spring 循环依赖与三级缓存:为什么必须是三级缓存
“Spring 怎么解决循环依赖?”是面试命中率极高的原理题。很多人背下“三级缓存”四个字,却说不清为什么必须三级、为什么构造器注入解决不了、Spring Boot 2.6 为什么默认禁用了循环依赖。本文先用一张生命周期图建立认知,再逐步推导三级缓存的必要性。
一、Bean 的生命周期(铺垫)
一个普通单例 Bean 从创建到就绪大致经历:
- 实例化:通过构造器 new 出对象(内存已分配,属性还是默认值);
- 属性填充(依赖注入):给 @Autowired/@Resource 字段注入依赖对象;
- 初始化:执行 Aware 回调、@PostConstruct、InitializingBean、init-method,以及各类 BeanPostProcessor 的 before/after;
- 就绪使用;容器关闭时执行销毁方法。
循环依赖问题的根源就在第 1、2 步之间:A 实例化后要注入 B,B 实例化后又要注入 A,如果等到“完整创建完 A”才能被引用,那谁都无法完成——必须先让 A“半成品”提前暴露给 B 使用。
二、什么是循环依赖
@Service
public class A {
@Autowired private B b; // A 依赖 B
}
@Service
public class B {
@Autowired private A a; // B 依赖 A —— 构成 A -> B -> A 环
}
注意:这里必须是 setter 注入 / 字段注入才能被解决;构造器注入(通过构造参数注入依赖)无法解决循环依赖,原因见第四节。
三、三级缓存与“提前暴露”
DefaultSingletonBeanRegistry 内部维护三个 Map:
// 第一级 singletonObjects:完整的单例(已完成属性填充与初始化),getBean 优先查这里
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>();
// 第二级 earlySingletonObjects:提前暴露的“早期引用”(已实例化,可能还没填属性和初始化)
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>();
// 第三级 singletonFactories:单例工厂,延迟调用 getEarlyBeanReference 生成“早期引用”
// (这一级才是“提前创建 AOP 代理”的关键)
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>();
创建 A 时,会先把 singletonFactory 放进第三级缓存(在实例化之后、属性填充之前)。当 A 填充属性发现需要 B,转而创建 B;B 填充属性需要 A 时,getSingleton("A") 在一级找不到,于是走三级:调用工厂生成 A 的早期引用(如果需要 AOP 就在这一步产出代理对象),放入二级缓存并返回给 B。B 完整创建后,A 继续完成自己的属性填充与初始化,最终进入一级缓存。
四、为什么必须是三级缓存?(核心考点)
很多人问:二级缓存直接存“半成品 A”不行吗?答案要从AOP 代理说起:
- 若 A 需要被 AOP 增强(比如标了 @Transactional),那么最终放进容器的应该是代理对象,而不是原始对象;
- 如果只用两级缓存(二级直接存原始半成品),B 注入的将是 A 的原始对象,等 A 初始化完再生成代理,B 持有的引用和容器最终暴露的 A 代理不是同一个对象,事务/AOP 在 B 内部调用时会失效;
- 三级缓存里的
singletonFactory延迟到“真正被别的 Bean 需要”的那一刻才调用getEarlyBeanReference生成代理。如果 A 没有被循环引用,就永远不会走到这一步,从而避免了对所有 Bean 提前做 AOP; - 同时第三级是 lambda 工厂,也保证同一轮里多次 getBean(A) 拿到的早期引用是同一个(放入二级后,三级工厂即被移除)。
一句话:三级缓存 = 原始实例的“延迟工厂”,它把“是否需要提前 AOP”的判断推迟到被引用时,既保证注入对象与最终代理一致,又避免无谓的提前代理。二级缓存做不到这两点兼顾,这就是要多一层的根本原因。
五、哪些循环依赖解决不了
| 场景 | 能否解决 | 原因 |
|---|---|---|
| setter / 字段注入的循环依赖 | 能(默认单例) | 实例化与注入分离,可提前暴露半成品 |
| 构造器注入的循环依赖 | 不能 | 实例化时必须先拿到构造参数,半成品尚未存在,无法提前暴露 |
| prototype 作用域的循环依赖 | 不能 | 原型每次 getBean 都新建,且不缓存半成品 |
| @Async / 某些 BeanPostProcessor 场景 | 可能报错 | 代理提前生成逻辑复杂,需配合 @Lazy |
六、开发中遇到循环依赖怎么办
Spring Boot 2.6 起默认禁止循环依赖(启动报 The dependencies of some of the beans in the application context form a cycle),2.6 之前只是告警。这是官方刻意引导:循环依赖大多是设计坏味道,正确姿势是消除而不是迁就:
- 重构分层:把 A 与 B 互相依赖的公共部分抽到 C,让 A、B 都只依赖 C;
- 构造器注入 + @Lazy:任一方构造参数加 @Lazy,延迟注入代理,打破启动期的实例化环;
- 事件 / 回调解耦:需要互相调用的场景改用 ApplicationEvent 解耦;
- 兜底开关(不推荐长期使用):
spring.main.allow-circular-references=true临时放行。
@Service
public class A {
private final B b;
public A(@Lazy B b) { // 构造器注入 + @Lazy:需要时才生成 B 的代理
this.b = b;
}
}
七、高频面试追问速答
- 三级缓存的 key 是什么? BeanName;value 分别为完整单例、早期引用、ObjectFactory。
- 什么时候从三级升到二级? 某 Bean 作为依赖被其他 Bean 提前获取时,调用工厂生成早期引用放入二级并移除三级工厂。
- 一级缓存为什么叫 singletonObjects? 只有真正的单例且创建完成(含初始化与代理)才会放入,getBean 最终从这层返回。
- @Transactional 的 Bean 参与循环依赖会怎样? 早期引用阶段就生成 CGLIB 代理,保证 B 拿到的与最终一致,这也是三级缓存存在意义的典型场景。
- 为什么官方要禁止循环依赖? 循环依赖常常掩盖了“对象职责不清/初始化顺序依赖”,且与构造器注入、AOP 提前代理等复杂场景冲突,尽早暴露设计问题更利于长期维护。
总结:把生命周期、提前暴露、AOP 代理三件事串起来,三级缓存就不是“背名词”而是水到渠成的设计;而面试官真正想听的,往往是你对“为什么三级而不是两级”和“为什么设计上要避免它”的思考深度。