人人都会AI编程

28.2 循环依赖问题与处理方案

更新时间:2026-07-10

在 Spring 应用开发中,循环依赖是几乎每一位开发者都会遇到的实际问题。它既可能悄然存在于代码深处,也可能随着业务迭代突然暴露为启动报错。理解其本质以及 Spring 容器的处理策略,是写出健壮、可维护代码的关键一步。

28.2.1 什么是循环依赖

简单来说,循环依赖就是两个或多个 Bean 互相依赖,形成一个闭环。例如:

  • A 依赖 BB 也依赖 A(直接循环)
  • A 依赖 BB 依赖 CC 又依赖 A(间接循环)

在代码中表现为:

@Component
public class A {
    private final B b;
    public A(B b) { this.b = b; }
}

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

这在传统的 new 操作中显然无法完成:创建 A 需要先有 B,创建 B 又需要先有 A,形成“鸡生蛋、蛋生鸡”的僵局。Spring 容器如果对此毫无处理手段,启动时就会抛出 BeanCurrentlyInCreationException

28.2.2 构造器注入的循环依赖:无解

首先要明确:Spring 无法解决构造器注入导致的循环依赖。原因在于根本物理限制——要调用 A 的构造器完成实例化,必须传入一个 B 的实例,而 B 的构造器又要求一个 A 的实例。在 Java 语言层面,对象的实例创建和依赖注入被绑定在同一步,无法“先创建出一个未完成的对象,稍后再填充依赖”。

因此,上面的构造器注入示例必然导致启动报错:

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: 
Error creating bean with name 'a': Requested bean is currently in creation: 
Is there an unresolvable circular reference?

遇到这种情况,Spring 会直接将问题暴露出来,阻止应用启动。这也是一种保护机制:与其带着隐患运行,不如在启动时明确告诉你“设计有问题”。

28.2.3 Spring 如何处理 Setter/字段注入的循环依赖:三级缓存

相比构造器注入,Setter 注入或字段注入的循环依赖则可以在 Spring 容器中得到解决。核心机制是 Spring 内部维护的三级缓存,它让容器能够提前暴露一个尚未完全初始化完成的 Bean 引用。

1. 三级缓存的结构

DefaultSingletonBeanRegistry 中,Spring 定义了三个 Map:

  • 一级缓存 singletonObjects:存放完全初始化好的单例 Bean,getBean 时直接从这里获取。
  • 二级缓存 earlySingletonObjects:存放早期暴露的单例对象,即已实例化但尚未完成属性填充和初始化的 Bean。
  • 三级缓存 singletonFactories:存放可以生成早期 Bean 引用的 ObjectFactory,允许在 Bean 创建早期对其应用 AOP 等后置处理。

2. 解决流程(以 A ↔ B 为例)

  1. 容器开始创建 A,通过反射实例化出一个原始对象(此时属性全为 null),并把它包装成一个 ObjectFactory 放入三级缓存,然后进入属性填充阶段。
  2. 填充 A 的属性时发现需要 B,于是去获取 B 的 Bean。
  3. 容器创建 B,同样先实例化原始对象,放入三级缓存,然后填充 B 的属性。
  4. 填充 B 时发现需要 A,于是从缓存中查找 A:先查一级(无),再查二级(此时还没有),最后在三级找到之前放入的 ObjectFactory。通过该工厂获得 A 的早期引用,并放入二级缓存,移除三级缓存中的工厂。
  5. B 的属性填充完成(持有的是 A 的早期引用),后续执行初始化(@PostConstruct 等),最终 B 成为完全体,进入一级缓存。
  6. 回到 A 的属性填充阶段,此时 B 已经可以从一级缓存中获取,A 顺利拿到的已是完整的 B 实例。A 继续完成初始化,最终也进入一级缓存。

整个过程的关键在于:实例化和初始化被拆分开来。实例化只负责创建对象并分配内存,初始化则是填充属性、执行回调等后续动作。通过将刚实例化的原始对象提前暴光,就打破了循环的僵局。

28.2.4 三级缓存的设计目的

这里有一个常被问到的问题:为什么需要三级缓存,两级行不行?

如果 Spring 没有 AOP 或不需要代理对象,二级缓存(直接存早期原始对象)或许足够。但在 Spring 中,许多 Bean 最终是以代理的形式存在的(例如声明了 @Transactional 或自定义 AOP 切面)。当事务等增强逻辑需要为 A 生成代理时,三级缓存中的 ObjectFactory 可以延迟调用 getEarlyBeanReference,在获取早期引用时才决定是否需要创建代理对象。这样,B 注入的 A 就和最终暴露出去的代理 A 是同一个对象,确保了单例的唯一性。

如果去掉三级缓存,只能在实例化后就立即生成代理并暴露,这会破坏某些后置处理器的执行顺序,并且对不需要代理的大量 Bean 产生不必要的开销。三级缓存是一种用空间换时间、兼顾扩展性的巧妙设计。

28.2.5 实操建议与常见问题

1. 避免循环依赖是上策

尽管 Spring 可以处理 Setter/字段注入的循环依赖,但这仍然是一种“技术上的可行”而非“设计上的推荐”。循环依赖会让代码结构变得僵硬、难以理解,并且构造器注入的循环依赖无法解决,限制了开发的最佳实践(许多团队强制要求构造器注入以保障不可变性)。

因此,遇到循环依赖时,首先应尝试通过重构消除它:

  • 引入中间协调类(例如事件发布者/监听者模式)
  • 提取公共依赖到第三个 Bean
  • 重新考虑职责划分,将部分逻辑合并或剥离

2. 使用 @Lazy 注解延迟加载

对构造器注入的情形,可以用 @Lazy 打破循环:

@Component
public class A {
    private final B b;
    public A(@Lazy B b) { this.b = b; }
}

这样注入的实际上是 B 的一个代理对象,真正的 B 在首次使用时才被创建,解决了构造时的即时循环问题。这是一种轻量级的补救方案,但滥用 @Lazy 可能导致依赖关系不清晰,且对性能有微小影响(每次调用都经过代理)。

3. 谨慎对待 @DependsOn

@DependsOn 强制指定 Bean 的创建顺序,有时会被用来“解决”循环依赖,但实际上它只是一种顺序约束,并不能打破循环。如果 AB 本身存在循环引用,加上 @DependsOn 反而容易让启动顺序更加混乱。

4. 使用设计层面消除循环

最终理想的状态是:应用设计为分层依赖的无环图。常见的分层结构(Controller → Service → Repository)天然就是单向的。如果出现 ServiceAServiceB 互相调用,可以思考是否需要拆分出一个新的 ServiceC 来承载被两者共享的逻辑,或者通过事件驱动(Spring Event)解耦。

28.2.6 小结

循环依赖是 Spring 依赖注入中的经典难题,其处理方式直观反映了容器的设计深度。核心要点可以概括为:

  • 构造器注入循环依赖:无解,必须重构或使用 @Lazy
  • Setter/字段注入循环依赖:Spring 通过三级缓存(提前暴露原始对象)可以解决,但本质上是不得已的妥协方案。
  • 设计优先:尽量从架构层面避免循环依赖,保持代码简洁清晰。

在实际项目中,了解 Spring 的循环依赖处理机制,不仅有助于快速定位和修复启动异常,更能帮助我们在编写代码时主动避免不合理的依赖关系,从而提升系统的可维护性。