1. 项目概述:当SpringBoot告诉你“你的Bean在谈恋爱”
“The dependencies of some of the beans in the application context form a cycle.” 如果你在启动SpringBoot应用时,控制台突然抛出这么一句看似文雅、实则令人头疼的异常,恭喜你,你遇到了Spring框架中一个经典且棘手的问题——循环依赖。这行报错翻译过来就是:“应用上下文中某些Bean的依赖关系形成了一个循环。” 说白了,就是你的Bean们陷入了“你中有我,我中有你”的死锁状态,Spring的IoC容器在创建它们时彻底懵了,不知道从谁开始。
这个问题在中小型项目中可能不常遇到,但随着业务复杂度的提升,尤其是在多人协作、模块划分不够清晰或者架构设计存在瑕疵时,它就像一个定时炸弹,随时可能在项目启动时引爆。我见过不少团队,在项目后期为了快速实现功能,随意地在Service之间相互注入,最终导致启动失败,排查起来费时费力。今天,我们就来彻底拆解这个“循环依赖”难题,不仅告诉你如何快速解决眼前的报错,更要从原理上理解Spring是如何处理(以及为何有时无法处理)循环依赖的,并分享一套从编码习惯到架构设计上避免此类问题的实战经验。无论你是刚接触SpringBoot的新手,还是已经踩过几次坑的老鸟,这篇文章都能帮你建立起清晰的问题解决思路。
2. 循环依赖的本质与Spring的“三级缓存”机制
要解决问题,必须先理解问题是如何产生的。循环依赖,用最直白的代码表示,就是下面这种情况:
@Service public class ServiceA { @Autowired private ServiceB serviceB; } @Service public class ServiceB { @Autowired private ServiceA serviceA; }ServiceA依赖ServiceB,而ServiceB又反过来依赖ServiceA。这形成了一个闭环。在普通的Java对象创建中,这会导致无限递归,最终栈溢出。Spring作为容器,其核心职责之一就是管理Bean的生命周期和依赖关系,它必须有一套机制来尝试打破这个闭环。
2.1 Bean的生命周期简析
要理解循环依赖的解决,必须对Bean的创建过程有个基本概念。Spring创建一个Bean(以单例为例)大致经历以下几个关键步骤:
- 实例化:通过反射调用构造函数,创建一个“半成品”对象(此时属性均为null)。
- 属性填充:进行依赖注入(如
@Autowired),为这个“半成品”对象的属性赋值。 - 初始化:调用初始化方法(如
@PostConstruct标注的方法、InitializingBean接口的afterPropertiesSet方法)。 - 放入单例池:完成后的Bean被放入“单例池”(即一级缓存
singletonObjects),后续直接从池中获取。
循环依赖的症结就出现在第1步和第2步之间。如果A依赖B,那么A在属性填充时,需要去获取B的实例。如果B还没被创建,容器就会去创建B;而如果B又依赖A,那么B在属性填充时,又会去获取A的实例……这就陷入了死循环。
2.2 三级缓存破局之道
Spring解决单例Bean的Setter方法注入或字段注入(@Autowired)造成的循环依赖,其核心秘密在于“三级缓存”。这是面试中的高频考点,也是理解整个机制的关键。
这三级缓存定义在DefaultSingletonBeanRegistry类中:
- 一级缓存
singletonObjects:ConcurrentHashMap,存放已经完全初始化好的Bean实例。我们平时从Spring容器@Autowired进来的,就是这里的Bean。它是最终形态。 - 二级缓存
earlySingletonObjects:HashMap,存放提前暴露的、早期的Bean引用。这些Bean已经实例化,但尚未完成属性填充和初始化(是个“半成品”)。它的存在是为了解决循环依赖过程中,可能出现的代理对象问题(如AOP)。 - 三级缓存
singletonFactories:HashMap,存放Bean的工厂对象(ObjectFactory)。这个工厂对象能产生该Bean的早期引用(可能是原始对象,也可能是代理对象)。
2.3 解决循环依赖的推演流程
让我们结合上面的A、B两个Service,推演一下Spring三级缓存的工作流程:
- 开始创建A:Spring准备创建
ServiceA,在实例化之后(调用构造函数,得到一个a = new ServiceA()),立即将这个“半成品”A包装成一个ObjectFactory工厂,放入三级缓存singletonFactories中。此时A的属性serviceB还是null。 - 填充A的属性,发现需要B:Spring开始为A进行属性填充,发现它依赖
ServiceB。于是去一级缓存找B,没有;去二级缓存找,也没有;去三级缓存找,还是没有(因为B还没开始创建)。 - 转而创建B:容器决定先去创建B。同样,实例化B之后,将“半成品”B的工厂对象放入三级缓存。
- 填充B的属性,发现需要A:Spring开始为B进行属性填充,发现它依赖
ServiceA。于是开始查找:- 一级缓存:无。
- 二级缓存:无。
- 三级缓存:有!找到了之前存放的A的工厂对象。
- 获取A的早期引用:通过三级缓存中的工厂对象,获取到A的早期引用(这个引用可能已经是AOP代理对象),并将这个早期引用从三级缓存升级到二级缓存(同时从三级缓存移除)。然后将这个早期引用注入给B。
- B完成创建:B成功完成了属性填充(注入了A的早期引用)和初始化,变成一个“完全体”Bean,被放入一级缓存。同时,清理二级和三级缓存中关于B的条目。
- A完成创建:此时流程回到第2步,A在等待B。现在一级缓存里已经有了B的“完全体”,Spring将其取出,注入到A中。接着A完成后续的初始化,也变成一个“完全体”Bean,放入一级缓存。
至此,循环依赖被成功解决,A和B都成为了可用的Bean。
关键理解:三级缓存的核心思想是“提前暴露引用”。在对象刚实例化、还是个“空壳”的时候,就把它的引用(工厂)存起来,供其他依赖它的Bean使用,从而打破“鸡生蛋、蛋生鸡”的僵局。二级缓存主要是一个中间缓存,用于性能优化和避免重复执行工厂逻辑。
2.4 为何需要三级缓存?两级不行吗?
这是一个经典的深度面试题。很多人会问,既然最终目的是拿到早期引用,为什么不直接用二级缓存(一个放成品,一个放半成品)?
关键在于AOP代理。如果Bean被AOP切面代理(比如事务@Transactional),那么最终放入容器、被其他Bean依赖的应该是代理对象,而不是原始对象。这个代理对象的创建时机是在初始化之后。但在循环依赖的场景下,B在属性填充时就需要A,此时A的代理对象还没生成(因为A还没初始化完)。
三级缓存中的ObjectFactory就是为了处理这个延迟决策。当B通过工厂获取A的早期引用时,这个工厂会执行一个getEarlyBeanReference方法。在这个方法里,Spring会检查A是否需要被代理。如果需要,就在这里提前生成代理对象并返回;如果不需要,就返回原始对象。这个逻辑被封装在工厂里,确保了无论何时获取,都能得到正确的对象(原始或代理)。
如果只有二级缓存,那么半成品池里存放的就是实例化后的原始对象。当A需要被代理时,B拿到的就是原始对象,而最终放入一级缓存的却是代理对象,这就造成了不一致:B依赖的A和最终容器里的A不是同一个对象!这会导致严重的问题。
所以,三级缓存的核心价值在于解耦“实例化”、“代理生成”和“暴露引用”的时机,以支持循环依赖下的AOP。
3. 哪些情况Spring也无法解决循环依赖?
了解了解决机制,更要明白它的局限性。Spring的三级缓存不是万能的,在以下几种情况下,循环依赖会直接导致启动失败,报出文章开头的错误。
3.1 构造器注入导致的循环依赖
这是最常见且Spring无法自动解决的情况。
@Service public class ServiceA { private final ServiceB serviceB; // 构造器注入 public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; } } @Service public class ServiceB { private final ServiceA serviceA; // 构造器注入 public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; } }为什么无法解决?回忆一下Bean的创建步骤:实例化 → 属性填充 → 初始化。
- 构造器注入发生在实例化阶段。要实例化A,必须首先有B的实例作为参数。但B的实例化又需要A的实例作为参数。这就成了一个“先有鸡还是先有蛋”的经典死锁,在对象实例化的第一步就卡住了,根本没有机会走到“提前暴露引用”(三级缓存)那一步。因为实例化都完成不了,自然没有“半成品”可以暴露。
3.2@Async异步方法导致的循环依赖
@Async注解的原理也是通过AOP生成代理对象。但它的问题更隐蔽。假设A有一个@Async方法,B依赖A。
@Service public class ServiceA { @Async public void asyncMethod() {...} } @Service public class ServiceB { @Autowired private ServiceA serviceA; // 注入的应该是A的代理对象 }如果A也依赖B,就可能出问题。因为@Async代理的创建通常发生在Bean生命周期的后期(通过BeanPostProcessor),在解决循环依赖的早期引用暴露阶段,负责创建@Async代理的处理器可能还未执行,导致工厂无法生成正确的代理对象,从而引发异常。
3.3 多例(Prototype)作用域的Bean循环依赖
Spring默认不处理原型Bean的循环依赖。因为对于原型Bean,每次getBean()都会创建一个新的实例。如果支持循环依赖,缓存中将会充斥着大量不完整的原型Bean实例,导致内存泄漏和逻辑混乱。因此,Spring在检测到原型Bean的循环依赖时,会直接抛出BeanCurrentlyInCreationException。
3.4 其他特殊情况
@PostConstruct方法中相互调用:即使在Setter注入下解决了循环依赖,如果两个Bean在@PostConstruct初始化方法中互相调用对方的方法,也可能因为状态未完全准备好而引发运行时异常。- 复杂的间接循环依赖:A依赖B,B依赖C,C依赖A。这种多层的循环依赖,虽然Spring有可能解决,但极大地增加了设计的复杂性和理解成本,是糟糕设计的信号。
4. 实战:诊断与解决循环依赖报错
当你的应用启动失败,看到循环依赖报错时,不要慌张。按照以下步骤,可以高效地定位和解决问题。
4.1 解读错误信息
SpringBoot 2.6+ 版本对循环依赖的报错信息非常友好。错误日志通常会包含一个类似下面的依赖关系链条:
The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | serviceA defined in file [xxx/ServiceA.class] ↑ ↓ | serviceB defined in file [xxx/ServiceB.class] └─────┘这个ASCII艺术图清晰地展示了循环路径:serviceA → serviceB → serviceA。你需要重点关注箭头指向的两个Bean类。
4.2 使用IDE工具辅助分析
现代IDE(如IntelliJ IDEA)是强大的帮手。
- 依赖关系图:在IDEA中,右键点击类名,选择Diagrams -> Show Dependencies。它可以可视化展示类之间的依赖关系,帮助你快速发现循环。
- 查找用法:在报错的Bean类上,使用Find Usages(
Alt+F7) 功能,查看它被哪些类注入,以及它又注入了哪些类。顺藤摸瓜,往往能找到循环链。
4.3 解决方案一:重构设计(治本之策)
这是最推荐、最根本的解决方案。循环依赖通常是职责划分不清或架构层次混乱的表现。
- 提取公共逻辑到第三个类:检查A和B,看是否有一部分业务逻辑是它们都需要的。将这部分逻辑抽取到一个新的
ServiceC或Component中,让A和B都去依赖C,从而打破A和B之间的直接循环。 - 使用接口与依赖倒置:定义接口,让高层模块依赖接口而非具体实现。有时循环依赖发生在具体实现类之间,通过面向接口编程,可以将依赖关系梳理得更清晰。
- 应用领域驱动设计(DDD)或清晰分层:严格遵循Controller -> Service -> Repository的分层,禁止同层之间相互注入,特别是Service层。如果两个Service需要协作,考虑是否应该有一个更上层的Service来协调它们,或者将部分功能下沉到领域模型或工具类中。
4.4 解决方案二:使用Setter/字段注入替代构造器注入(治标之法)
如果循环依赖无法通过重构立即消除,且是由构造器注入引起的,可以临时将其改为Setter注入或字段注入。
@Service public class ServiceA { private ServiceB serviceB; // 改为Setter注入 @Autowired public void setServiceB(ServiceB serviceB) { this.serviceB = serviceB; } }为什么这样可行?如前所述,Setter注入发生在属性填充阶段,此时Bean的实例(半成品)已经创建并提前暴露到了三级缓存,因此有机会解决循环。
重要提示:这只是一个临时解决方案。从设计模式和代码质量的角度看,构造器注入是更推荐的方式,因为它能明确声明不可变的依赖,便于测试,并能保证Bean在构造完成后就处于完全初始化的状态。改用Setter注入掩盖了设计问题,应尽快用方案一进行重构。
4.5 解决方案三:使用@Lazy注解(缓兵之计)
@Lazy注解可以延迟依赖的初始化。
@Service public class ServiceA { private final ServiceB serviceB; // 在构造器参数上使用 @Lazy public ServiceA(@Lazy ServiceB serviceB) { this.serviceB = serviceB; } }或者用在字段/Setter上:
@Service public class ServiceA { @Lazy @Autowired private ServiceB serviceB; }工作原理:@Lazy告诉Spring,不要立即注入一个真实的ServiceB实例,而是先注入一个代理对象。当第一次真正调用serviceB的方法时,代理才会去触发真实Bean的创建和初始化。这样就打破了启动时的即时依赖循环。
适用场景与风险:@Lazy适用于循环依赖链条中不那么关键、或者不需要在启动时就完全初始化的依赖。但它会带来一些副作用:
- 问题被推迟到运行时,可能使一些初始化错误更难发现。
- 增加了代理开销。
- 可能掩盖了更深层次的设计缺陷。因此,它也应被视为一种临时手段。
4.6 解决方案四:调整SpringBoot配置(不推荐)
在SpringBoot 2.6版本之前,默认允许单例Bean的循环依赖。但从2.6版本开始,为了鼓励更好的设计,SpringBoot默认禁止了循环依赖。
如果你不得不暂时容忍循环依赖(例如在迁移旧项目时),可以在配置文件中将其打开:
# application.properties spring.main.allow-circular-references=true强烈不建议这样做!这个配置相当于关掉了编译器的所有警告,让一个糟糕的设计继续运行,会给项目埋下巨大的技术债。它只能解决Setter/字段注入的循环依赖,对构造器注入依然无效。
5. 编码规范与架构设计预防循环依赖
最好的解决方式是不让它发生。在团队中建立良好的编码规范至关重要。
5.1 依赖注入方式的选择建议
- 强制使用构造器注入(对于必需依赖):这能迫使开发者思考每个Bean的必需依赖是什么,并且能立即暴露出循环依赖问题(启动就失败),而不是将其隐藏到运行时。
- 可选依赖使用Setter注入:对于一些可选的、或配置类的依赖,可以使用Setter注入,并配合
@Autowired(required = false)。 - 避免字段注入:虽然字段注入写起来简洁,但它有几个缺点:无法声明依赖为
final(不可变)、不利于单元测试(必须通过反射注入)、隐藏了依赖关系。许多团队规范已明确禁止使用字段注入。
5.2 模块与包结构设计
- 清晰的单向依赖层次:设计一个严格的依赖规则,比如:Web层 → 业务层 → 数据层。使用IDE或架构守护工具(如ArchUnit)来检查并禁止反向依赖和循环依赖。
- 依赖注入框架(Dagger, Guice)的启示:这些框架通常对循环依赖有更严格的检查。学习它们的思想,在设计时就将组件视为有向无环图(DAG)中的节点。
- 定期进行架构复审:在代码评审中,除了看业务逻辑,也要关注类之间的依赖关系图。利用SonarQube等静态代码分析工具,可以设置规则来检测循环依赖。
5.3 利用Spring的特性进行优化
@DependsOn注解:如果一个Bean的初始化需要在另一个Bean之后,可以使用@DependsOn来显式声明,但这不应用于解决循环依赖,而是用于定义明确的初始化顺序。- 事件驱动解耦:对于某些需要协作的场景,可以考虑使用Spring的事件发布/订阅机制(
ApplicationEventPublisher)。Bean A完成某工作后发布一个事件,Bean B监听该事件并做出响应,从而避免直接的依赖调用。
6. 高级场景:在复杂项目中定位深层循环依赖
在大型微服务或遗留系统中,循环依赖链可能很长,跨越多个模块。此时,需要更系统的排查方法。
6.1 使用Spring Actuator的Beans端点
如果应用能部分启动(比如因为某个非关键Bean的循环依赖),可以启用Actuator,访问/actuator/beans端点。这个端点会以JSON形式返回所有Bean的定义及其依赖关系,数据非常详细,可以帮你梳理复杂的依赖网。
6.2 编写单元测试暴露问题
为疑似存在循环依赖的模块编写简单的集成测试。
@SpringBootTest class CircularDependencyTest { @Autowired private ApplicationContext applicationContext; @Test void contextLoads() { // 如果存在无法解决的循环依赖,测试启动时就会失败 } }在持续集成(CI)流程中加入这个测试,可以防止新增代码引入循环依赖。
6.3 分析工具:JDepend与Structure101
对于遗留系统的技术债清理,可以使用专门的架构分析工具:
- JDepend:可以生成包级别的依赖度量报告,识别循环依赖包。
- Structure101:功能更强大,可以可视化代码结构,设置架构规则(如“不允许循环依赖”),并在构建时检查违规。
这些工具能从更高维度帮你发现架构层面的循环依赖,而不仅仅是类级别的。
7. 总结与个人实践心得
循环依赖报错是SpringBoot开发中的一个“富贵病”,往往出现在业务快速发展、代码量激增的阶段。它像一个设计上的警报器,提醒我们是时候停下来审视一下代码结构了。
我的经验是,不要轻易使用spring.main.allow-circular-references=true或过度依赖@Lazy。前者是掩耳盗铃,后者虽然有用,但就像给代码打上“此处有坑”的标记,应作为临时过渡,并附上清晰的注释说明,计划何时重构。
坚持构造器注入,能让问题在最早期的编译或启动阶段就暴露出来,这远比在运行时因为某个Bean状态不对而调试半天要高效得多。在团队中推行这个规范,初期可能会遇到阻力,觉得写起来麻烦,但长期来看,它对代码的可维护性和可测试性带来的好处是巨大的。
最后,理解“三级缓存”的原理,不仅仅是为了应付面试,更是为了在遇到复杂问题时,能清晰地知道Spring容器内部在做什么,从而做出正确的判断和决策。当你再看到 “The dependencies of some of the beans in the application context form a cycle” 时,希望你的第一反应不是去搜索如何关闭检查,而是能自信地说:“让我看看是哪里设计得不合理。”