在 Spring 应用开发中,循环依赖是几乎每一位开发者都会遇到的实际问题。它既可能悄然存在于代码深处,也可能随着业务迭代突然暴露为启动报错。理解其本质以及 Spring 容器的处理策略,是写出健壮、可维护代码的关键一步。
28.2.1 什么是循环依赖
简单来说,循环依赖就是两个或多个 Bean 互相依赖,形成一个闭环。例如:
A依赖B,B也依赖A(直接循环)A依赖B,B依赖C,C又依赖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 为例)
- 容器开始创建
A,通过反射实例化出一个原始对象(此时属性全为 null),并把它包装成一个ObjectFactory放入三级缓存,然后进入属性填充阶段。 - 填充
A的属性时发现需要B,于是去获取B的 Bean。 - 容器创建
B,同样先实例化原始对象,放入三级缓存,然后填充B的属性。 - 填充
B时发现需要A,于是从缓存中查找A:先查一级(无),再查二级(此时还没有),最后在三级找到之前放入的ObjectFactory。通过该工厂获得A的早期引用,并放入二级缓存,移除三级缓存中的工厂。 B的属性填充完成(持有的是A的早期引用),后续执行初始化(@PostConstruct等),最终B成为完全体,进入一级缓存。- 回到
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 的创建顺序,有时会被用来“解决”循环依赖,但实际上它只是一种顺序约束,并不能打破循环。如果 A 和 B 本身存在循环引用,加上 @DependsOn 反而容易让启动顺序更加混乱。
4. 使用设计层面消除循环
最终理想的状态是:应用设计为分层依赖的无环图。常见的分层结构(Controller → Service → Repository)天然就是单向的。如果出现 ServiceA 与 ServiceB 互相调用,可以思考是否需要拆分出一个新的 ServiceC 来承载被两者共享的逻辑,或者通过事件驱动(Spring Event)解耦。
28.2.6 小结
循环依赖是 Spring 依赖注入中的经典难题,其处理方式直观反映了容器的设计深度。核心要点可以概括为:
- 构造器注入循环依赖:无解,必须重构或使用
@Lazy。 - Setter/字段注入循环依赖:Spring 通过三级缓存(提前暴露原始对象)可以解决,但本质上是不得已的妥协方案。
- 设计优先:尽量从架构层面避免循环依赖,保持代码简洁清晰。
在实际项目中,了解 Spring 的循环依赖处理机制,不仅有助于快速定位和修复启动异常,更能帮助我们在编写代码时主动避免不合理的依赖关系,从而提升系统的可维护性。