← 返回文章

Spring 循环依赖

发布于 2026/10/20

1. 完整回答

Spring 循环依赖指的是两个或多个 Bean 相互依赖,形成闭环,比如 A 依赖 B,B 又依赖 A,导致容器在创建 Bean 时需要反复获取尚未创建完成的对象。

Spring 主要通过三级缓存和提前暴露对象引用来解决部分单例 Bean 的循环依赖,核心是利用 Bean 实例化和初始化之间的时间差。

以 A 和 B 通过字段注入相互依赖为例:

Spring 首先实例化 A,此时 A 已经有对象实例,但还没有完成属性注入和初始化。Spring 会把获取 A 早期引用的工厂放入三级缓存,然后开始注入 B。

创建 B 时发现需要 A,Spring 就可以通过三级缓存获取 A 的早期引用,注入 B,让 B 完成创建。随后再完成 A 的属性注入和初始化,最终把 A 放入一级缓存。

三级缓存分别保存完整的单例对象、提前暴露的对象引用,以及生成早期引用的工厂。第三级缓存的重要作用是支持按需生成早期引用,特别是在 AOP 场景下,可以提前生成代理对象,尽量保证依赖方拿到的对象与最终暴露的对象一致。

这种机制主要适用于允许循环引用的情况下,通过字段或 Setter 注入形成的单例循环依赖。双方都依赖构造器注入的循环依赖无法通过提前暴露实例解决,原型 Bean 的循环依赖也不受这套单例缓存机制支持。

另外,从 Spring Boot 2.6 开始,默认禁止循环引用。实际项目中更推荐通过职责拆分、调整依赖方向或者延迟获取依赖来消除循环,三级缓存主要是一种容器层面的兼容机制。

2. 核心原理与关键概念

2.1 三级缓存解决循环依赖的完整流程

以 A、B 两个单例 Bean 通过字段注入相互依赖为例,假设 Spring 允许循环引用:

  1. 实例化 A → 调用构造方法创建 A,此时属性还没有注入。

  2. 提前暴露 A → 将获取 A 早期引用的 ObjectFactory 放入三级缓存。

  3. 注入 B → A 需要 B,Spring 开始创建 B。

  4. B 获取 A → 发现 A 正在创建,一级、二级缓存中都没有 A,于是调用三级缓存中的工厂获取早期引用。

  5. 升级缓存 → 将 A 的早期引用放入二级缓存,移除对应的三级缓存工厂。如果 A 需要 AOP 代理,此时可以提前生成代理对象。

  6. 完成 B → 将 A 的早期引用注入 B,B 完成初始化,进入一级缓存。

  7. 完成 A → 将 B 注入 A,完成 A 的初始化,必要时采用之前暴露的早期引用作为最终对象,放入一级缓存并清理早期缓存。

整体流程:

实例化 A → 三级缓存暴露工厂 → 创建 B → 获取 A 的早期引用 → 初始化 B → 初始化 A → 一级缓存保存最终对象

关键在于:A 完成实例化后就能提供对象引用,不需要等待整个初始化流程结束。

2.2 三级缓存分别存储什么?

缓存 对应字段 存储内容 作用
一级缓存 singletonObjects 创建完成的单例对象 正常获取单例 Bean
二级缓存 earlySingletonObjects 已生成的早期对象引用 复用尚未完成初始化的对象或代理
三级缓存 singletonFactories ObjectFactory 对象工厂 按需生成早期引用,支持提前代理

查找顺序:

一级缓存 → 二级缓存 → 三级缓存

需要注意,只有 Bean 正在创建过程中,Spring 才会在允许提前引用的情况下尝试使用后两级缓存。

2.3 提前暴露发生在什么阶段?

普通 Bean 的核心创建流程:

实例化 → 属性注入 → 初始化 → 创建完成

允许循环引用的单例 Bean,在实例化之后、属性注入之前,可以注册早期引用工厂。

这里要区分两个状态:

  • 实例化完成:对象已经存在,但依赖和初始化逻辑可能尚未完成。

  • 初始化完成:依赖已经注入,相关初始化回调和后置处理已经执行。

循环依赖机制利用的就是这两个阶段之间的时间差。

3. 高频面试追问

3.1 Spring 能解决哪些循环依赖?

Spring 在允许循环引用时,能够自动处理部分单例 Bean 通过字段注入或 Setter 注入形成的循环依赖。

因为这些注入方式可以先创建对象,再填充依赖,Spring 就有机会提前暴露对象引用。

主要限制有三个:

  • 构造器相互依赖:双方在实例化之前就需要对方,无法通过三级缓存解决。

  • 原型 Bean 相互依赖:原型对象不使用这套单例缓存机制。

  • 代理或特殊初始化场景:即使是单例字段注入,也可能因为代理生成时机等原因失败。

另外,Spring Boot 2.6 及之后的版本默认禁止循环引用,需要先满足允许循环引用的配置条件。

3.2 为什么需要三级缓存?二级缓存不能解决吗?

如果只考虑普通单例对象的循环依赖,二级缓存就可以解决;引入三级缓存,主要是为了支持按需生成早期引用,兼顾 AOP 代理。

普通对象实例化后,可以直接放进二级缓存,其他 Bean 需要它时直接获取即可。

但存在 AOP 时,对外使用的 Bean 可能是代理对象。如果过早缓存原始对象,就可能导致其他 Bean 持有原始对象,而容器最终暴露的是代理对象,造成引用不一致。

三级缓存存储的是对象工厂,在真正发生循环依赖时,通过 getEarlyBeanReference() 获取原始对象或提前创建的代理对象。

这样可以做到两个事情:

第一,没有发生循环依赖时,不需要专门为了提前暴露而创建代理。

第二,发生循环依赖时,可以按需生成早期引用,并将它缓存到二级缓存,避免重复生成。

因此,三级缓存的核心价值是延迟创建早期引用,以及协调代理对象的一致性。

3.3 Spring 如何解决 AOP 代理对象的循环依赖?

Spring 可以通过提前生成 AOP 代理,让依赖方获取代理对象,并在 Bean 创建完成时尽量保持引用一致。

正常情况下,AOP 代理通常通过 Bean 后置处理器在初始化阶段生成。

但循环依赖时,其他 Bean 需要提前获取当前 Bean,这时就可以通过三级缓存中的对象工厂,调用 getEarlyBeanReference()。

常见的 Spring AOP 自动代理创建器能够在这个过程中判断是否需要代理,并提前返回代理对象。

后续初始化阶段还会避免对已经提前代理的 Bean 重复包装,最终由 Spring 协调早期引用与最终暴露对象。

不过,三级缓存不能保证所有代理场景都成功。如果某个后置处理器只在初始化之后创建新的代理,早期注入的对象就可能与最终对象不一致,Spring 可能直接抛出异常。

继续追问:使用 @Async 时也一定能解决吗?

不能保证。@Async 涉及异步代理的创建,某些配置下可能在初始化之后才包装对象。如果其他 Bean 已经拿到了早期原始引用,就可能触发对象不一致的问题。遇到这类情况,通常应该调整依赖关系或延迟获取依赖。

3.4 为什么构造器循环依赖无法通过三级缓存解决?

因为构造器注入要求先拿到依赖对象,才能完成当前 Bean 的实例化,而三级缓存需要对象实例化后才能提前暴露引用。

例如:

A 的构造器需要 B,B 的构造器又需要 A。

Spring 创建 A 时,需要先创建 B;创建 B 时又需要先创建 A。

这时 A 的实例化还没有完成,没有可供提前暴露的 A 实例,因此无法通过三级缓存打破循环。

核心原因是实例化时机受限。

这也是 Spring 更推荐构造器注入的原因之一:必需依赖可以在创建阶段得到明确验证,循环设计问题更容易暴露。

3.5 为什么原型 Bean 的循环依赖无法通过三级缓存解决?

因为三级缓存属于 Spring 的单例对象管理机制,原型 Bean 每次请求都需要创建新实例,不会通过单例缓存提前暴露。

例如 A 和 B 都是原型 Bean,A 依赖 B,B 又依赖 A。

创建 A 时需要创建 B,创建 B 时又请求 A,Spring 会检测到 A 已经处于创建状态,最终抛出 BeanCurrentlyInCreationException。

Spring 对原型 Bean 有创建状态检查,但不会像单例 Bean 一样缓存并复用早期实例。

因此,原型 Bean 的循环依赖一般需要调整对象之间的依赖关系,或者改成延迟获取。

3.6 循环依赖已经解决,为什么还可能出现空指针异常?

因为提前暴露的对象还没有完成属性注入和初始化。

例如 A 依赖 B,B 又依赖 A。

Spring 创建 B 时,可能已经将 A 的早期引用注入 B,但此时 A 的其他字段还没有注入完成。

如果 B 在 @PostConstruct 方法中立即调用 A,而 A 的业务方法又访问了尚未注入的字段,就可能出现空指针异常。

因此,能够完成循环依赖注入,不代表依赖对象已经处于完整可用的状态。

实际开发中应该尽量避免在初始化阶段执行相互依赖的业务逻辑。

3.7 @Lazy 为什么能够解决部分循环依赖?

因为在依赖注入点使用 @Lazy,可以通过延迟解析代理,把真正获取依赖对象的时机推迟到方法调用时。

例如 A 构造器需要 B,B 构造器需要 A。

如果 A 对 B 的注入点使用 @Lazy,Spring 可以先为 B 提供一个延迟解析代理,让 A 的构造过程完成。

等后续真正使用 B 时,再通过代理获取实际对象。

这相当于推迟了其中一条依赖的解析时间,从而打破初始化过程中的闭环。

但要注意:

  • @Lazy 放在 Bean 定义上,主要控制 Bean 何时创建。

  • @Lazy 放在 注入点上,可以推迟实际依赖的解析。

  • 如果在对象还没准备好时就调用延迟代理,仍可能出现创建失败。

继续追问:@DependsOn 能解决循环依赖吗?

通常不能。@DependsOn 用来声明 Bean 的初始化先后关系,只能调整初始化顺序,无法消除相互依赖。双方通过 @DependsOn 形成闭环,还会触发循环依赖检查。

3.8 Spring Boot 为什么默认禁止循环依赖?如何开启?

Spring Boot 从 2.6 开始默认禁止循环引用,主要是希望更早暴露 Bean 之间不合理的依赖关系。

循环依赖通常意味着模块之间耦合较强,容易带来初始化顺序问题,也会增加 AOP 代理、测试和后续维护的复杂度。

如果需要临时兼容旧项目,可以通过以下配置允许 Spring 尝试解决:

spring:
  main:
    allow-circular-references: true

需要注意,这个配置只能恢复容器自动尝试处理循环引用的行为,不能保证所有循环依赖都能解决。

另外,Spring Framework 本身和 Spring Boot 的默认配置需要区分。Spring Framework 的常规 BeanFactory 实现可以默认允许循环引用,但 Spring Boot 对应用启动行为做了更严格的限制。

3.9 项目中遇到循环依赖,应该怎么排查和解决?

优先调整依赖设计,只有短期无法改造时才考虑延迟注入或兼容配置。

实际排查可以按照以下流程进行:

查看启动异常 → 确认依赖闭环 → 检查注入方式与 Bean 作用域 → 检查代理和初始化逻辑 → 调整依赖关系

例如订单服务依赖支付服务,支付服务又依赖订单服务。

如果两个服务都承担了对方的部分职责,可以考虑抽取独立的订单状态服务,让订单服务和支付服务分别依赖它。

如果只是某个方法偶尔需要调用另一个服务,可以考虑使用 ObjectProvider 延迟获取,避免创建阶段就解析依赖。

如果循环依赖涉及 @Transactional、@Async 等代理机制,还需要检查是否存在原始对象与代理对象不一致的问题。

总体上,优先考虑拆分职责、调整依赖方向、提取公共服务。@Lazy、ObjectProvider 等适合确实需要延迟访问的场景,开启循环引用更适合作为旧项目的过渡措施。

4. 易混淆概念对比

4.1 构造器注入与字段、Setter 注入

对比项 构造器注入 字段 / Setter 注入
注入时机 实例化过程中 实例化之后
是否能提前暴露对象 依赖尚未满足时无法实例化 实例化后可以提前暴露
相互循环依赖 通常无法自动解决 允许循环引用时可能解决
主要特点 必需依赖明确,支持不可变设计 可以先创建对象再注入依赖

4.2 一级、二级、三级缓存

对比项 一级缓存 二级缓存 三级缓存
对象状态 创建完成 可能尚未初始化 尚未生成早期引用
存储内容 最终单例对象 早期对象或代理 对象工厂
是否直接返回对象 是 是 需要调用工厂
核心作用 单例复用 复用早期引用 按需获取早期引用

4.3 原始对象与 AOP 代理对象

对比项 原始对象 AOP 代理对象
含义 实际业务类实例 包装目标对象的代理
方法调用 直接执行目标方法 可以先执行增强逻辑
事务、切面 直接调用通常不经过代理增强 符合条件时可以触发增强
循环依赖风险 可能绕过代理增强 需要保证早期与最终引用一致

4.4 @Lazy 与 ObjectProvider

对比项 @Lazy 注入点 ObjectProvider
实现方式 注入延迟解析代理 注入依赖提供器
获取真实依赖 通常在代理方法调用时 显式调用 getObject() 等方法
使用特点 对业务代码侵入较小 获取时机更明确
适用场景 延迟访问普通依赖 按需获取、可选依赖、动态获取

4.5 Spring Framework 与 Spring Boot 的循环依赖默认行为

对比项 Spring Framework 常规 BeanFactory Spring Boot 2.6+
默认是否允许循环引用 允许 禁止
能否处理字段注入的单例循环依赖 符合条件时可以 默认不尝试
能否解决双方构造器循环依赖 无法通过三级缓存解决 同样无法
推荐做法 优先消除循环 优先消除循环

5. 总结

Spring 循环依赖就是 Bean 之间相互引用,导致创建过程中需要获取尚未创建完成的对象。Spring 在允许循环引用的情况下,主要通过三级缓存和提前暴露早期引用来解决部分单例 Bean 的循环依赖。核心流程是先实例化对象,再注册早期引用工厂,在其他 Bean 需要时提前获取对象或代理,最后完成属性注入和初始化。三级缓存的关键价值在于按需生成早期引用,并尽量保证 AOP 代理的一致性。这种机制主要适用于单例 Bean 的字段或 Setter 注入,双方构造器循环依赖和原型 Bean 循环依赖无法通过该机制解决。Spring Boot 2.6 之后默认禁止循环引用,实际开发中优先通过拆分职责和调整依赖关系来解决。