Spring Boot循环依赖深度解析与解决方案(附详细代码)
作者:weixin_72753562
1. 什么是循环依赖
循环依赖是指两个或多个 Bean 之间相互依赖,形成闭环。
代码示例:
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
}
@Service
public class ServiceB {
@Autowired
private ServiceA serviceA;
}
当 Spring 容器创建 ServiceA 时,发现需要 ServiceB;转而去创建 ServiceB,又发现需要 ServiceA。这就形成了闭环。
如果不处理,理论上会陷入无限循环。 Spring 通过三级缓存机制解决了大部分单例 Bean 的循环依赖,但并非万能。
2. Spring 的底层救星:三级缓存机制
理解 Spring 如何解决循环依赖,是定位问题的关键。
2.1 核心缓存
DefaultSingletonBeanRegistry 类中定义了三个 Map:
| 缓存级别 | Map 名称 | 存放内容 |
|---|---|---|
| 一级缓存 | singletonObjects | 完全初始化好的 Bean |
| 二级缓存 | earlySingletonObjects | 提前暴露的 Bean 实例(未完成属性填充和初始化) |
| 三级缓存 | singletonFactories | 生成 Bean 的工厂(可生成代理对象) |
2.2 解决流程(以 ServiceA 与 ServiceB 为例)
- 创建 A:实例化 A(此时 A 未赋值,属性为空) → 将能获取 A(或 A 的代理)的工厂存入三级缓存。
- 填充 A:发现依赖 B → 去容器找 B。
- 创建 B:实例化 B → 将 B 的工厂存入三级缓存。
- 填充 B:发现依赖 A → 去容器找 A → 命中三级缓存 → 通过工厂获取 A 的早期引用 → 将 A 的早期引用升级存入二级缓存。
- 完成 B:B 拿到 A 的引用(此时 A 尚未初始化完毕),B 完成初始化 → 存入一级缓存。
- 回溯 A:A 拿到 B 的完整引用,完成初始化 → 存入一级缓存。
2.3 为何需要三级缓存?二级不行吗?
关键在于 AOP(面向切面编程)。
如果只有二级缓存,在步骤 2 中我们只能存入原始 A 对象。后续若 A 需要被动态代理,B 中注入的将是原始对象,而非代理对象,这会造成 AOP 失效。三级缓存的 ObjectFactory 能确保在暴露早期引用时,先判断是否需要创建代理对象,从而保证注入的是正确版本。
3. 循环依赖的“雷区”:什么情况下会失败
虽然三级缓存很强大,但以下几种情况它会失效,导致著名的 BeanCurrentlyInCreationException。
3.1 构造器注入(Constructor Injection)
失败原因: 构造器注入要求实例化时就必须传入依赖。Bean 无法先“半成品实例化”,也就没机会存入三级缓存。
错误示例:
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(ServiceB serviceB) { // 创建A必须先有B
this.serviceB = serviceB;
}
}
@Service
public class ServiceB {
private final ServiceA serviceA;
public ServiceB(ServiceA serviceA) { // 创建B必须先有A
this.serviceA = serviceA;
}
}
// 启动直接报错
3.2 原型(Prototype)作用域 Bean
失败原因: Spring 不缓存原型 Bean。每次请求都创建新实例,无法利用缓存来“破环”。
错误示例:
@Scope("prototype")
@Component
public class PrototypeA {
@Autowired
private PrototypeB prototypeB;
}
3.3 异步代理与@Lazy的复杂组合
当循环依赖链中存在 @Async 或 @Transactional 代理,且配置不当时,三级缓存的早期暴露可能因代理对象类型不一致而失败。
4. 解决方案详解(从优到劣)
方案一:重新设计架构(最推荐)
这是治本之策。循环依赖往往是职责划分不清晰的信号。
- 合并 Bean:如果两个类职责高度耦合,可以直接合并。
- 引入中介者:引入第三个
ServiceC,让 A 和 B 都只依赖 C,由 C 协调逻辑。 - 提取接口与实现:
A 依赖接口IB,B 依赖接口IA。实现类不直接互相依赖,通过容器注入接口。
方案二:使用 Setter/Field 注入(简单妥协)
Spring 官方推荐构造器注入,但 Setter 注入恰好可以配合三级缓存。
- 构造器注入:强依赖,无法破环。
- Setter / @Autowired 字段注入:将依赖注入延迟到实例化之后,能破环。
只需将关键依赖改为 @Autowired 字段注入或 setter 注入即可。
方案三:@Lazy懒加载(巧妙快捷)
在其中一个依赖点使用 @Lazy,使 Spring 先注入一个代理对象,直到第一次调用代理方法时才真正去容器获取目标 Bean。
示例:
@Component
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) { // 构造器注入 + @Lazy
this.serviceB = serviceB;
}
// ... 使用时 serviceB.doSomething() 才会触发真实调用
}
优点: 保持构造器注入的不变性,代码改动最小。
注意: 滥用 @Lazy 可能掩盖真正的设计问题,并增加初次调用开销。
方案四:通过编程式推迟解决
使用 ObjectProvider 或 ApplicationContext 手动获取 Bean。
@Component
public class ServiceA {
@Autowired
private ObjectProvider<ServiceB> serviceBProvider;
public void doSomething() {
ServiceB b = serviceBProvider.getIfAvailable();
}
}
方案五:@PostConstruct设置回调
在 A 中依赖 B,在 B 中使用 @PostConstruct 调用 A 的方法。这要求其中一个方向的依赖不是构造器级别的。
// 允许 A 注入 B
@Component
public class B {
@Autowired
private A a;
@PostConstruct
public void init() {
a.setB(this);
}
}
警告: 这种方式非常脆弱,破坏了不可变性,是最后的选择。
5. Spring Boot 调试与定位技巧
5.1 开启循环依赖报告
在 application.properties 中开启 Debug 日志:
logging.level.org.springframework.beans.factory.support.DefaultListableBeanFactory=DEBUG
启动时会在控制台看到依赖处理的详细轨迹。
5.2 使用 Actuator 分析
引入 spring-boot-starter-actuator,访问 /actuator/beans 端点,仔细查看 Bean 的 dependencies 信息。
5.3 IDE 静态检查
IntelliJ IDEA 的 Spring Bean Dependencies 图可直接可视化检测循环(Endpoints 分析)。
6. 最佳实践总结
- 强制使用构造器注入:即使它不支持循环依赖。这能强迫开发者规范代码设计。
- 从根源避免:一旦发现循环依赖,首先反思模块职责划分是否合理。
@Lazy作为工具箱:对于无法立刻重构的遗留系统,@Lazy是有效、低风险的止痛药。- 警惕混合代理:当使用
@Async等注解时,要特别留意循环依赖的影响。
通过本学习文件,建议在实际开发中先尝试理解和重现三级缓存机制,再针对性地选择解耦方案。
到此这篇关于Spring Boot循环依赖深度解析与解决方案的文章就介绍到这了,更多相关SpringBoot循环依赖内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
