java 技术随笔

Spring 循环依赖与三级缓存:为什么必须是三级缓存

“Spring 怎么解决循环依赖?”是面试命中率极高的原理题。很多人背下“三级缓存”四个字,却说不清为什么必须三级为什么构造器注入解决不了Spring Boot 2.6 为什么默认禁用了循环依赖。本文先用一张生命周期图建立认知,再逐步推导三级缓存的必要性。

一、Bean 的生命周期(铺垫)

一个普通单例 Bean 从创建到就绪大致经历:

  1. 实例化:通过构造器 new 出对象(内存已分配,属性还是默认值);
  2. 属性填充(依赖注入):给 @Autowired/@Resource 字段注入依赖对象;
  3. 初始化:执行 Aware 回调、@PostConstruct、InitializingBean、init-method,以及各类 BeanPostProcessor 的 before/after;
  4. 就绪使用;容器关闭时执行销毁方法。

循环依赖问题的根源就在第 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 之前只是告警。这是官方刻意引导:循环依赖大多是设计坏味道,正确姿势是消除而不是迁就

  1. 重构分层:把 A 与 B 互相依赖的公共部分抽到 C,让 A、B 都只依赖 C;
  2. 构造器注入 + @Lazy:任一方构造参数加 @Lazy,延迟注入代理,打破启动期的实例化环;
  3. 事件 / 回调解耦:需要互相调用的场景改用 ApplicationEvent 解耦;
  4. 兜底开关(不推荐长期使用):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 代理三件事串起来,三级缓存就不是“背名词”而是水到渠成的设计;而面试官真正想听的,往往是你对“为什么三级而不是两级”“为什么设计上要避免它”的思考深度。

标签
Spring循环依赖三级缓存原理