Spring循环依赖:三级缓存原理、解决方案与工程实践
最近在开发一个基于 Spring Boot 的微服务项目时,遇到了一个非常典型且令人头疼的问题:服务启动后,部分 Bean 的依赖注入总是失败,控制台反复抛出 NoSuchBeanDefinitionException 或 BeanCreationException。排查过程就像在玩“大家来找茬”,配置文件、注解、包扫描路径查了个遍,最后发现根源竟是一个不起眼的循环依赖问题。这种“你中有我,我中有你”的 Bean 关系,像极了生物学里的“噬菌体”与“宿主菌”——一个必须依赖另一个才能存活和复制,处理不当就会导致整个“生态系统”(应用上下文)崩溃。
本文将深入剖析 Spring 框架中循环依赖这一经典难题。无论你是刚接触 Spring 的新手,还是有一定经验但在复杂项目中踩过坑的开发者,都能通过本文彻底理解循环依赖的产生原理、Spring 的默认解决方案(三级缓存)及其局限性,并掌握一套从编码规范、设计模式到高级特性的完整规避与解决策略。我们将通过可运行的代码示例,一步步复现问题、分析原理并给出最佳实践,让你在未来的项目中能从容应对此类依赖纠缠。
1. 循环依赖:概念、场景与影响
在开始技术拆解之前,我们首先要明确什么是循环依赖,以及它为什么在 Spring 管理的应用中成为一个问题。
1.1 什么是循环依赖?
循环依赖,顾名思义,是指两个或多个 Spring Bean 之间相互持有对方的引用,形成一个闭环。最常见的场景是双向依赖:
- Bean A 的创建需要注入 Bean B。
- Bean B 的创建又需要注入 Bean A。
这就构成了一个简单的循环:A → B → A。
在更复杂的业务系统中,可能会出现更长的依赖链,例如:A → B → C → A,形成一个循环圈。
1.2 为什么这会成为问题?
Spring IoC 容器负责 Bean 的生命周期管理,其核心流程包括:实例化、属性填充、初始化。在遇到循环依赖时,这个流程会陷入一个“先有鸡还是先有蛋”的困境:
- 容器开始创建 Bean A,执行实例化(调用构造函数),得到一个“早期对象”。
- 接下来要为 Bean A 进行属性填充(依赖注入),发现它依赖 Bean B。
- 容器转而开始创建 Bean B,执行实例化,得到 Bean B 的“早期对象”。
- 为 Bean B 进行属性填充时,发现它依赖 Bean A。
- 此时 Bean A 尚未完成属性填充和初始化,不是一个“完全体”的 Bean。如果容器无法提供这个处于创建中状态的 Bean A 的引用,那么 Bean B 的创建就会失败,进而导致 Bean A 的创建也失败。
1.3 常见产生场景
循环依赖往往隐藏在不当的设计中:
- 架构设计缺陷:服务层
UserService和OrderService相互调用对方的方法,并相互注入。 - 数据模型关联:实体类
Author和Book在业务层被设计成相互依赖的 Bean。 - 配置类相互引用:两个
@Configuration类通过@Bean方法相互依赖。 - 过度使用
@Autowired:在字段、构造器、Setter 方法上不加思考地使用自动装配,容易无意中引入循环依赖。
理解问题本质后,我们来看 Spring 是如何尝试解决这个难题的。
2. Spring 的默认解决方案:三级缓存机制
Spring 框架通过其著名的“三级缓存”机制,默认支持解决单例 Bean且通过属性注入(Setter 注入)或字段注入方式的循环依赖。这是 Spring 容器的一项高级特性,理解其原理对深入掌握 Spring 至关重要。
2.1 三级缓存定义
在 DefaultSingletonBeanRegistry 类中,定义了三个关键的 Map,称为三级缓存:
- 一级缓存
singletonObjects:ConcurrentHashMap<String, Object>。缓存已经完成全部生命周期(实例化、属性填充、初始化)的“成品”单例 Bean。这是我们通常从容器中获取到的 Bean。 - 二级缓存
earlySingletonObjects:HashMap<String, Object>。缓存早期的单例对象,这些对象已经实例化,但尚未完成属性填充和初始化。它用于解决循环依赖。 - 三级缓存
singletonFactories:HashMap<String, ObjectFactory<?>>。缓存单例工厂对象。当 Bean 完成实例化后,会将其包装成一个ObjectFactory放入此缓存。当发生循环依赖时,会调用这个工厂的getObject()方法来获取 Bean 的早期引用(可能会经过 BeanPostProcessor 的处理,如 AOP 代理)。
2.2 解决循环依赖的流程
我们以 BeanA 和 BeanB 相互依赖为例,且都采用 @Autowired 字段注入:
-
开始创建
BeanA:- 实例化
BeanA(调用构造函数),得到一个原始对象aInstance。 - 将
aInstance包装成一个ObjectFactory,并放入 三级缓存 (singletonFactories)。 - 开始属性填充,发现需要注入
BeanB。
- 实例化
-
转而创建
BeanB:- 实例化
BeanB,得到bInstance。 - 将
bInstance包装成ObjectFactory,放入三级缓存。 - 开始属性填充,发现需要注入
BeanA。
- 实例化
-
解决
BeanB对BeanA的依赖:- 容器首先从一级缓存找
BeanA,没有。 - 从二级缓存找,也没有。
- 从三级缓存找到
BeanA的ObjectFactory,调用getObject()。这个方法可能会直接返回aInstance,也可能通过AbstractAutoProxyCreator等后处理器返回一个 AOP 代理对象(这就是为什么 Spring AOP 默认使用 CGLIB 时也能支持循环依赖)。将这个早期引用放入 二级缓存,并从三级缓存移除该工厂。 - 将获取到的
BeanA的早期引用注入到BeanB中。 BeanB完成属性填充和初始化,成为一个“成品” Bean,被放入 一级缓存,并从二、三级缓存中清理掉BeanB的相关条目。
- 容器首先从一级缓存找
-
继续完成
BeanA的创建:- 回到
BeanA的属性填充步骤,现在需要注入BeanB。 - 此时直接从 一级缓存 中就能获取到“成品”
BeanB,将其注入。 BeanA完成属性填充和初始化,成为“成品” Bean,放入 一级缓存,并清理其二、三级缓存条目。
- 回到
至此,循环依赖被成功解决,两个 Bean 都完成了创建。
2.3 方案的局限性
Spring 的三级缓存机制并非万能,它有以下明确限制:
- 仅支持单例作用域(Singleton):原型(Prototype)作用域的 Bean 每次都会新建,无法通过缓存解决循环依赖。
- 依赖注入方式受限:
- 支持:字段注入(
@Autowired)、Setter 方法注入。 - 不支持:构造器注入(Constructor Injection)。因为 Bean 实例化的第一步就是调用构造器,如果构造器参数需要另一个 Bean,而另一个 Bean 也需要当前 Bean 作为构造参数,那么双方连第一步(实例化)都无法完成,三级缓存根本无从谈起。Spring 在这种情况下会直接抛出
BeanCurrentlyInCreationException。
- 支持:字段注入(
- 存在“治标不治本”的风险:三级缓存掩盖了设计上的问题。即使依赖注入成功,Bean 在初始化方法(如
@PostConstruct)中调用对方的方法,可能会因为对方尚未完全初始化而遇到NullPointerException或状态不一致的问题。
了解了 Spring 的默认机制和它的边界,我们就可以探讨如何从根本上避免和解决循环依赖。
3. 环境准备与示例项目搭建
为了直观地演示循环依赖的现象和解决方案,我们创建一个简单的 Spring Boot 项目。
3.1 环境与版本说明
- JDK: 17 或 8
- Spring Boot: 3.x 或 2.7.x (本文示例基于 3.x,核心原理一致)
- 构建工具: Maven
- IDE: IntelliJ IDEA 或 Eclipse
3.2 创建项目与依赖
使用 Spring Initializr 或 IDE 创建项目,主要依赖为 Spring Web。pom.xml 关键依赖如下:
3.3 项目结构
创建以下包和类,模拟循环依赖场景:
4. 循环依赖问题复现与原理分析
4.1 构造器注入导致的循环依赖(Spring 无法解决)
让我们首先创建一个 Spring 无法解决的循环依赖场景。
ServiceA.java
ServiceB.java
启动应用,你会立即看到类似如下的错误,应用启动失败:
错误分析:
Spring Boot 2.6+ 版本默认禁用了循环依赖(spring.main.allow-circular-references=false)。即使你在 application.properties 中将其设为 true,对于构造器注入形成的循环依赖,Spring 核心容器依然无法解决,因为 Bean 的实例化步骤都无法完成。错误信息清晰地指出了循环的路径。
4.2 字段/Setter注入导致的循环依赖(Spring 默认可解决)
现在,我们将注入方式改为字段注入,并开启允许循环依赖的配置。
修改 ServiceA 和 ServiceB,使用字段注入:
在 application.properties 中开启循环依赖支持:
创建一个测试 Controller 来触发 Bean 的加载和使用:
启动应用。这次应用可以正常启动,控制台会输出:
访问 http://localhost:8080/test,会正常返回响应。这说明 Spring 的三级缓存机制成功解决了字段注入方式的循环依赖。
5. 解决循环依赖的工程化方案
虽然 Spring 能解决部分循环依赖,但依赖这个特性是危险的。我们应该从设计和代码层面主动避免或打破循环。以下是几种工程化的解决方案。
5.1 方案一:代码重构与设计模式
这是最根本、最推荐的解决方案。
1. 提取公共逻辑到第三方面
如果 ServiceA 和 ServiceB 相互调用是因为有共同的业务逻辑,可以将这部分逻辑抽取到一个新的 ServiceC(或 CommonService)中,让 A 和 B 都依赖 C,从而将循环依赖变为星型依赖。
2. 使用接口与依赖倒置 定义接口,让依赖关系建立在抽象层上。通过回调、事件或观察者模式解耦。
3. 使用方法调用替代属性注入
如果 A 需要 B 的功能仅仅是在某个特定方法中,可以考虑在需要时通过 ApplicationContext 动态获取,或者将 B 作为方法参数传入。但这通常意味着需要调整调用链的设计。
5.2 方案二:使用 @Lazy 注解
@Lazy 注解可以延迟 Bean 的初始化或依赖的注入。将其加在注入点或 Bean 定义上,可以打破循环依赖的初始化顺序。
在注入点使用 @Lazy:
原理:@Lazy 会让 Spring 注入一个代理对象,而不是真实的 Bean 实例。当第一次调用代理对象的方法时,才会触发真实 Bean 的初始化。这样,双方构造器都能成功调用,循环依赖被延迟到方法调用时才解决。注意:这并没有消除循环依赖,只是推迟了冲突发生的时间,可能会将启动时错误转化为运行时错误。
5.3 方案三:使用 Setter/字段注入 + @Autowired(配合允许循环引用)
这就是我们前面演示的、Spring 默认支持的方案。它利用三级缓存工作。这是下策,因为它掩盖了设计问题,并且对构造器注入无效。
5.4 方案四:使用 ObjectProvider 或 Provider
Spring 提供了 ObjectProvider(或 JSR-330 的 Provider)接口,用于延迟查找和获取 Bean,它也能用于解决某些循环依赖场景。
在这个例子中,ServiceA 并不直接依赖 ServiceB 的实例,而是依赖一个能提供 ServiceB 的 ObjectProvider。这样 ServiceA 的构造器就能成功执行。当 ServiceA 真正需要 ServiceB 时,再通过 Provider 获取。此时 ServiceB 早已被创建并初始化完成(因为 ServiceB 只依赖已完成的 ServiceA)。
6. 最佳实践与工程建议
-
优先使用构造器注入:
- 这是 Spring 官方推荐的方式。它能明确声明 Bean 的强制依赖,保证 Bean 在构造完成后就处于完全初始化的状态(
final字段),并且便于单元测试(无需 Spring 容器即可构造对象)。 - 构造器注入会强制暴露循环依赖问题,迫使你在设计阶段就解决它,而不是将其隐藏到运行时。
- 这是 Spring 官方推荐的方式。它能明确声明 Bean 的强制依赖,保证 Bean 在构造完成后就处于完全初始化的状态(
-
遵循单一职责与依赖倒置原则:
- 每个类应该有明确且单一的职责。如果两个服务紧密耦合,考虑是否违反了单一职责原则。
- 依赖于抽象(接口),而非具体实现。使用接口可以更容易地通过设计模式(如策略、观察者)解耦循环依赖。
-
谨慎使用
@Lazy:- 将
@Lazy作为解决循环依赖的首选方案是危险的。它会使 Bean 的初始化时机变得不确定,可能将启动问题转化为更难调试的运行时问题(如NullPointerException在特定业务流中才出现)。 @Lazy更适用于那些初始化成本高、但不一定在应用启动时就需要的 Bean(如连接池、某些客户端)。
- 将
-
进行架构评审与依赖分析:
- 在项目早期和迭代过程中,定期使用工具(如 IDE 的依赖图、ArchUnit 测试)或通过代码审查来识别潜在的循环依赖。
- 对于大型项目,考虑将相互依赖紧密的模块合并,或者引入一个新的中间模块来承载共享逻辑。
-
理解并接受 Spring 的局限性:
- 明确知道 Spring 只能解决单例、非构造器注入的循环依赖。
- 对于原型 Bean、
@Configuration类中的@Bean方法循环依赖,也需要通过重构或@Lazy来解决。
-
配置与监控:
- 在生产环境中,保持
spring.main.allow-circular-references=false(Spring Boot 2.6+ 默认值)。这能让循环依赖在启动时快速失败,而不是在运行时引发不可预知的行为。 - 在测试阶段,可以结合
@SpringBootTest进行集成测试,确保 Bean 的装配和初始化顺序符合预期。
- 在生产环境中,保持
7. 常见问题排查清单
当遇到 BeanCurrentlyInCreationException 或与循环依赖相关的诡异行为时,可以按照以下步骤排查:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
应用启动失败,报 BeanCurrentlyInCreationException,并显示循环链。 |
1. 构造器注入导致的循环依赖。 2. 原型(Prototype) Bean的循环依赖。 3. @Configuration 类中 @Bean 方法相互调用。 |
1. 分析错误日志:Spring 给出的循环链非常清晰,直接定位到涉及的 Bean。 2. 检查注入方式:将构造器注入改为 Setter/字段注入不是好办法,应优先考虑重构设计,提取公共逻辑或引入接口。 3. 检查 Bean 作用域:确认是否为 @Scope(“prototype”),考虑是否真的需要原型模式。 |
应用能启动,但在调用某个特定方法时抛出 NullPointerException 或 LazyInitializationException。 |
1. 使用了 @Lazy,但代理对象在错误的时间点被访问。2. 在 @PostConstruct 方法中调用了循环依赖对方的方法,而对方可能还未完成初始化。 |
1. 检查 @Lazy 的使用:确保在调用 @Lazy Bean 的方法时,其依赖项已准备就绪。2. 避免在生命周期回调中使用循环依赖:不要在 @PostConstruct、InitializingBean.afterPropertiesSet() 或构造器中调用可能尚未完全初始化的依赖 Bean 的业务方法。 |
| 字段注入的循环依赖,在 Spring Boot 2.6+ 下启动失败。 | Spring Boot 2.6 开始默认禁止循环引用。 | 1. 首选方案:重构代码,打破循环。 2. 临时方案:在 application.properties 中设置 spring.main.allow-circular-references=true。务必清楚这只是临时绕过,并评估风险。 |
使用 @Async、@Transactional 等 AOP 代理时,循环依赖出现问题。 |
AOP 代理可能干扰了三级缓存的正常工作流程,尤其是在使用 JDK 动态代理且代理基于接口时。 | 1. 确保 AOP 代理模式一致:默认使用 CGLIB 代理 (spring.aop.proxy-target-class=true) 对循环依赖支持更好。2. 尝试对循环依赖的一方使用 @Lazy。3. 考虑将 AOP 切面逻辑移到循环依赖链之外的服务中。 |
循环依赖是 Spring 开发中的一个深水区问题,它既是技术挑战,也是软件设计质量的试金石。Spring 提供的三级缓存机制是一个精巧的工程解决方案,但它更像是一把“安全锤”,用于在紧急情况下破窗,而不应作为架构设计的常规工具。优秀的系统设计应当追求清晰、单向的依赖关系。当循环依赖出现时,把它视为一个重构代码、优化架构的宝贵信号。通过本次对“噬菌体”式循环依赖的全面剖析,希望你能掌握其原理、解决方案与避坑指南,从而构建出更加健壮、可维护的 Spring 应用。