上上周在内部技术群里看到有人贴启动报错一屏红色核心就一句话The dependencies of some of the beans in the application context form a cycle。下面跟着一串OrderService - StockService - PaymentService - OrderService的箭头链。像这种SpringBoot循环依赖问题出现的频率远超大多数人的想象。我见过刚转Java的同事遇到它时一脸懵也见过干了七八年的老手在处理它时选择直接用Lazy糊弄过去结果启动不报错了线上却出现了诡异的数据不一致。所以我想把循环依赖这件事从根上讲清楚它到底为什么发生、SpringBoot容器底层是用什么机制兜底的、哪些场景必然无解、哪些方案真正值得用。无论你是刚接触Spring的开发还是已经在这种报错里折腾过几次的人这篇文章都值得花几分钟读完。读完你基本能自己判断当前这个循环依赖是该重构还是该临时用某个注解止血。1. 循环依赖到底是怎么发生的1.1 一个最简单的复现先写一段几乎所有Java后端都见过的代码Service public class OrderService { Autowired private StockService stockService; } Service public class StockService { Autowired private OrderService orderService; }这段代码在Spring Boot 2.6之前的默认配置下启动一般不报错因为容器默认允许循环依赖并会用内置机制兜住。但从Spring Boot 2.6开始官方默认把循环依赖关闭启动时会直接抛异常。这个差异本身就值得注意很多老项目升级Spring Boot版本后突然冒出大量循环依赖报错其实代码里的环一直存在只是以前被容器默默容忍了。循环依赖不一定是“两个对象互相持有字段”。严格来说它是对象图里存在一个环比如A - B - C - A这种传递依赖在大型业务系统里更隐蔽也更难一眼看出来。报错日志里给出的箭头链就是定位的依据。1.2 为什么会出现循环业务层面的真实成因循环依赖本质上是设计阶段留下的问题不是Spring的Bug。我在实际项目里见过几种典型成因第一种是模块边界模糊。订单模块、库存模块、支付模块各有一个Service但彼此之间没有明确的领域边界。订单服务为了校验库存直接调用库存服务库存服务为了回写预占状态又反向调用订单服务环就形成了。这种环往往不是一开始就有而是后面加需求时顺手在类里塞了新依赖。第二种是回调泛滥。服务A直接调用B的一个方法B处理完又需要把结果回传给A于是B反向持有A。如果回调逻辑完全没有异步化或事件化处理双向持有就成了必然。第三种是接口与实现错位。比如SomeServiceImpl注入了一个OtherService而OtherServiceImpl又依赖SomeService接口结果因为接口和实现类的关系没理清形成了错觉性的双向依赖。第四种是历史债务积累。老项目里二十个Service互相引用启动不报错纯粹是因为默认配置允许循环依赖。代码里到处都是字段注入后来要改成构造器注入时一改一个准全在启动阶段炸开。1.3 无环的依赖方向长什么样正常的依赖方向应该是有向无环的Controller - Service - Repository - DataSource。每一层只依赖下层同层之间可以有接口约定但不要互相反向持有。这样对象图是线性展开的Spring在构建Bean时只需要按顺序创建不需要处理“正在创建的Bean又被别人引用”这种特殊情况。依赖无环还有一个额外好处可测试性会明显变好。每个Service构造时只向构造函数传它真正需要的依赖单测只需要mock一个方向不需要A、B、C连环构造。2. 三级缓存Spring解决循环依赖的底层逻辑2.1 先认识Spring容器里的三个MapSpring解决单例Bean循环依赖的核心是DefaultSingletonBeanRegistry里的三个缓存。很多文章把它们叫“三级缓存”虽然官方源码里没有“三级缓存”这个词但这个称呼确实很形象。缓存数据结构存放内容singletonObjectsMapString, Object一级缓存存放创建完成的成品单例BeanearlySingletonObjectsMapString, Object二级缓存存放提前暴露的早期引用singletonFactoriesMapString, ObjectFactory?三级缓存存放提前暴露的Bean工厂一级缓存是普通的成品仓库只在Bean完整创建成功后才放进去。二级缓存里放的是“实例化完成但尚未完成属性填充/初始化”的早期引用说白了就是半成品。三级缓存比较特殊它不是直接保存Bean实例而是保存一个ObjectFactory只有在真的发生循环依赖、容器需要提前拿引用时才调用这个工厂生成早期引用。2.2 一个Bean的创建时序从零到一我们用A - B - A这个场景走一遍Spring的创建流程。假设A依赖BB依赖A并且两者都是单例。第一步容器开始创建A。createBeanInstance先调用A的构造器完成实例化。这时A已经是一个Java对象了但它的属性还没填充Autowired属性全是null。第二步A实例化完成后Spring会把一个ObjectFactory放到三级缓存里。这一步对应源码里的addSingletonFactory核心逻辑就是把工厂登记好真正要不要通过工厂生成早期引用还要看后续是否需要。第三步A开始填充属性发现需要一个B。于是Spring去容器里获取B走B的创建流程。B完成实例化后也登记到三级缓存然后开始填充属性。第四步B填充属性时发现需要一个A。它去一级缓存里查没有去二级缓存里查也没有。接着去三级缓存里查发现A的工厂还在于是调用工厂生成A的早期引用放到二级缓存同时把三级缓存里那个工厂删掉。第五步B拿到了A的引用哪怕这个A还没填充完属性B也能先握着它继续完成自己的创建。B创建完后作为一个成品放进一级缓存。第六步回到A的创建流程。A把B属性填充完毕继续走初始化相关逻辑最后也作为成品放进一级缓存。getSingleton的判断逻辑核心大致是这样protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { // 从三级缓存拿工厂通过工厂生成早期引用 ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }细节可能随版本略有调整但核心逻辑是稳定的。值得留意的是二级缓存里放的是A的早期引用可能是一个还没完成属性注入的裸对象。所以在循环依赖里B拿到的A的字段很可能还没完全初始化这是很多运行时诡异问题的根源。2.3 为什么非要三级缓存而不是直接提前暴露很多人会问既然二级缓存已经能够存取早期引用为什么还要第三级缓存网上有个流传很广的说法是“三级缓存纯粹为了AOP”其实更准确地说三级缓存的价值在于“按需暴露”和“代理与原始对象的一致性”。一级缓存不能直接放半成品这个问题很直观。如果Bean还在创建过程中就被放进成品缓存其他线程并发读取时可能拿到一个没初始化完的Bean造成难以预料的空指针或状态错乱。所以一级缓存必须只放成品。那么为什么不直接在Bean实例化后就把实例放进二级缓存如果这样做所有单例Bean在没有任何循环依赖的情况下也会被强制注册一个早期引用。这些Bean大多永远不会被别人在创建期提前引用白费一次写入和一次查询。更麻烦的是代理问题很多Bean最终是需要被AOP增强的早期引用和最终代理对象如果被分成两次生成容器里就可能出现两个不同形态的“同一个Bean”依赖方拿到的是原始对象调用方拿到的却是代理对象切面、事务、异步全部失效。三级缓存保存的是工厂而不是实例核心思想是延迟计算。只有在真正发生“当前Bean正在创建又被另一个Bean反向依赖”时才会调用工厂生成早期引用。生成动作会经过BeanPostProcessor链比如SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference它能在提前暴露时就把AOP代理创建出来保证依赖方拿到的引用和最终成品保持一致。2.4 代理对象与缓存之间的微妙关系当一个Bean命中循环依赖且需要被代理时时序非常讲究。正常创建流程里Bean先实例化再填充属性再执行InitializingBean、PostConstruct等初始化逻辑最后才由代理创建器生成代理对象。但在循环依赖里B在填充属性时就需要A如果这时不生成代理B拿到的就是原始对象。等A最终生成代理后B里持有的引用和容器里的A就不是同一个对象依赖就错乱了。所以Spring在早期引用生成时会主动调用getEarlyBeanReference提前创建代理。这一步完成后再标记一下等后续A走完初始化流程发现代理已经生成就不会再造一遍新代理而是复用早期引用。这样一来依赖方看到的是代理对象最终容器里放的也是同一个代理对象。理解了这一层再回头看Async场景就能明白很多坑是怎么来的。如果某个Bean同时存在循环依赖和异步方法早期引用生成时就必须已经把异步代理处理掉否则后面怎么补救都容易出现“调用了异步方法但同步执行”或者“代理对象不一致”之类的诡异问题。2.5 三级缓存能兜底的前提条件三级缓存并不是万能的它需要同时满足几个前提第一Bean必须是单例作用域。只有单例才有一级缓存放成品、二级缓存放早期引用的概念原型Bean每次请求都新建没有“在制品”可以共享。第二在Spring Boot 2.6之后必须允许循环引用。默认配置是禁止的启动就会报错。第三依赖注入方式不能是构造器注入。用构造器注入时循环依赖根本走不到缓存那一步。这些条件后面还会展开讲。先记住一点三级缓存解决的是“setter注入/字段注入 单例 允许循环引用”这个组合它很巧妙但不是所有循环依赖的万能药。3. 有些循环依赖Spring真的救不了3.1 构造器注入循环从第一步就卡死构造器注入循环是最常见、也最容易让新手困惑的一种。看似只是把Autowired从字段上挪到了构造器参数上结果启动直接报错这是为什么因为Bean的实例化过程必须优先调用构造器而构造器里的参数必须先被解析出来。我们看A(StockService stockService)这种写法Spring要创建A首先得拿到一个完整的StockService对象。但StockService反过来又依赖A而A此时连构造器都还没执行完根本不存在一个可以提前暴露的实例。三级缓存暴露的前提是“实例化已完成只是属性还没填充完”构造器注入卡在实例化这一步Spring没有可用的早期引用来打破僵局。报错信息通常长这样BeanCurrentlyInCreationException: Error creating bean with name orderService: Requested bean is currently in creation: Is there an unresolvable circular reference?解决办法不是去调整缓存而是改变注入方式或者用Lazy构造器参数延迟解析。3.2 prototype、request等非单例作用域原型Bean没有缓存概念。每次getBean(xxx)都会新建一个实例Spring不会为它保存早期引用也没有成品仓库可以放最终对象。两个原型Bean互相依赖时容器无论怎么努力都无法让它们稳定地持有彼此直接报循环依赖错误。request、session作用域和线程绑定上下文强相关。虽然理论上有缓存但早期引用和完整Bean跨请求共享很容易出问题实际项目中也不建议用它们解决循环依赖。最稳妥的办法是让短生命周期Bean依赖长生命周期Bean避免互相持有。3.3 Async与AOP代理叠加的陷阱Async、Transactional、自定义切面这类依赖AOP代理的注解和循环依赖叠加时最容易出现“启动不报错运行期行为诡异”的问题。典型表现是A依赖BB依赖AA上还标了一个Async方法。启动可能顺利通过但真正调用那个异步方法时发现它在当前线程同步执行。之所以这样是因为早期引用生成时如果没能正确预告代理创建最终容器里放的Bean形态和依赖方持有的Bean形态可能不一致导致注解增强被绕过了。这个问题比启动报错难排查得多。启动报错至少能告诉你环在哪而运行期行为异常往往要靠Debug断点、查看Bean class类型、检查代理是否生效才能定位。我的建议是凡是涉及AOP增强的Bean一旦卷入循环依赖不要试图通过调注入方式去绕直接把环拆掉最干净。3.4 SpringBoot 2.6 开始默认拒绝Spring Boot 2.6.0发布说明里有一个重要变化默认禁止循环依赖。项目升级到2.6之后原本被容器容忍的循环依赖全部会在启动时暴露出来。官方给了一个应急开关spring.main.allow-circular-referencestrue但这个开关只是退回到旧版行为让Spring继续用三级缓存去兜底并没有消除环本身。我建议把它当成临时措施而不是长期配置。每次打开它都是在给未来的维护者埋雷。3.5 为什么有时候换了个注入方式就好了很多人在“字段注入A依赖B、B依赖A”时发现不报错换成构造器注入就报错于是误以为是Spring在不同注入方式下的能力差异。核心区别在于字段注入和setter注入时Bean可以先用默认构造器完成实例化实例已经存在才能被三级缓存暴露出去构造器注入时实例还没创建出来就没有“早期引用”可暴露。所以本质上不是注入方式导致环消失而是注入方式决定Bean能否在环出现前先生成半成品。这也是为什么别人常说“setter注入能缓一缓循环依赖”但根本解法还是重构。4. 解决方案图谱哪个方案配什么场景4.1 重构从根上拆掉环重构治本但也最容易被忽视。真正常见的业务环都不是无缘无故形成的而是两个类的职责边界划错了。我常用的拆环思路有三种第一种是职责下沉。抽出公共逻辑独立成一个新组件让原来互相依赖的两个类都只依赖这个新组件。比如订单服务为校验库存调用库存服务库存服务为回写订单状态调用订单服务那“订单状态更新”这个能力应该下沉为独立的订单状态服务由两边共同依赖而不是两两互调。第二种是依赖倒置。把一对双向强依赖改成单向依赖加回调接口。A依赖B但B不再直接持有A而是持有A定义的某个回调接口。调用关系还是A调B但B通过接口把结果传回去依赖方向就变成了单向。第三种是事件驱动。这是Spring项目里最优雅的方案。A完成某操作后通过ApplicationEventPublisher发布事件B作为监听者消费事件A和B之间没有编译期依赖。代价是事件异步化后事务边界和失败补偿需要额外设计但这一块本来就应该认真设计。4.2 Lazy快速止血但要清楚代价Lazy可以加在字段注入、setter注入、构造器参数上。它的原理不是解决环而是给依赖方注入一个代理对象真正调用目标方法时才让Spring去创建或获取那个Bean。比如构造器注入的循环可以加在参数上Service public class OrderService { private final StockService stockService; public OrderService(Lazy StockService stockService) { this.stockService stockService; } }这样Spring构造OrderService时不需要立刻创建真实的StockService而是注入一个延迟代理对象。等到OrderService真正调用StockService方法时代理内部才发起Bean获取实现“晚一步再创建”。这个方案特别适合“升级Spring Boot 2.6后大量启动报错需要快速恢复上线”的应急场景。但我必须强调两点代价第一延迟初始化把启动期的错误推到了运行期原来一启动就暴露的问题变成了线上某个请求时才爆炸第二延迟代理和真实对象之间如果再有AOP叠加代理嵌套的情况会让调试变得很痛苦。4.3 setter或字段注入能兜底但别当常规手段setter注入能解决部分循环依赖因为Bean先完成实例化再填充属性有机会走三级缓存的提前暴露路径。字段注入也一样。但把它们当常规方案就有问题了。字段注入的类很难被独立构造测试依赖关系被隐藏类里到底依赖什么只能靠读源码。Spring官方也一直推荐构造器注入因为构造器能强制让依赖可见、让类不可变。循环依赖只是特殊场景不应该因此全面倒退回setter注入。我的个人习惯是构造器注入为主遇到循环依赖先考虑重构确实没法快速重构时用Lazy缓冲而不是把工程改成全字段注入。4.4 DependsOn调整顺序不等于解除环DependsOn控制的是Bean的初始化顺序不是依赖关系本身。比如A需要在B之后创建可以写Service DependsOn(stockService) public class OrderService { }但如果OrderService和StockService之间存在双向依赖光调顺序解决不了问题。因为A在创建时需要BB在创建时需要A无论先创建谁最终还是会卡在“创建中的Bean被反向引用”这个死结上。只有那些“不构成对象引用环、只是初始化前后关系需要约束”的场景DependsOn才真正有效。4.5 ObjectProvider推迟获取依赖的另一种方式Spring 4.3之后提供了ObjectProvider它和Lazy有相似之处但更通用。可以在构造器里注入ProviderComponent public class OrderService { private final ObjectProviderStockService stockServiceProvider; public OrderService(ObjectProviderStockService stockServiceProvider) { this.stockServiceProvider stockServiceProvider; } public void doSomething() { StockService stockService stockServiceProvider.getIfAvailable(); } }ObjectProvider还有一个好处可以用getIfAvailable、getIfUnique处理可选依赖和候选Bean歧义问题。在确实不希望启动阶段就解析依赖的场景里它是比Lazy更规范的选择。4.6 方案选型对比方案能否根治适用场景主要风险重构依赖关系能任何场景最推荐改动范围大需要完整回归测试Lazy延迟注入不能构造器注入循环快速止血错误推迟到运行期代理叠加难排查setter/字段注入不能老代码临时兼容依赖隐藏可测试性差DependsOn不能初始化顺序问题非依赖环环没消失只是调整顺序ObjectProvider不能可选依赖、推迟解析使用不当可能拿不到对象allow-circular-referencestrue不能升级SpringBoot 2.6后应急掩盖问题成为长期技术债5. 实战定位、修复与防复发5.1 从启动报错定位循环链Spring实现循环依赖出现在启动阶段时报错会直接给出循环链。典型长这样The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderService ↑ ↓ | stockService └─────┘遇到这种报错第一步不是想着怎么关开关而是把箭头链读出来。链上的每一个类都是环的一部分只要打断其中任意一条边整个环就能解开。所以你要做的其实是找到那条“最不影响业务逻辑的边”把它改掉。我一般会在IDE里用依赖图插件把相关类的关系画出来然后从环上找“职责最不正统”的那个依赖。比如库存服务不应该去主动改支付结果那它为什么会反向持有支付相关的类多半是设计上越界了。这类边砍起来最安全改动也最小。5.2 一个模拟项目的完整排查复盘以前做过一个模拟项目X订单履约系统里维护着支付、订单、库存三个核心流程。升级Spring Boot版本后启动失败循环链是PaymentFacade - OrderPolicyService - StockGateway - PaymentFacade第一眼觉得奇怪支付门面为什么要被库存网关反向依赖查了代码才发现库存服务每次更新库存后都要去同步一份支付结果状态。这实际上是把“支付结果状态同步”的职责错误地塞进了库存网关里导致库存网关被迫依赖支付门面。我的修复思路是支付结果状态变化本来就是一个业务事件库存侧感兴趣的话应该监听事件而不是主动调用支付门面。于是把这段逻辑改为在支付完成时发布PaymentCompletedEvent库存模块新建一个监听器消费事件并更新本地状态。StockGateway不再依赖PaymentFacade环顺理成章消失。修复后还额外获得了一个好处库存和支付的调用不再是同步阻塞支付完成后的库存变化可以异步处理接口耗时降下来了一截。当然事件消费的失败补偿多了一些工作量但这本来就应该作为业务完整性的一部分来设计。5.3 工程规范从代码评审阶段拦住环循环依赖最好在代码评审阶段就被发现而不是等启动了再修。我总结了几条可落地的检查项新写的Service默认使用构造器注入。一旦存在循环依赖启动阶段立刻暴露不会拖到运行期。禁止Service之间直接new对方。这种肉眼可见的强耦合不仅破坏Spring管理也让单元测试寸步难行。模块依赖方向必须单向。如果出现跨模块调用只能上层依赖下层不能反向。接口和实现分离。跨模块依赖尽量面向接口避免一个模块直接引用另一个模块的具体类。CR时用IDEA依赖图插件扫一遍依赖关系凡是发现环当场讨论职责归属而不是当场加注解绕过。这里的核心原则是把循环依赖当成一种“设计红牌”而不是一桩可以靠某个注解豁免的小罪。看到环第一反应应该是“这两个类不该互相认识”而不是“我应该让Spring怎么圆场”。5.4 常见问题速查现象可能原因处理方式启动抛BeanCurrentlyInCreationException构造器注入循环加Lazy或重构Spring Boot 2.6启动报循环依赖默认关闭循环依赖重构优先或临时配置允许循环引用启动不报错但异步方法变成同步AOP代理与循环依赖叠加消除循环依赖不要试图绕过字段注入的循环不报错Spring三级缓存兜底应主动重构别把兜底当正常容器里A的实例和B持有的A不是同一个早期引用与最终代理不一致确认代理逻辑或拆掉循环最后说点我自己的习惯。现在随手写代码基本默认构造器注入真遇到循环依赖第一步不是加注解而是打开启动日志把箭头链对应到代码结构里去问一句这两个类真的应该互相知道吗大部分时候答案是不应该。Lazy和allow-circular-referencestrue这两个开关我都用过它们能让你今天顺利上线但欠下的债早晚会在某个奇怪时机还回来。处理循环依赖最省事的方式还是一开始就把依赖方向画清楚。
阅读完成 · 觉得有帮助?