什么是 Spring 的循环依赖问题?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,通常不只是想听一个定义,而是希望考察候选人:

  1. 对 Spring IoC 容器核心工作原理的理解深度:能否清晰描述 Bean 的创建、初始化流程,以及依赖注入发生的时机。
  2. 对“循环依赖”这一特定问题的场景化认知:不仅仅是理论,更想知道你是否在实际开发中遇到过,以及它的典型表象。
  3. 对 Spring 解决方案(三级缓存)的掌握程度:这是考察的核心,你是否理解 singletonFactoriesearlySingletonObjectssingletonObjects 这三个缓存各自的作用与协作流程。
  4. 对 Spring 解决范围局限性的认知:知道 Spring 并非能解决所有类型的循环依赖,理解其解决的前提条件(如单例、Setter/字段注入等)。
  5. 架构设计与规避意识:优秀的开发者不仅知道如何解决,更知道如何从设计上避免。面试官想看你是否有良好的设计嗅觉。

核心答案

Spring 循环依赖指的是,在 Spring IoC 容器管理的 Bean 之间,存在一个相互依赖的“环”。例如,Bean A 的创建需要注入 Bean B,而 Bean B 的创建又需要注入 Bean A,导致容器无法确定谁应该先被完全初始化。

Spring 通过 “三级缓存” 机制,巧妙地解决了单例(Singleton)作用域下,主要通过 Setter 注入(或字段注入) 造成的循环依赖问题。其核心思想是 “提前暴露” 一个尚未完成初始化的 Bean 引用(即对象地址),供其他 Bean 依赖注入使用,从而打破循环等待。

深度解析

原理/机制:三级缓存工作流程

Spring 解决循环依赖的关键在于 DefaultSingletonBeanRegistry 类中的三个 Map,俗称 “三级缓存”:

  1. singletonObjects(一级缓存):存放已完成所有初始化步骤(实例化、属性填充、初始化方法)的 “成品” 单例 Bean。这是最主要的缓存,我们 getBean 时最终获取到的对象就来自这里。
  2. earlySingletonObjects(二级缓存):存放提前暴露的 “半成品” 单例 Bean。这些 Bean 已经实例化,但尚未进行属性填充和初始化。它用于避免重复创建代理对象。
  3. singletonFactories(三级缓存):存放单例工厂对象 (ObjectFactory)。当 Bean 被实例化后,会将其对应的工厂对象放入此缓存。工厂对象的 getObject() 方法能返回这个提前暴露的 Bean 引用(可能是原始对象,也可能是代理对象)。

以一个经典的 A 依赖 B,B 依赖 A 为例(均为单例,Setter 注入)

  1. 开始创建 A。调用 getSingleton(“a”) 依次查询一级 -> 二级 -> 三级缓存,均未找到。
  2. 实例化 A(在堆中分配内存,new A()),此时 A 的属性 b 还是 null
  3. 关键步骤:将包含 A(原始对象或早期代理)的 ObjectFactory 放入三级缓存 (singletonFactories),并从二级缓存中移除 A(如果存在)。
  4. 开始为 A 进行属性填充(populateBean)。发现需要注入 B。
  5. 开始创建 B。流程同上,实例化 B 后,将 B 的 ObjectFactory 放入三级缓存。
  6. 为 B 进行属性填充时,发现需要注入 A。
  7. 再次调用 getSingleton(“a”)
    • 一级缓存没有(A 未初始化完)。
    • 二级缓存没有。
    • 三级缓存命中! 通过 singletonFactories 中 A 的工厂对象,获取到 A 的早期引用(可能是原始对象)。将这个早期引用放入二级缓存 (earlySingletonObjects),并从三级缓存中移除该工厂。
  8. B 成功获得 A 的早期引用,完成属性填充和后续初始化,成为一个 “成品” Bean,并被放入一级缓存 (singletonObjects)
  9. B 创建完毕,返回到 A 的创建流程。此时 A 获得了“成品” B 的引用,完成自己的属性填充和初始化。
  10. A 初始化完成,被放入一级缓存,并从二级、三级缓存中清理掉。

代码示例

一个最简单的循环依赖场景:

@Service
public class ServiceA {
    @Autowired // 或者通过Setter方法
    private ServiceB serviceB;
}

@Service
public class ServiceB {
    @Autowired // 或者通过Setter方法
    private ServiceA serviceA;
}

启动 Spring 应用,上述代码在单例模式下可以正常运作。

对比分析 & 适用场景局限性

Spring 不能解决所有循环依赖,其支持范围有严格限定:

场景是否支持原因分析
单例 Bean + Setter/字段注入支持这是三级缓存机制设计的主要目标。对象实例化与属性填充分步进行。
原型 (Prototype) Bean不支持Spring 不缓存原型 Bean,每次 getBean 都会创建新对象,无法提前暴露一个确定的引用。容器会直接抛出 BeanCurrentlyInCreationException
构造器 (Constructor) 注入不支持Bean 在实例化 (new) 时就需要完整的依赖对象。此时 Bean 的引用还未创建,更无法提前暴露,形成“先有鸡还是先有蛋”的死锁。
单例 Bean,但依赖通过 @Async, @Transactional 等 AOP 代理有条件支持这是三级缓存中 ObjectFactory 的关键价值。工厂可以判断并返回早期代理对象,保证注入的 A 和最终的 A 是同一个代理对象。如果只用二级缓存,可能产生多个不同的代理对象,破坏单例性。

最佳实践与常见误区

  • 最佳实践

    • 设计上规避:循环依赖通常是代码结构不佳(职责不清、高耦合)的信号。优先考虑通过重构(提取公共逻辑到第三类、使用事件/回调、应用设计模式如观察者模式)来消除循环依赖。
    • 使用构造器注入:虽然它无法被 Spring 自动解决循环依赖,但它能强制在编译期暴露依赖问题,使 Bean 的依赖关系更清晰,且对象在构造后即处于完全初始化的状态(推荐做法)。
    • 如果必须使用,确保是单例且使用 Setter/字段注入
  • 常见误区

    1. “Spring 能解决所有循环依赖”:如上表所示,这是错误的认知。
    2. 混淆“提前暴露”的对象状态:提前暴露的只是对象的引用(内存地址),该对象此时属性是空的,是一个“空壳”。依赖方拿到这个引用后,可以将其设置到自己的属性中,但不能立刻调用其业务方法(可能抛出 NPE),因为它的依赖还没注入完。
    3. 不理解三级缓存中 singletonFactories 的必要性:很多人疑惑为什么需要三级,二级不够吗?关键在于处理 AOP 代理。工厂模式提供了生成代理的灵活性,确保在整个循环中,对于同一个 Bean,大家拿到的是同一个早期代理对象。

总结

Spring 通过三级缓存提前暴露对象引用的机制,巧妙地解决了单例模式下 Setter/字段注入造成的循环依赖问题,但这应被视为容器的 “修复” 能力,而非鼓励使用的设计模式,在架构设计时我们应尽力避免循环依赖的产生。