面试必问!Spring 如何用三级缓存魔法破解 Bean 循环依赖?(附源码级解析 & 避坑指南)

328 阅读10分钟

导语: 你是否曾在 Spring 项目中遭遇过 BeanCurrentlyInCreationException 的暴击?是否在面试中被连环追问“Spring 如何解决循环依赖”?这背后隐藏着 Spring IoC 容器最精妙的设计之一——三级缓存机制。今天,我们就撕开它的神秘面纱,从源码流程到设计哲学,彻底搞懂这个高频面试题,并教你写出更优雅的代码!


一、循环依赖:Spring 开发者的“经典噩梦”

想象一下:

  • BeanA 需要注入 BeanB
  • BeanB 又需要注入 BeanA
  • 两者相互等待,陷入死锁... 🤯

这就是循环依赖(Circular Dependency)。Spring 默认通过三级缓存(Three-level Cache) 这个精妙的机制,为单例Bean的Setter/Field注入场景提供了优雅的解决方案。但构造器注入原型Bean,它就无能为力了(后面细说)。

核心问题:如何在 Bean 未完全初始化完成前,提前暴露它的引用?


二、三级缓存:Spring 的破局利器

Spring 容器内部维护了三个核心 Map,它们构成了解决循环依赖的基石:

// 一级缓存(成品仓库):存放完全初始化好的、立即可用的 Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// 二级缓存(半成品展厅):存放提前暴露的 Bean(已实例化,但属性未填充/未初始化)。用于避免重复创建早期引用。
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

// 三级缓存(Bean 工厂车间):存放 ObjectFactory,用于生产 Bean 的早期引用(原始对象或代理对象)。这是解决循环依赖的关键入口。
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

关键点理解:

  • 一级缓存 (singletonObjects):最终的、完全可用的 Bean 归宿。
  • 二级缓存 (earlySingletonObjects):性能优化层。一旦某个 Bean 的早期引用被从三级缓存中创建出来并放到二级缓存,后续依赖查找就直接从这里拿,避免重复执行 ObjectFactory.getObject()(尤其是涉及代理时)。
  • 三级缓存 (singletonFactories)核心中的核心! 它存放的不是 Bean 本身,而是一个能生产 Bean 早期引用的工厂 (ObjectFactory)。这个工厂至关重要,因为它能处理 AOP 代理等需要包装原始对象的情况。工厂的存在让 Spring 有能力在需要时动态生成正确的引用(原始对象或代理对象)。

三、源码级流程拆解:以 A→B→A 为例

让我们跟随 Spring 容器的脚步,看三级缓存如何协同工作,破解 AB 的相爱相杀:

  1. 创建 Bean A (开始征途)

    • 实例化 A 调用 A 的构造函数,在堆内存中创建出一个原始对象(此时 A 的属性 b 还是 null)。
    • 暴露工厂 (关键一步!): 将这个原始对象包装进一个 ObjectFactory,并将这个工厂放入三级缓存 (singletonFactories)。代码体现:
      addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); // 核心!
      
      • getEarlyBeanReference() 是灵魂方法!它会检查该 Bean 是否需要被包装(如应用 BeanPostProcessor, 特别是 SmartInstantiationAwareBeanPostProcessor 用于 AOP 代理)。如果需要代理,此时就会创建代理对象;否则返回原始对象。这确保了后续注入的引用是最终形态(可能是代理)!
  2. 填充 A 的属性 (发现依赖 B)

    • 容器尝试为 A 的属性 b 注入值。
    • 发现需要 BeanB,但一级缓存 (singletonObjects) 中没有。
  3. 创建 Bean B (连锁反应)

    • 实例化 B 调用 B 的构造函数,创建 B 的原始对象。
    • 暴露工厂: 同样,将 BObjectFactory 放入三级缓存 (singletonFactories)
  4. 填充 B 的属性 (关键转折 - 需要 A!)

    • 容器尝试为 B 的属性 a 注入值。
    • 查找 BeanA
      • 一级缓存 (singletonObjects):无(A 还没完全初始化好)。
      • 二级缓存 (earlySingletonObjects):无(首次查找)。
      • 三级缓存 (singletonFactories)找到 A 的工厂!
    • 执行 ObjectFactory.getObject() (魔法发生!):
      • 调用之前放入的 ObjectFactory(即 () -> getEarlyBeanReference(...))。
      • getEarlyBeanReference() 执行:如果 A 需要代理(比如有 @Async, @Transactional 或自定义 AOP),此时创建并返回 A 的代理对象;否则返回 A 的原始对象。
      • 将得到的这个早期引用(可能是代理对象)放入二级缓存 (earlySingletonObjects)并从三级缓存中移除 A 的工厂
    • 注入: 将这个 A 的早期引用注入到 B 的属性 a 中。至此,B 持有了 A 的引用(即使 A 还没完全初始化好)!
  5. 完成 B 的初始化 (B 率先毕业)

    • 继续执行 B 的初始化逻辑(如 @PostConstruct 方法)。
    • 完全初始化好的 B 放入一级缓存 (singletonObjects)
    • 清理: 从二级缓存 (earlySingletonObjects) 和三级缓存 (singletonFactories) 中移除 B 的相关记录(如果存在)。
  6. 完成 A 的初始化 (A 紧随其后)

    • 回到 A 的属性填充流程,现在可以顺利拿到一级缓存中完全初始化好的 B,注入到 A 的属性 b
    • 继续执行 A 的初始化逻辑(如 @PostConstruct)。
    • 完全初始化好的 A 放入一级缓存 (singletonObjects)
    • 清理: 从二级缓存 (earlySingletonObjects) 中移除 A 的早期引用(如果有)。

🎉 循环依赖成功解决!AB 都完成了初始化并可用。


四、深度解析:设计精妙之处与关键考量

  1. 为什么是三级?两级不行吗?

    先说结论:Spring 设计三级缓存的核心是为了解决 AOP 代理与循环依赖的时序冲突。无 AOP 时,两级缓存足以处理循环依赖。

    各级缓存必要性与 AOP 的关系:

    1. 三级缓存 (singletonFactories - 工厂缓存):

      • 有 AOP:绝对必需。 它存储的 ObjectFactory 动态生成并返回代理对象作为早期引用,确保依赖方在循环中拿到的是最终形态(代理),解决了代理创建在初始化之后与依赖注入在初始化之前的矛盾。
      • 无 AOP:非必需(功能上可被二级缓存替代)。 其工厂虽能返回原始半成品对象,但二级缓存直接存储该对象也能达到相同目的。
    2. 二级缓存 (earlySingletonObjects - 早期引用缓存):

      • 有 AOP:必需(性能核心)。 缓存首次生成的代理对象(早期引用),避免同一 Bean 被多次依赖时重复执行昂贵的代理创建逻辑 (ObjectFactory.getObject())。
      • 无 AOP:必需(性能优化)。 缓存原始半成品对象,避免同一 Bean 被多次依赖时重复查找或构造
    3. 一级缓存 (singletonObjects - 成品缓存):

      • 有无 AOP 均必需。 存储最终初始化完成的、可用的单例 Bean 对象。
  2. AOP 代理的优雅处理:getEarlyBeanReference()

    • 这是三级缓存机制能正确处理 AOP 的核心。在暴露早期引用的关键时刻(放入三级缓存时),Spring 通过 ObjectFactory 预留了后路。
    • 当真正需要注入早期引用时(调用 getObject()),getEarlyBeanReference() 方法会应用那些需要在早期暴露阶段处理的 BeanPostProcessor(主要是 AbstractAutoProxyCreator),确保最终注入的是代理对象(如果需要代理)。如果等到完全初始化后再代理,循环依赖就无法解决了,因为注入的将是一个原始对象。
  3. 作用域限制:单例的专利

    • 仅单例 (Singleton) Bean 适用: Spring 容器只对单例 Bean 维护了这套三级缓存结构。原型 (Prototype) Bean 每次请求都会创建一个新实例,容器无法管理其生命周期和依赖关系,遇到循环依赖直接抛 BeanCurrentlyInCreationException
      if (isPrototypeCurrentlyInCreation(beanName)) {
          throw new BeanCurrentlyInCreationException(beanName);
      }
      
  4. 构造器注入:三级缓存的“死穴”

    • 为什么无法解决?
      @Component
      public class A {
          private final B b; // final 字段,必须在构造器赋值
          public A(B b) { this.b = b; } // 构造器注入:创建 A 实例时,必须立即有 B!
      }
      
      @Component
      public class B {
          private final A a; // final 字段,必须在构造器赋值
          public B(A a) { this.a = a; } // 构造器注入:创建 B 实例时,必须立即有 A!
      }
      
    • 根源: 构造器注入要求依赖在对象实例化完成的那一刻就必须就绪。此时 A 的构造函数执行需要 B,而 B 的构造函数执行又需要 A双方都卡在实例化这一步,没有任何一个 Bean 有机会被实例化并提前暴露其引用(即放入三级缓存)。三级缓存机制在属性注入阶段才介入,此时为时已晚。

五、实战:解决方案对比与最佳实践

方案适用场景是否推荐说明与注意事项
Setter/Field 注入属性循环依赖 (单例)推荐Spring 三级缓存天然支持。最常见、最安全的解决方式。
构造器注入循环依赖场景⚠️ 慎用无法解决自身循环依赖。 优先保证无循环,否则强推构造器注入会带来问题。
@Lazy 延迟加载解决特定循环问题 (如 Controller/Service)可选注解在依赖项上 (@Autowired private @Lazy B b;)。使用时才触发初始化,打破循环。简单有效,但可能掩盖设计问题。
代码重构根治循环依赖强烈推荐最根本的解决方案! 思考:
- 提取公共逻辑到新的 Service?
- 是否过度耦合?
- 使用事件 (ApplicationEvent) 解耦?
ApplicationContext.getBean()手动获取 Bean避免破坏 IoC 控制反转,增加耦合度,使测试困难。万不得已的 hack 手段。

📌 开发者最佳实践:

  1. 优先使用 Setter/Field 注入: 避免构造器注入带来的无法解决的循环依赖问题。
  2. 拥抱重构:将消除循环依赖作为首要目标!
    • 循环依赖往往是设计缺陷(高耦合、职责不清)的信号。
    • 积极思考: 能否将相互依赖的部分抽离到一个新的、独立的 Bean 中?能否使用观察者模式(事件监听)替代直接调用?
  3. @Lazy 作为权宜之计: 在紧急修复或重构成本过高时,@Lazy 是一个有效的临时解决方案。但要清醒认识到它只是延迟了问题,并非根治。
  4. 理解原理,面试不慌: 深刻理解三级缓存流程(尤其 getEarlyBeanReference 的作用)和构造器注入的限制,足以应对大部分深度面试提问。

六、总结:Spring 的智慧与开发者的责任

Spring 的三级缓存机制 (singletonFactories + earlySingletonObjects + singletonObjects) 是其 IoC 容器设计中极其精妙的一环。它通过:

  1. 提前暴露引用: 在 Bean 仅实例化后、未初始化前,就通过 ObjectFactory 将其(可能是代理对象)暴露出去。
  2. 代理感知: 核心方法 getEarlyBeanReference() 确保在依赖注入时提供的是最终形态的对象。
  3. 缓存协作: 三级分工明确,兼顾功能与性能。

它优雅地解决了单例 Bean Setter/Field 注入的循环依赖难题,展现了框架设计的强大。

然而,技术再巧妙,也非万能药。 构造器注入循环和原型 Bean 循环仍是禁区。更重要的是,过度依赖框架解决循环,往往掩盖了代码设计的不足。 作为追求卓越的开发者,我们应当:

  • 理解原理: 知晓三级缓存如何运作,知其然也知其所以然。
  • 善用工具: 在必要时正确使用 Setter/Field 注入或 @Lazy
  • 追求卓越: 将重构代码、消除循环依赖视为提升系统可维护性和设计质量的关键步骤! 写出低耦合、高内聚的代码,才是治本之道。

你在项目中遇到过哪种棘手的循环依赖?是用什么方案解决的?或者对三级缓存还有哪些疑问?欢迎在评论区分享讨论!