Spring 循环依赖:神秘AI助手拆解三级缓存与面试考点

小编头像

小编

管理员

发布于:2026年04月28日

7 阅读 · 0 评论

本文适合:Spring技术栈开发者、面试备考者
阅读收益:搞懂循环依赖的本质、掌握三级缓存机制、拿下高频面试题

在 Spring 框架体系中,循环依赖(Circular Dependency) 是面试中出镜率最高的核心考点之一,也是许多开发者“会用但说不清”的知识痛点。日常开发中,我们习惯性地使用 @Autowired 字段注入,项目顺利跑起来,却很少追问:当 A 依赖 B、B 依赖 A 时,Spring 到底是如何“破局”的?为什么换成构造器注入就报错了?Spring 2.6.0 之后默认禁用循环依赖又是什么原因?本文将逐一拆解,从痛点场景到三级缓存原理,从源码关键逻辑到高频面试题,带你建立完整的知识链路。

更新时间:北京时间 2026 年 4 月 10 日
适用版本:Spring Boot 3.4.x / 4.0.x 及以上

一、痛点切入:为什么会出现循环依赖?

先看一段“能跑起来”的代码:

java
复制
下载
@Service
public class A {
    @Autowired
    private B b;      // A 依赖 B
}

@Service
public class B {
    @Autowired
    private A a;      // B 依赖 A —— 形成闭环
}

项目启动成功,一切正常。但当你将 @Autowired 改成构造器注入后:

java
复制
下载
@Service
public class A {
    private final B b;
    public A(B b) { this.b = b; }
}

@Service
public class B {
    private final A a;
    public B(A a) { this.a = a; }
}

启动时直接抛出 BeanCurrentlyInCreationException,报错信息以循环箭头图示标出 A ↔ B 的依赖闭环。-37

为什么同样的循环依赖,字段注入能过,构造器注入就过不了?

关键在于 Bean 创建的时机差异。Spring Bean 的创建分为三步:

  1. 实例化:调用构造器创建对象(此时对象已有内存地址,但属性均为默认值)

  2. 属性填充:注入依赖

  3. 初始化:执行 @PostConstruct 等初始化方法

构造器注入将“实例化”与“属性填充”合并一步完成——这意味着在构造器中需要传入依赖对象时,该依赖对象必须已经完整可用。当 A 和 B 通过构造器互相依赖时,A 的实例化需要 B,B 的实例化需要 A,双方都无法先完成实例化,形成“死锁”。

而字段注入(以及 Setter 注入)则给了 Spring 一个缓冲空间:A 先完成实例化(此时 b 为 null),然后 Spring 在填充属性时去创建 B;B 实例化后发现自己依赖 A,此时 A 的“半成品”已经存在,可以直接拿来用。-12

二、核心概念讲解:三级缓存

Spring 解决单例 Bean 字段 / Setter 循环依赖的核心机制,就是“三级缓存”。 Spring 在 DefaultSingletonBeanRegistry 类中定义了三个 Map,分别存储处于不同状态的 Bean 实例:-11

缓存级别缓存名称作用
一级缓存singletonObjects存放完全初始化完成的单例 Bean(成品),供业务直接使用
二级缓存earlySingletonObjects存放提前暴露的半成品 Bean(已实例化但未完成属性填充和初始化)
三级缓存singletonFactories存放 ObjectFactory(对象工厂),这是一个函数式接口,调用 getObject() 时才真正创建 Bean 实例

生活化类比:想象一个“新员工入职流程”。

  • 三级缓存:人事部门收到一份“简历模板”,不急着打印,等你问“张三在哪”时才当场打印出来。

  • 二级缓存:简历已经打印出来了,但还没录入系统和分配工位,是个“半成品”。

  • 一级缓存:员工完成入职培训、分配到工位,成为“正式员工”,可以直接开展工作。

关键理解:三级缓存存的不是 Bean 实例,而是一个“能生产 Bean 实例的工厂函数”。这种“延迟创建”的设计,恰恰是解决 AOP 代理问题的关键。

三、关联概念讲解:二级缓存与三级缓存的关系

二级缓存 earlySingletonObjects 和三级缓存 singletonFactories 共同服务于“提前暴露”这一目标,但分工明确:

  • 三级缓存:存的是 ObjectFactory<?>,即 “如何创建 Bean”的工厂,不主动创建,只有调用时才会生产。

  • 二级缓存:存的是实际的 Bean 实例(半成品),是三级缓存工厂的“产出结果”。

两者的流转关系是:当一个 Bean 完成实例化后,Spring 会将其包装成一个 ObjectFactory 放入三级缓存。当其他 Bean 在依赖注入时通过 getSingleton() 获取该 Bean:

  1. 先查一级缓存(成品)

  2. 没有则查二级缓存(早期暴露的半成品)

  3. 还没有则从三级缓存取出 ObjectFactory,调用 getObject() 创建实例,存入二级缓存,并移除三级缓存,最后返回该实例。-11

一句话概括:三级缓存负责“生产”,二级缓存负责“临时存储生产出来的半成品”,一级缓存负责“最终存放成品”。三级用完转二级,二级用完转一级。

四、概念关系与区别总结

对比维度一级缓存 singletonObjects二级缓存 earlySingletonObjects三级缓存 singletonFactories
存储内容完整的 Bean 实例半成品 Bean 实例ObjectFactory 工厂对象
何时放入初始化完成后从三级缓存生产后实例化后、属性填充前
何时移除长期保留放入一级缓存时移除转为二级缓存时移除
能否直接使用✅ 可以⚠️ 依赖尚未注入完成❌ 只是工厂,需调用才生成

一句话记忆口诀:三级存工厂、二级存半成品、一级存成品,层层递进,逐步完善。

⚠️ 注意:三级缓存只能解决单例(Singleton)+ 字段注入 / Setter 注入场景下的循环依赖。构造器循环依赖、原型(Prototype)Bean 循环依赖均无法解决。-17

五、代码示例:三级缓存协作流程

以下是一个典型的循环依赖解决流程(以 A ↔ B 为例):

text
复制
下载
1. 开始创建 A:
   - 实例化 A → 将 A 的 ObjectFactory 放入三级缓存
   - 填充 A 的属性,发现需要 B
   
2. 转去创建 B:
   - 实例化 B → 将 B 的 ObjectFactory 放入三级缓存
   - 填充 B 的属性,发现需要 A
   - 调用 getSingleton("A"):
     · 一级缓存没有 A
     · A 正在创建中(满足条件)
     · 从三级缓存取出 A 的 ObjectFactory
     · 调用 getObject() 获得 A 的早期引用
     · 将 A 的早期引用放入二级缓存
     · 从三级缓存移除 A
     · 返回 A 的早期引用给 B
   - B 完成属性填充和初始化
   - 将完整的 B 放入一级缓存
   
3. 回到 A 的创建:
   - A 获取到 B(此时 B 已是完整 Bean)
   - A 完成属性填充和初始化
   - 将完整的 A 放入一级缓存
   - 从二级缓存移除 A

核心洞察:正是“提前暴露 A 的半成品引用”这一机制,打破了 A 和 B 之间的创建僵局。A 尚未初始化完成,但 B 已经能拿到它的引用地址——Java 引用传递保证了最终指向的是同一个对象。-22

六、底层原理 / 技术支撑

Spring 的三级缓存机制底层依赖以下核心技术:

  1. Java 引用传递:B 拿到的是 A 的引用地址,而非副本。即便 A 此时只是半成品,后续 A 完成初始化后,B 持有的引用自动指向完成态对象,无需额外更新。-22

  2. ObjectFactory 函数式接口ObjectFactory 是函数式接口,仅定义 getObject() 方法。Spring 将 Bean 的创建逻辑封装成 Lambda 表达式存入三级缓存,实现“按需创建、延迟执行”。

  3. AOP 代理的提前生成:如果 A 需要 AOP 代理(如事务管理、日志切面),Spring 必须在早期暴露时生成代理对象,而非等到初始化完成后才创建。三级缓存的 singletonFactories 通过存储 Lambda 表达式,允许在 getObject() 调用时提前创建代理对象,从而保证循环依赖场景下 AOP 仍能正常工作。-12

追问:为什么不用两级缓存就够了?
答:若不用三级缓存,则需要在实例化后就立即生成 AOP 代理对象,而不是延迟到需要时再创建。这会破坏 AOP 的正常创建时机(通常是在 BeanPostProcessor 后置处理阶段),且会增大内存开销——每个 Bean 无论是否被循环依赖依赖,都要提前生成代理。三级缓存通过“按需创建”解决了这一设计矛盾。-22

七、高频面试题与参考答案

Q1:什么是循环依赖?Spring 能解决所有循环依赖吗?

标准答案

循环依赖指两个或多个 Bean 相互持有对方引用,形成闭环依赖关系。Spring 只能解决单例模式 + 字段注入 / Setter 注入场景下的循环依赖,通过三级缓存提前暴露半成品 Bean。无法解决构造器循环依赖、原型 Bean 循环依赖以及多例模式下的循环依赖。-34-17

踩分点:定义 → 能解决的场景 → 不能解决的场景,缺一不可。

Q2:Spring 三级缓存分别存什么?为什么需要三级缓存?

标准答案

  • 一级缓存 singletonObjects:存放完全初始化完成的单例 Bean。

  • 二级缓存 earlySingletonObjects:存放提前暴露的半成品 Bean(已实例化,未初始化)。

  • 三级缓存 singletonFactories:存放 ObjectFactory 对象工厂,用于延迟创建 Bean 实例。

三级缓存的必要性:引入三级缓存的核心原因是处理 AOP 代理场景。若只用两级缓存,需要在实例化后就立即创建 AOP 代理对象,这会破坏 AOP 的创建时机,并导致不必要的性能开销。三级缓存通过存储工厂 Lambda 表达式,实现“按需创建代理对象”,是 Spring 设计哲学中“单一职责”和“延迟初始化”的体现。-12

Q3:构造器循环依赖为什么无法解决?

标准答案

构造器注入将“实例化”与“属性填充”合并为一步。当 A 和 B 通过构造器互相依赖时,A 的实例化需要 B 实例,B 的实例化又需要 A 实例,双方都无法先完成实例化,形成“死锁”。Spring 的三级缓存依赖于“实例化后提前暴露引用”这一前提,而构造器注入在实例化阶段就要求依赖对象已完整可用,因此无法应用该机制。-17

Q4:Spring Boot 2.6.0 之后为什么默认禁用循环依赖?

标准答案

Spring Boot 2.6.0 开始默认将 spring.main.allow-circular-references 设为 false,目的是引导开发者写出更健康的代码。循环依赖本质上是代码耦合过高的信号,长期依赖 Spring 的自动解决可能掩盖设计问题。若项目确实存在合理循环依赖,可通过配置 spring.main.allow-circular-references=true 临时开启,或使用 @Lazy 延迟注入解决。-30

Q5:@Lazy 注解是如何解决循环依赖的?

标准答案

@Lazy 注解告诉 Spring 不要立即创建被注解的 Bean,而是返回一个代理对象。当真正调用该 Bean 的方法时,代理对象才会触发实际 Bean 的创建和初始化。通过这种方式,循环依赖中的一方被延迟到真正使用时才初始化,从而避免了启动时的创建死锁。-13

八、结尾总结

回顾全文,核心知识点如下:

知识点要点
循环依赖定义Bean 之间相互引用形成闭环
Spring 解决范围单例 + 字段/Setter 注入 ✅;构造器/原型 ❌
三级缓存一级存成品、二级存半成品、三级存工厂
为什么需要三级解决 AOP 代理的提前创建问题
@Lazy 方案延迟初始化,返回代理对象
Spring Boot 2.6+默认禁用,需显式开启或重构

易错点提醒:不要误以为 Spring 能解决所有循环依赖;构造器循环依赖直接报错,不是配置就能解决的。最佳实践:优先重构代码打破循环依赖,而非依赖框架的自动处理。

下一篇预告:深度解析 Spring AOP 底层原理——JDK 动态代理 vs CGLIB,从源码到面试一网打尽。

标签:

相关阅读