北京时间 2026 年 4 月 10 日发布
在日常 Spring 开发中,你一定写过类似这样的代码:ServiceA 调用 ServiceB 的业务,ServiceB 又需要反向回调 ServiceA。不知不觉间,A 依赖 B、B 又依赖 A——这就是 循环依赖(Circular Dependency) 。绝大多数开发者天天用 @Autowired,项目能跑起来就不深究,但一到面试被问到“循环依赖是怎么解决的”,要么语焉不详只说“三级缓存”,要么被追问“为什么二级不够”“构造器注入为什么不行”时当场卡壳-3。今天我们就从概念到源码,再到面试必考的高频问题,彻底把这套机制吃透。本文目标读者: 技术入门/进阶学习者、在校学生、面试备考者、相关技术栈开发工程师。本文定位: 技术科普 + 原理讲解 + 代码示例 + 面试要点,兼顾易懂性与实用性。

一、痛点切入:先看这段“会报错”的代码
// ❌ 这样写,Spring Boot 2.6+ 启动直接报错@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; } }
启动报错信息非常直白:
The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | userService defined in ... ↑ ↓ | orderService defined in ... └─────┘
为什么字段注入能正常运行,构造器注入就不行? 这就引出了 Spring 的核心设计:三级缓存。但在分析它之前,先看看如果完全没有 Spring 的“帮忙”,我们自己在普通代码中会如何陷入死锁。
二、旧有实现方式的致命缺陷
假设没有 Spring 的 IOC 容器,我们自己手动管理对象依赖:
// 原始手动创建方式 public class A { private B b; public A() { // 想在构造时就注入B,但B还没创建 } public void setB(B b) { this.b = b; } } public class B { private A a; public B() { // 想在构造时就注入A,但A还没创建 } public void setA(A a) { this.a = a; } }
// 手动组装 A a = new A(); // 先创建A B b = new B(); // 再创建B a.setB(b); // 把B注入A b.setA(a); // 把A注入B
这套“先实例化空壳,再互相赋值”的做法其实可行——关键在于先创建对象,后注入依赖。但它的缺点非常明显:
耦合高: 创建对象的顺序需要开发人员手动控制,代码零散。
扩展性差: 一旦类数量增多,手动维护依赖链变得极其困难。
代码冗余: 每个依赖都需要显式的 setter 调用,重复代码随处可见。
Spring 的三级缓存设计,本质上就是对上述“先创建后注入”思路的系统化封装,再加上对代理对象(AOP)的精细处理,让框架自动完成这套复杂流程。
三、核心概念详解
概念 A:循环依赖(Circular Dependency)
标准定义: Circular Dependency,指的是两个或多个 Bean 之间互相持有对方的引用,形成闭环依赖关系-1。
一句话理解: “先有鸡还是先有蛋”的问题——Bean A 需要 Bean B 才能创建,Bean B 又需要 Bean A 才能创建。
典型表现形式:
构造器注入产生的循环依赖:在
new对象时就被堵住了,属于“硬死锁”,Spring 无法解决-1。Setter/字段注入产生的循环依赖(单例模式):Spring 可以通过三级缓存解决-1。
多例(Prototype)模式产生的循环依赖:每次
getBean()都产生新对象,无穷无尽最终导致 OOM,Spring 无法解决-1。
作用和价值: 理解循环依赖的解决机制,有助于深入理解 Spring 容器管理 Bean 生命周期的全貌,也是面试中衡量候选人源码理解深度的重要考点。
概念 B:三级缓存(Three-Level Cache)
标准定义: 三级缓存是 Spring 在 DefaultSingletonBeanRegistry 中定义的三个 Map,用于在不同阶段存储单例 Bean 的状态,协同解决循环依赖问题-45。
一级缓存:
singletonObjects—— 存放完全初始化完成的“成品”Bean。二级缓存:
earlySingletonObjects—— 存放提前暴露的“半成品”Bean(已实例化但未填充属性)。三级缓存:
singletonFactories—— 存放ObjectFactory(对象工厂函数),仅在调用getObject()时才会生成 Bean 实例-45。
概念 A 与 B 的关系
关系梳理: 三级缓存是解决循环依赖的具体实现手段,循环依赖是三级缓存要解决的核心问题。
一句话总结: 三级缓存是 Spring 用来“提前暴露半成品 Bean 引用”的工具集,从而打破循环依赖的闭环。
两种注入方式的区别与示例
| 对比维度 | Setter/字段注入(可解决) | 构造器注入(不可解决) |
|---|---|---|
| 注入时机 | Bean 实例化之后,通过反射调用 setter | Bean 实例化期间,通过构造器传入 |
| Bean 状态 | 允许“空壳对象”存在 | 必须拿到所有依赖才能创建对象 |
| 解决循环依赖 | ✅ 三级缓存机制 | ❌ 直接抛出异常 |
代码示例对比:
// ✅ Setter注入(Spring可以解决循环依赖) @Service public class A { private B b; @Autowired public void setB(B b) { this.b = b; } } // ❌ 构造器注入(Spring无法解决) @Service public class A { private final B b; public A(B b) { this.b = b; } // 必须在构造时拿到B,但B依赖A,形成死锁 }
四、核心机制:三级缓存如何协同工作
三级缓存的定义
| 缓存级别 | 名称 | 数据结构 | 作用 |
|---|---|---|---|
| 一级缓存 | singletonObjects | ConcurrentHashMap<String, Object> | 存放完全初始化完成的成品 Bean,供业务直接使用-40 |
| 二级缓存 | earlySingletonObjects | HashMap<String, Object> | 存放提前暴露的“半成品”Bean(已实例化,未完成属性填充)-40 |
| 三级缓存 | singletonFactories | HashMap<String, ObjectFactory<?>> | 存放 ObjectFactory 回调函数,用于按需生成半成品 Bean(支持 AOP 代理延迟创建)-40 |
完整解决流程(以 A 依赖 B、B 依赖 A 为例)
开始创建 A:调用
getSingleton("A"),三级缓存均无 A。Spring 实例化 A(调用构造器),得到原始对象a_原始。将 A 放入三级缓存:
singletonFactories.put("A", ObjectFactory用来生产A的早期引用)。开始填充 A 的属性:发现 A 需要 B → 调用
getSingleton("B")。开始创建 B:实例化 B,将 B 的
ObjectFactory放入三级缓存。填充 B 的属性:发现 B 需要 A → 调用
getSingleton("A")。关键步骤——三级缓存中获取 A:
一级缓存查找 A → 没有(A 还在创建中)
二级缓存查找 A → 没有
三级缓存查找 A → 找到
ObjectFactory→ 调用getObject()生成 A 的早期引用(半成品)→ 将其移入二级缓存将 A 的早期引用注入 B,B 完成初始化 → B 移入一级缓存
回到 A 的创建:拿到 B 的完整实例注入 A,A 完成初始化 → A 移入一级缓存,清理二级/三级缓存-13-40
核心理解:三级缓存机制的本质,就是在 Bean 的依赖注入阶段,允许“先拿地址后填属性”——只要对象的内存地址已确定,就可以被其他 Bean 引用,即便它的属性还没填完-。
五、底层原理:为什么需要三级而不是两级?
这是面试中最容易翻车的追问。核心答案:为了支持 AOP 代理对象在循环依赖中的正确生成。
如果只用二级缓存(早期暴露直接存原始对象),当 Bean 需要被 AOP 代理(如加了
@Transactional)时,就无法保证循环依赖链中所有地方拿到的是同一个代理对象。三级缓存存储的是
ObjectFactory(函数式接口),只有当真正被依赖时才会调用它生成早期引用。如果 Bean 需要 AOP,ObjectFactory会在返回对象前执行代理增强,确保最终暴露的是代理对象而非原始对象。若在实例化阶段就强行生成代理对象,虽然也能解决问题,但会导致对象过早创建,无法体现懒加载(Lazy)思想-36。
一句话总结:二级缓存只能解决“无 AOP”的循环依赖;三级缓存通过函数式接口延迟生成早期引用,解决了“有 AOP”场景下的代理对象一致性问题。
六、Spring 的解决边界
⚠️ 重要结论:Spring 只解决「单例 Bean 的字段/setter 循环依赖」,靠的是三级缓存 + 提前暴露“半成品”引用;构造器注入、原型作用域的循环依赖直接抛异常-。
✅ Spring 能解决的场景
依赖注入方式:Setter 注入 或
@Autowired字段注入Bean 作用域:单例(Singleton)
❌ Spring 无法解决的场景
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 构造器注入 | 构造时就必须完成所有依赖,无法提前暴露 | 改用 Setter/字段注入;或对一方使用 @Lazy |
| 原型(Prototype)Bean | 每次都是新对象,无法缓存和提前暴露 | 避免设计上的循环依赖;改用单例 + 对象池 |
| 多例之间的循环 | 无穷无尽创建,最终 OOM | 重新设计依赖关系 |
七、代码示例:字段注入的正常运行
// ✅ 字段注入(Spring可以解决循环依赖) @Service public class UserService { @Autowired private OrderService orderService; // 字段注入 public void createUser() { System.out.println("User created"); orderService.createOrderForNewUser(); } } @Service public class OrderService { @Autowired private UserService userService; // 字段注入 public void createOrderForNewUser() { System.out.println("Order created for user"); // 这里可以反向调用 UserService 的方法 } }
启动项目,一切正常!因为字段注入允许 Bean 先被实例化(空壳对象),再通过反射完成依赖注入,三级缓存机制在其中发挥了作用-13。
八、Spring Boot 2.6+ 的变化(加分项)
从 Spring Boot 2.6.0(Spring Framework 5.3)开始,为了鼓励更清晰的设计,默认禁用了循环依赖。如果项目中存在,启动时会直接报错,需要显式设置配置才能恢复旧版行为-14:
application.yml spring: main: allow-circular-references: true 显式开启循环依赖
💡 面试加分技巧:提到这个版本变化,能体现你对技术动态的关注,表明你不是只背了“三级缓存”这个词。
九、高频面试题与参考答案
Q1:Spring 如何解决循环依赖?
标准答案要点(踩分点):
Spring 通过三级缓存机制解决单例 Bean 的 Setter/字段注入循环依赖。
三级缓存包括:
singletonObjects(一级,成品)、earlySingletonObjects(二级,半成品)、singletonFactories(三级,对象工厂)。核心原理:在 Bean 实例化后、属性填充前,将早期引用提前暴露到三级缓存,打破依赖闭环-14。
解决限制:构造器注入和多例模式无法解决。
Q2:为什么需要三级缓存?两级不够吗?
标准答案要点(踩分点):
两级缓存只能解决不带 AOP 的循环依赖。
三级缓存存储的是
ObjectFactory函数式接口,在 Bean 被实际依赖时才调用生成早期引用。当 Bean 需要 AOP 代理(如
@Transactional)时,ObjectFactory会在返回对象前执行代理增强,保证循环依赖链中所有地方拿到的是同一个代理对象-36。若在实例化阶段直接生成代理对象存二级缓存,会破坏懒加载思想,且无法灵活处理 AOP 增强逻辑。
Q3:构造器注入的循环依赖为什么无法解决?
标准答案要点(踩分点):
构造器注入要求在 Bean 实例化(调用构造器)时必须传入所有依赖。
如果 A 的构造器需要 B、B 的构造器需要 A,两者都无法先完成实例化,没有“空壳对象”可供提前暴露。
因此 Spring 会在启动阶段直接抛出
BeanCurrentlyInCreationException-51。
Q4:原型(Prototype)Bean 的循环依赖为什么无法解决?
标准答案要点(踩分点):
原型 Bean 每次请求都会创建新实例,Spring 不对其进行缓存。
没有缓存就意味着无法提前暴露“半成品”引用供循环依赖中的其他 Bean 使用。
若 A(原型)依赖 B(原型)、B 又依赖 A,会陷入无限创建循环,最终导致内存溢出(OOM)-1。
Q5:除了三级缓存,还有哪些方式可以解决循环依赖?
标准答案要点(踩分点):
@Lazy延迟注入:对循环依赖的一方使用@Lazy,Spring 注入代理对象,延迟实际初始化-60。手动从容器获取:实现
ApplicationContextAware,在需要时通过getBean()获取依赖-60。接口隔离/中间层解耦:抽取共同逻辑到中间组件,打破直接依赖(推荐长期方案)-60。
事件驱动机制:通过
ApplicationEvent发布/订阅模式解耦-60。
十、总结回顾
| 核心知识点 | 要点总结 |
|---|---|
| 循环依赖定义 | 两个或多个 Bean 互相持有对方引用,形成闭环 |
| Spring 解决范围 | 仅单例 Bean + Setter/字段注入(构造器注入不行) |
| 解决机制 | 三级缓存:一级存成品、二级存半成品、三级存 ObjectFactory |
| 为什么三级 | 二级无法处理 AOP 代理对象的循环依赖 |
| 版本变化 | Spring Boot 2.6+ 默认禁用循环依赖,需显式开启 |
| 底层原理 | 基于反射 + 代理 + 函数式接口(ObjectFactory) |
重点提醒:
循环依赖通常是代码设计有问题的信号,应优先考虑重构而非依赖框架的“兜底”机制。
面试时不要只背“三级缓存”四个字,要能说清每级缓存存什么、为什么需要三级、构造器注入为何不行。
提到 Spring Boot 2.6+ 的默认禁用策略,是展示技术敏感度的加分项。
下一篇预告:我们将深入 Spring AOP 的底层实现,剖析 JDK 动态代理与 CGLIB 的原理区别,以及 AOP 如何与三级缓存协同处理代理对象的循环依赖。敬请关注!
推荐阅读:
Spring 官方文档:Core Technologies
三级缓存的终极奥义:Spring 如何用 3 个 Map 解决循环依赖

扫一扫微信交流