Spring 循环依赖终极答案:为什么一定要三级缓存?(源码+图解)

172 阅读5分钟

前言

“Spring 如何解决循环依赖?”

这也是我在面试高级 Java 岗位时最喜欢问的问题之一。大多数人能背出“三级缓存”,也能说出 A -> B -> A 的流程。

但如果我追问一句: “如果把第三级缓存去掉,只用两级,能解决循环依赖吗?”

或者: “第三级缓存里存的 ObjectFactory 到底是个什么东西?为什么要存个工厂而不是直接存对象?”

很多人就卡壳了。

今天我们不背八股文,我们从 Spring 的设计哲学 出发,配合源码与时序图,彻底把这块骨头啃下来。


一、 核心冲突:Spring 的“两难困境”

在深入源码之前,你必须明白 Spring 面临的一个核心矛盾,这是理解三级缓存的钥匙:

  1. 原则 A(标准生命周期):

    Spring 的设计原则是:AOP 代理对象应该在 Bean 生命周期的一步(初始化后)生成。

    也就是说:能不提前生成代理,就绝不提前。

  2. 现实 B(循环依赖困境):

    当 A 依赖 B,B 又依赖 A 时。B 在属性注入时,必须拿到 A 的引用。

    如果 A 被标记为需要 AOP 代理,那么 B 必须拿到 A 的代理对象,而不是原始对象。

矛盾出现了:

  • 按原则,A 的代理要在最后创建。
  • 按现实,B 现在就要用 A 的代理。
  • 如果这时候给 B 一个原始的 A,等到 A 流程结束变成了代理对象,那 B 持有的 A容器里的 A 就不是同一个东西了!(单例性被破坏)。

怎么办?

Spring 需要一个机制:平时严格遵守原则 A,只有在万不得已(循环依赖)时,才打破原则,提前生成代理。

第三级缓存,就是这个“万不得已”的开关。


二、 破案工具:三级缓存的“身份卡”

DefaultSingletonBeanRegistry 中,Spring 用了三个 Map。请看清它们的职责:

// 一级缓存:成品库 (最终单例池)
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
​
// 二级缓存:半成品库 (避免循环依赖中的重复代理)
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);
​
// 三级缓存:工厂库 (延迟决策的钩子)
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
缓存级别存的是什么?(What)存入时机 (Write)作用 (Why)
一级成品 BeanBean 初始化全部完成后日常使用getBean 拿到的最终对象。
二级半成品 Bean (原身或代理)当 B 需要 A,且从三级缓存找到 A 时,A 升级入驻“占位符” 。保证在循环依赖期间,A 无论被引用多少次,返回的都是同一个对象。
三级ObjectFactory (Lambda)A 刚刚实例化 (new A) 之后,立刻存入“打破生命周期的钩子” 。它是一个推迟的动作。如果需要提前代理,就调用它;否则永远不调用。

三、 案发过程:A 与 B 的纠缠

假设 ServiceAServiceB 互相依赖。

  1. 创建 A:Spring 实例化 A (new A())。

  2. A 暴露工厂:Spring 立刻把一个能获取 A 的工厂(Lambda 表达式)放入 三级缓存

    • 代码:addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
  3. A 注入属性:发现需要 B,转去创建 B。

  4. 创建 B:B 实例化,注入属性时发现需要 A。

  5. B 找 A

    • 一级?空。
    • 二级?空。
    • 三级?命中! 拿到工厂对象,调用 getObject()
  6. 关键时刻:调用 getObject() 时,getEarlyBeanReference 逻辑触发:

    • 如果有 AOP:立刻创建 A 的代理对象(提前了!)。
    • 如果没有 AOP:返回 A 的原始对象。
  7. 升级缓存:把拿到的对象(代理或原始)放入 二级缓存,同时删除三级缓存。

  8. B 完工:B 拿到 A,初始化完成,入驻一级缓存。

  9. A 完工:A 回来拿到 B,初始化完成,入驻一级缓存。

上帝视角:时序图解

文字太抽象?这张时序图展示了对象在缓存间的关键流转

sequenceDiagram
    participant Main as Spring容器/主线程
    participant Cache1 as 一级缓存(单例池)
    participant Cache2 as 二级缓存(早产儿)
    participant Cache3 as 三级缓存(工厂)
    participant BeanA as ServiceA
    participant BeanB as ServiceB

    Note over Main: 1. 开始创建 Bean A
    Main->>BeanA: 实例化 A (new A)
    Main->>Cache3: addSingletonFactory(A的工厂)
    Note right of Cache3: A 的工厂入驻三级缓存<br/>此时 A 是半成品
    
    Main->>BeanA: 填充属性 (发现依赖 B)
    Note over Main: A 暂停,转去创建 B
    
    Main->>BeanB: 实例化 B (new B)
    Main->>Cache3: addSingletonFactory(B的工厂)
    Main->>BeanB: 填充属性 (发现依赖 A)
    
    Note over BeanB: 2. B 开始在缓存中找 A
    BeanB->>Cache1: getSingleton(A)? -> 空
    BeanB->>Cache2: getSingleton(A)? -> 空
    BeanB->>Cache3: getSingleton(A)? -> 命中!
    
    Cache3-->>BeanB: 调用 getObject() 获取 A
    Note right of Cache3: 关键点:若需 AOP<br/>此处创建 A 的代理对象
    
    BeanB->>Cache2: 将 A (或代理) 放入二级缓存
    BeanB->>Cache3: 从三级缓存移除 A
    Note right of Cache2: A 升级进入二级缓存
    
    BeanB->>BeanB: B 初始化完成
    BeanB->>Cache1: 将 B 放入一级缓存
    
    Note over Main: 3. B 创建完成,回到 A
    Main->>BeanA: A 拿到 B,属性填充完成
    Main->>BeanA: A 初始化完成
    Main->>Cache1: 将 A 放入一级缓存
    Main->>Cache2: 从二级缓存移除 A
    
    Note over Main: 循环依赖解决,A/B 均可用

四、 灵魂推演:为什么两级不行?

这是本文的高潮部分。我们通过“控制变量法”来证明第三级的必要性。

假设 1:没有 AOP,只有两级缓存

  • A 实例化 -> 放二级缓存(原始对象)。
  • B 引用 A -> 从二级缓存拿(原始对象)。
  • 结果:完全没问题。
  • 结论如果不考虑 AOP,两级缓存确实够用了。

假设 2:有 AOP,只有两级缓存

为了解决循环依赖,我们必须让 B 拿到 A。

  • 做法一:所有 Bean 实例化后立刻创建代理,存入二级缓存。

    • 后果:严重违反 Spring 设计原则。Spring 希望正常的 Bean 在最后一步才创建代理。如果一上来就全部代理,生命周期全乱了。
  • 做法二:实例化后存原始对象到二级缓存。

    • B 拿到“原始 A”。
    • A 走完流程,在最后一步创建了“代理 A”存入一级缓存。
    • 后果单例被破坏。容器里是“代理 A”,B 只有“原始 A”。调用 B 时,A 的切面逻辑(事务、日志)统统失效。

结论:三级缓存的真正价值

三级缓存(Factory)是一个“延迟决策”的中间层。

它允许我们:

  1. 默认情况:不动声色,让 A 按照标准流程在最后一步创建代理。
  2. 循环依赖发生时:通过调用 Factory,被迫提前创建代理,并把这个代理放入二级缓存锁定下来,保证全局唯一。

五、 总结

  1. 一级缓存:存成品。
  2. 二级缓存:存半成品。主要为了解决 AOP 场景下,单例对象的一致性问题(确保多次引用拿到的是同一个代理)。
  3. 三级缓存:存工厂。为了打破生命周期,在循环依赖发生时,提供一个提前创建代理的机会。

最后留个思考题:

我们知道 @Transactional 的循环依赖能被三级缓存解决,但 @Async 注解会导致循环依赖报错,即便有了三级缓存也不行。

这也是大厂面试的高频坑,为什么?和代理创建的时机有什么关系?

欢迎在评论区留下你的看法!觉得有收获的兄弟,点赞、收藏防走丢,下期带你手写一个简易版 Spring。