在Spring应用开发中,循环依赖(Circular Dependency) 是一个既常见又令人头疼的问题。特别是在大型项目迭代或多人协作开发时,经常会出现启动报错 BeanCurrentlyInCreationException。
很多开发者知道Spring能解决循环依赖,但往往不清楚为什么能解决、什么情况下不能解决以及如何优雅地处理。本文将深入剖析Spring循环依赖的底层原理,特别是著名的“三级缓存”机制,并给出实战中的解决方案。
一、 什么是循环依赖?
简单来说,就是两个或多个Bean之间相互持有对方的引用,形成闭环。
常见场景
- 两者循环:A依赖B,B依赖A。
- 三者循环:A依赖B,B依赖C,C依赖A。
- 自我依赖:A依赖A(较少见,通常由配置错误导致)。
@Component
public class ServiceA {
@Autowired
private ServiceB serviceB; // A依赖B
}
@Component
public class ServiceB {
@Autowired
private ServiceA serviceA; // B依赖A
}
二、 Spring能解决所有循环依赖吗?
答案是:不能。
Spring解决循环依赖是有严格前提条件的:
- 必须是单例(Singleton):原型(Prototype)作用域的Bean无法解决,因为每次获取都会创建新实例,导致无限递归 。
- 必须是非构造器注入:如果是纯构造器注入,Spring无法解决,会直接抛出异常 。
为什么构造器注入无法解决?
构造器注入要求在Bean实例化阶段就必须提供依赖对象。此时Bean尚未创建完成,无法提前暴露引用,导致“先有鸡还是先有蛋”的死锁问题 。
三、 核心揭秘:三级缓存机制
Spring之所以能解决单例Bean的Setter/字段注入循环依赖,核心在于其内部的三级缓存机制。这三级缓存定义在 DefaultSingletonBeanRegistry 类中 。
1. 三级缓存的结构
| 缓存级别 | 变量名 | 类型 | 作用 |
|---|---|---|---|
| 一级缓存 | singletonObjects | Map<String, Object> | 存放完全初始化好的成品Bean 。 |
| 二级缓存 | earlySingletonObjects | Map<String, Object> | 存放早期曝光的Bean实例(半成品),用于解决循环依赖时的引用共享 。 |
| 三级缓存 | singletonFactories | Map<String, ObjectFactory<?>> | 存放Bean工厂对象,用于在需要时动态生成早期引用(特别是代理对象) 。 |
2. 解决流程详解
假设 A 依赖 B,B 依赖 A:
- 创建 Bean A:
- Spring 实例化 A(调用构造器),此时 A 是一个原始对象。
- 将 A 的
ObjectFactory放入三级缓存 。 - 开始填充 A 的属性,发现依赖 B。
- 创建 Bean B:
- Spring 尝试获取 B,发现不存在,于是实例化 B。
- 将 B 的
ObjectFactory放入三级缓存。 - 开始填充 B 的属性,发现依赖 A。
- 获取早期 A:
- B 请求获取 A。Spring 依次查找:
- 一级缓存:无。
- 二级缓存:无。
- 三级缓存:找到 A 的工厂。
- 调用工厂的
getObject()方法。如果 A 需要 AOP 代理,此处会生成代理对象;否则返回原始对象 。 - 将生成的早期引用放入二级缓存,并从三级缓存移除 。
- B 获得 A 的引用,完成初始化,放入一级缓存。
- B 请求获取 A。Spring 依次查找:
- 完成 Bean A:
- A 从一级缓存获取已完成的 B。
- A 完成剩余初始化,放入一级缓存。
3. 为什么需要三级缓存?只用二级不行吗?
这是面试中的高频考点。如果 Bean 没有 AOP 代理,二级缓存确实足够。但 Spring 引入三级缓存的核心目的是为了处理 AOP 代理。
- 原则:Spring AOP 代理通常在 Bean 初始化完成后(
postProcessAfterInitialization)生成。 - 冲突:循环依赖需要在初始化前就获取引用。
- 解决:三级缓存中的
ObjectFactory允许 Spring 在真正需要早期引用时,才去判断是否要创建代理。如果直接放在二级缓存,就无法区分是返回原始对象还是代理对象,可能导致最终注入的 Bean 版本不一致(即报错中提到的 "injected into other beans in its raw version... but has eventually been wrapped") 。
四、 Spring 无法解决的场景及对策
尽管三级缓存很强大,但以下场景仍会报错:
- 构造器循环依赖:
- 现象:启动直接报错。
- 解决:使用
@Lazy注解 。
- 多例(Prototype)循环依赖:
- 现象:每次请求都创建新对象,陷入死循环。
- 解决:重构代码,避免多例间的循环依赖,或改为单例 。
- @Async/@Transaction 导致的代理问题:
- 现象:某些特定后置处理器(如
AsyncAnnotationBeanPostProcessor)未正确实现getEarlyBeanReference,导致早期引用不是代理对象,而最终Bean是代理对象,引发版本不一致异常 。 - 解决:使用
@Lazy或重构依赖关系。
- 现象:某些特定后置处理器(如
五、 实战建议:如何优雅地处理循环依赖?
虽然 Spring 能解决部分循环依赖,但循环依赖本身往往是设计不良的信号。以下是推荐的处理方案:
1. 首选:重构代码(最佳实践)
循环依赖通常意味着两个类耦合度过高。
- 提取第三方服务:将 A 和 B 共同依赖的逻辑抽取到新的 Service C 中,让 A 和 B 都依赖 C 。
- 接口解耦:通过接口隔离变化,减少直接依赖。
2. 次选:使用 @Lazy 注解
当无法重构时,@Lazy 是最简单的救命稻草。
- 用法:在构造器参数或字段上添加
@Lazy。 - 原理:注入的是一个代理对象,只有在首次调用方法时才会真正去容器中获取目标 Bean,从而打破初始化时的死锁 。
@Service
public class ServiceA {
private final ServiceB serviceB;
// 加上 @Lazy,Spring 注入 ServiceB 的代理,避免启动时死锁
public ServiceA(@Lazy ServiceB serviceB) {
this.serviceB = serviceB;
}
}
3. 其他技巧
- Setter 注入替代构造器注入:对于非强依赖,可以使用 Setter 注入,利用 Spring 的三级缓存自动解决 。
- **使用 **
ObjectProvider:Spring 4.3+ 提供的延迟查找机制,效果类似@Lazy,但更语义化 。
六、 总结
- Spring 通过三级缓存巧妙解决了单例、Setter/字段注入的循环依赖问题。
- 三级缓存的核心价值在于延迟生成代理对象,确保循环依赖中引用的 Bean 与最终容器中的 Bean 一致。
- 构造器注入和多例 Bean的循环依赖无法自动解决,需借助
@Lazy或代码重构。 - 治本之策是优化系统设计,降低耦合,避免循环依赖的产生。
理解这一机制,不仅能帮你排查启动报错,更能让你深入理解 Spring IoC 容器的设计哲学。
评论区