什么是 Spring 的循环依赖问题?
2026年02月03日
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 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/
面试考察点
面试官提出这个问题,通常不只是想听一个定义,而是希望考察候选人:
- 对 Spring IoC 容器核心工作原理的理解深度:能否清晰描述 Bean 的创建、初始化流程,以及依赖注入发生的时机。
- 对“循环依赖”这一特定问题的场景化认知:不仅仅是理论,更想知道你是否在实际开发中遇到过,以及它的典型表象。
- 对 Spring 解决方案(三级缓存)的掌握程度:这是考察的核心,你是否理解
singletonFactories、earlySingletonObjects、singletonObjects这三个缓存各自的作用与协作流程。 - 对 Spring 解决范围局限性的认知:知道 Spring 并非能解决所有类型的循环依赖,理解其解决的前提条件(如单例、Setter/字段注入等)。
- 架构设计与规避意识:优秀的开发者不仅知道如何解决,更知道如何从设计上避免。面试官想看你是否有良好的设计嗅觉。
核心答案
Spring 循环依赖指的是,在 Spring IoC 容器管理的 Bean 之间,存在一个相互依赖的“环”。例如,Bean A 的创建需要注入 Bean B,而 Bean B 的创建又需要注入 Bean A,导致容器无法确定谁应该先被完全初始化。
Spring 通过 “三级缓存” 机制,巧妙地解决了单例(Singleton)作用域下,主要通过 Setter 注入(或字段注入) 造成的循环依赖问题。其核心思想是 “提前暴露” 一个尚未完成初始化的 Bean 引用(即对象地址),供其他 Bean 依赖注入使用,从而打破循环等待。
深度解析
原理/机制:三级缓存工作流程
Spring 解决循环依赖的关键在于 DefaultSingletonBeanRegistry 类中的三个 Map,俗称 “三级缓存”:
singletonObjects(一级缓存):存放已完成所有初始化步骤(实例化、属性填充、初始化方法)的 “成品” 单例 Bean。这是最主要的缓存,我们getBean时最终获取到的对象就来自这里。earlySingletonObjects(二级缓存):存放提前暴露的 “半成品” 单例 Bean。这些 Bean 已经实例化,但尚未进行属性填充和初始化。它用于避免重复创建代理对象。singletonFactories(三级缓存):存放单例工厂对象 (ObjectFactory)。当 Bean 被实例化后,会将其对应的工厂对象放入此缓存。工厂对象的getObject()方法能返回这个提前暴露的 Bean 引用(可能是原始对象,也可能是代理对象)。
以一个经典的 A 依赖 B,B 依赖 A 为例(均为单例,Setter 注入):
- 开始创建 A。调用
getSingleton(“a”)依次查询一级 -> 二级 -> 三级缓存,均未找到。 - 实例化 A(在堆中分配内存,
new A()),此时 A 的属性b还是null。 - 关键步骤:将包含 A(原始对象或早期代理)的
ObjectFactory放入三级缓存 (singletonFactories),并从二级缓存中移除 A(如果存在)。 - 开始为 A 进行属性填充(
populateBean)。发现需要注入 B。 - 开始创建 B。流程同上,实例化 B 后,将 B 的
ObjectFactory放入三级缓存。 - 为 B 进行属性填充时,发现需要注入 A。
- 再次调用
getSingleton(“a”)。- 一级缓存没有(A 未初始化完)。
- 二级缓存没有。
- 三级缓存命中! 通过
singletonFactories中 A 的工厂对象,获取到 A 的早期引用(可能是原始对象)。将这个早期引用放入二级缓存 (earlySingletonObjects),并从三级缓存中移除该工厂。
- B 成功获得 A 的早期引用,完成属性填充和后续初始化,成为一个 “成品” Bean,并被放入一级缓存 (
singletonObjects)。 - B 创建完毕,返回到 A 的创建流程。此时 A 获得了“成品” B 的引用,完成自己的属性填充和初始化。
- 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/字段注入。
-
常见误区:
- “Spring 能解决所有循环依赖”:如上表所示,这是错误的认知。
- 混淆“提前暴露”的对象状态:提前暴露的只是对象的引用(内存地址),该对象此时属性是空的,是一个“空壳”。依赖方拿到这个引用后,可以将其设置到自己的属性中,但不能立刻调用其业务方法(可能抛出 NPE),因为它的依赖还没注入完。
- 不理解三级缓存中
singletonFactories的必要性:很多人疑惑为什么需要三级,二级不够吗?关键在于处理 AOP 代理。工厂模式提供了生成代理的灵活性,确保在整个循环中,对于同一个 Bean,大家拿到的是同一个早期代理对象。
总结
Spring 通过三级缓存和提前暴露对象引用的机制,巧妙地解决了单例模式下 Setter/字段注入造成的循环依赖问题,但这应被视为容器的 “修复” 能力,而非鼓励使用的设计模式,在架构设计时我们应尽力避免循环依赖的产生。