北京时间 2026-04-10 | 阅读时长:约 12 分钟
开篇引入

在 Spring 面试中,“Spring 如何解决循环依赖”是一道绕不开的高频考题。很多开发者背下了“三级缓存”这个答案,却说不清为什么需要三级、二级够不够、构造器注入为何无法解决-24。作为 Java 开发者日常使用频率最高的框架之一,Spring 的这一设计既精妙又复杂,理解它不仅是面试通关的关键,更是深入掌握 IoC 容器运作机制的基础。
本文将从问题痛点 → 核心概念 → 源码解析 → 代码示例 → 面试要点逐层展开,帮助你建立完整的知识链路。无论是技术入门者、面试备考者,还是希望深入理解 Spring 原理的开发者,都能从中获得可落地的收获。

一、痛点切入:为什么需要循环依赖解决方案?
1.1 什么是循环依赖?
循环依赖,指的是两个或多个 Bean 之间互相持有对方的引用,形成闭环依赖关系。最典型的就是 A 依赖 B,B 又依赖 A-1。
@Service public class A { @Autowired private B b; // A 依赖 B } @Service public class B { @Autowired private A a; // B 依赖 A,形成循环 }
如果 Spring 不做特殊处理,应用启动时就会抛出 BeanCurrentlyInCreationException 异常-1。
1.2 旧有方式的缺陷
在不支持循环依赖的传统 IoC 容器设计中,Bean 创建流程是“实例化 → 注入依赖 → 初始化”的顺序执行。当遇到 A 依赖 B、B 依赖 A 的情况时,容器会陷入无限等待——要创建 A 就必须先有 B,要创建 B 又必须先有 A。这种“鸡生蛋、蛋生鸡”的死锁问题会导致容器直接报错退出。
这一缺陷带来的问题:
| 问题 | 具体表现 |
|---|---|
| 耦合度过高 | 两个类职责边界模糊,互相引用意味着它们承担了过多耦合的职责 |
| 可维护性差 | 修改其中一个类时,另一个类往往也需要同步调整 |
| 测试困难 | 单元测试时需要同时 Mock 两个相互依赖的服务 |
| 启动失败 | 应用无法正常启动,影响部署和交付 |
1.3 设计初衷
正是为了解决这一痛点,Spring 设计了三级缓存机制,通过“提前暴露正在创建中的 Bean”来打破依赖闭环,使容器能在不牺牲代码可读性的前提下优雅地处理循环依赖问题-2。
二、核心概念:Spring 的三级缓存
Spring 通过三个 Map 来存储不同状态的 Bean,分别对应 Bean 生命周期的不同阶段。
2.1 一级缓存:singletonObjects(成品池)
定义:private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);-1
作用:存放完全初始化完成的单例 Bean(成品对象),供业务代码直接使用。Bean 走完实例化 → 属性填充 → 初始化的全部流程后才会被放入这里-2。
💡 类比:就像超市货架上已经包装好的商品,消费者可以直接取用。
2.2 二级缓存:earlySingletonObjects(半成品池)
定义:private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);-1
作用:存放已实例化但尚未完成属性填充和初始化的提前暴露 Bean(半成品)。当 A 依赖 B、B 又依赖 A 时,A 在属性注入前会把自己“提前暴露”出来,放进这个缓存,供 B 在创建时获取 A 的早期引用-2。
💡 类比:就像在生产线上的半成品,虽然没有全部完工,但已经有基本形状可供其他工序使用。
2.3 三级缓存:singletonFactories(对象工厂池)
定义:private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);-1
作用:存放 ObjectFactory(对象工厂),这是一个函数式接口,仅在调用 getObject() 时才会创建 Bean 实例-1。它不存对象,而是存一个 lambda 表达式,如 () -> getEarlyBeanReference(beanName, mbd, bean)-2。
💡 类比:就像“商品兑换券”,不直接给你商品,而是在需要的时候再去兑换——什么时候要,什么时候才生成。
三、关联概念:构造器注入 vs 字段/Setter 注入
3.1 两种注入方式的区别
| 对比维度 | 构造器注入 | 字段/Setter 注入 |
|---|---|---|
| 定义 | 通过构造函数参数声明依赖 | 通过 @Autowired 或 Setter 方法注入 |
| 依赖时机 | 实例化时就需要所有依赖 | 实例化后、属性填充阶段注入 |
| 循环依赖支持 | ❌ 不支持 | ✅ 支持(单例模式下) |
| Spring 推荐度 | ⭐⭐⭐⭐⭐ 官方最推荐 | ⭐⭐⭐ 兼容性强 |
3.2 为什么构造器注入无法解决循环依赖?
构造器注入要求在实例化时就提供所有依赖,而循环依赖场景下两个 Bean 都无法完成实例化——Spring 的三级缓存机制依赖于“提前暴露早期引用”,这在构造器注入场景下无法实现-4。
// ❌ 构造器注入——无法解决循环依赖,启动直接报错 @Service public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService = orderService; } } @Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } }
3.3 概念关系总结
一句话总结:构造器注入是设计规范,三级缓存是实现手段;前者强调“依赖的不可变性”,后者解决“依赖的循环性”。Spring 允许你在设计规范与运行灵活性之间做权衡。
四、代码示例:三级缓存解决循环依赖的全流程
4.1 极简示例代码
// A 依赖 B @Component public class A { @Autowired private B b; public void doSomething() { System.out.println("A is doing something"); } } // B 依赖 A @Component public class B { @Autowired private A a; public void doSomething() { System.out.println("B is doing something"); } }
4.2 执行流程详解
| 步骤 | 动作 | 缓存状态变化 |
|---|---|---|
| 1 | 开始创建 Bean A | 将 A 的名称加入 singletonsCurrentlyInCreation |
| 2 | A 实例化完成(调用构造器) | 将 A 的 ObjectFactory 放入三级缓存 |
| 3 | 填充 A 的属性,发现需要 B | 触发 B 的创建流程 |
| 4 | 开始创建 Bean B | B 实例化完成,将 B 的 ObjectFactory 放入三级缓存 |
| 5 | 填充 B 的属性,发现需要 A | 从三级缓存获取 A 的 ObjectFactory → 调用 getObject() 生成 A 的早期引用 → 放入二级缓存 → 移除三级缓存中的 A |
| 6 | B 的属性填充完成 | B 继续完成初始化,初始化后 B 移入一级缓存,移除二级缓存 |
| 7 | 返回 A 的创建流程 | A 从一级缓存获取 B 并注入 |
| 8 | A 完成初始化 | A 移入一级缓存,移除二级缓存 |
🎯 关键理解:三级缓存中存的是工厂而不是 Bean 实例本身,这个设计让 Spring 能够在真正需要暴露早期引用时,才动态决定是否生成 AOP 代理对象-2。
五、底层原理:为什么一定要三级缓存?
5.1 核心疑问
很多面试者都会问:用二级缓存不就能解决循环依赖了吗?为什么非要用三级缓存?
5.2 答案:AOP 代理是根本原因
如果只有二级缓存,Spring 就不得不在 Bean 实例化之后立刻决定是否生成 AOP 代理。但此时尚未走到初始化阶段,无法判断是否需要增强(比如 @PostConstruct 方法里才添加的切面标记)-2。
三级缓存通过 ObjectFactory 工厂,将“是否生成代理”的判断延迟到第一次被其他 Bean 引用时,实现了两个目标:
按需生成代理:只有当其他 Bean 真正需要这个早期引用时,才调用工厂生成对象
保证代理唯一性:同一个 Bean 被多个地方依赖时,能确保返回的是同一个代理对象,避免地址不一致的问题
📌 一句话总结:三级缓存不是炫技,而是对“懒加载代理”和“循环依赖破局”的一次精巧兼顾-2。
5.3 底层技术支撑
这一机制底层依赖了 Spring 的核心知识点:
反射(Reflection):动态调用构造器和 Setter 方法
代理模式(Proxy Pattern):CGLIB 和 JDK 动态代理实现 AOP
函数式接口(
ObjectFactory):延迟执行,按需创建对象Map 缓存与并发控制:通过
ConcurrentHashMap和synchronized保证线程安全
六、高频面试题与参考答案
面试题 1:Spring 是如何解决循环依赖的?
参考答案(踩分点)
Spring 通过三级缓存机制解决单例模式下 Setter/字段注入的循环依赖问题:
一级缓存(singletonObjects) :存放完全初始化的成品 Bean
二级缓存(earlySingletonObjects) :存放提前暴露的半成品 Bean
三级缓存(singletonFactories) :存放 ObjectFactory 对象工厂
核心思路是 “提前暴露正在创建中的 Bean” ,在 Bean 实例化后、属性填充前,将其工厂放入三级缓存,当依赖方需要该 Bean 时,通过工厂生成早期引用供其使用,从而打破循环依赖的死锁。
面试题 2:为什么需要用三级缓存,二级不行吗?
参考答案(踩分点)
不行。二级缓存解决不了 AOP 代理对象提前暴露的问题:
如果只有二级缓存,Spring 必须在实例化后立即决定是否生成代理对象
但此时 Bean 还未初始化,无法确定是否需要增强(如
@Transactional、@Async等注解是在初始化阶段生效的)三级缓存将“是否生成代理”的判断延迟到第一次被其他 Bean 引用时,既解决了循环依赖,又保证了 AOP 的正确生效
面试题 3:Spring 无法解决哪些循环依赖?
参考答案(踩分点)
以下三种场景 Spring 无法自动解决循环依赖:
构造器注入循环依赖:实例化时需要所有依赖,无法提前暴露半成品
Prototype 多例模式循环依赖:Spring 不对 prototype 作用域的 Bean 使用缓存标记机制
Spring Boot 2.6+ 默认配置下:
spring.main.allow-circular-references=false,默认禁止循环依赖,需要手动开启
面试题 4:Spring Boot 2.6+ 为什么要默认禁止循环依赖?
参考答案(踩分点)
Spring 团队基于以下考量:
设计原则:循环依赖违反单一职责原则,鼓励开发者重构代码而非依赖框架
运行风险:循环依赖可能导致事务代理失效(提前暴露的可能是原始对象而非代理对象)
长期维护:强制开发者在早期就考虑更清晰的模块划分和依赖方向-33
七、总结与进阶
7.1 核心知识点回顾
| 知识点 | 核心要点 |
|---|---|
| 问题定义 | A 依赖 B、B 依赖 A 形成的闭环依赖关系 |
| 解决方案 | 三级缓存机制:一级存成品、二级存半成品、三级存工厂 |
| 适用条件 | 单例模式 + 字段/Setter 注入 |
| 不适用场景 | 构造器注入、Prototype 模式 |
| 设计精髓 | 三级缓存的本质是为了解决 AOP 代理对象的提前暴露问题 |
| 最新趋势 | Spring Boot 2.6+ 默认禁止循环依赖,推动开发者走向更解耦的设计 |
7.2 易错点提醒
⚠️ 常见误区澄清:
“Spring 能解决所有循环依赖” ❌ → 构造器循环依赖无法解决
“三级缓存是为了性能” ❌ → 核心是为了解决 AOP 代理问题
“只要加 @Lazy 就行” ❌ → 治标不治本,且影响启动速度-24
7.3 进阶预告
下篇我们将深入 Spring AOP 的底层原理,讲解动态代理是如何在三级缓存机制中与循环依赖协作的,敬请关注!
本文由全AI智能助手基于 Spring 5.3.x 源码及业界最佳实践整理,旨在帮助开发者构建完整的 Spring 循环依赖知识体系。如有疑问,欢迎在评论区交流讨论。
扫一扫微信交流