1. 先看它为什么炸了再谈怎么解决循环依赖这个问题凡是做后端开发、写了几年Spring项目的基本都绕不开。可能你还没被它坑过但只要你有朝一日接手一个堆了几年业务、类之间你拽我我扯你的老项目或者自己写代码时图省事直接在两个Service里互相注入那你就一定会遇上那个熟悉的报错Description: The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderService defined in file ... └─────┘很多新手看到这个心就慌百度一搜“Spring Boot解决循环依赖”出来的帖子五花八门有的让你加Lazy有的让你改构造器注入有的甚至让你去把Spring的循环依赖开关给打开——对的新版Spring Boot默认把循环依赖支持给关了。看完更懵。这篇文章我不打算只丢几个解法让你抄我更想把循环依赖这件事从头到尾讲透它到底是什么、Spring底层是怎么处理的、什么场景能解、什么场景无解、为什么新版本Spring Boot默认禁止、你遇到的时候应该怎么排查和决策。这些东西搞明白了以后你遇到循环依赖就不慌而且你能判断哪种解法符合长期利益哪种只是勉强糊口。适合谁来读写Spring Boot服务端代码、遇到Bean创建报错、或者在看代码时好奇Spring容器到底怎么管理Bean生命周期的都可以看。不需要你有源码阅读基础我会尽量用大白话把机制讲清楚。2. 拆开循环依赖这个烫手山芋它到底想干嘛2.1 一个最朴素的理解循环依赖说白了就是A类里面需要一个B类B类里面又需要一个A类。代码长这样Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } }Service public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService orderService; } }这个代码一启动就会报错。为什么因为Spring要先创建OrderService发现它构造器需要UserService于是去创建UserService。创建UserService的时候又发现它构造器需要OrderService于是回头去找OrderService——结果OrderService还只是个半成品构造器都没执行完没法给UserService用。于是死锁了。这个场景有点像两个人互相等着对方先出门你不走我就不走我不走你也不走结果俩人在门口耗到天黑。Spring一看这情况直接就抛出循环依赖异常不陪你耗着。2.2 为什么循环依赖是个“设计警报”先别急着骂Spring不给你机会。我想先说一句可能不太中听的话大部分循环依赖本质上都是设计层面埋下的雷。类与类之间为了互相协作而双向依赖说明它们的职责边界划得不清晰。拿上面的例子来说OrderService和UserService真的必须互相知道对方的存在吗是不是可以把公共逻辑抽到第三个组件里是不是可以通过事件机制解耦是不是只需要其中一个单向调用只是代码结构没梳理好所以我个人经验里处理循环依赖的第一步永远不是“怎么让Spring不报错”而是“先想想这里是不是代码设计有问题”。如果设计确实没问题绑定确实需要双向那再来看Spring能怎么救。识别设计警报最简单的方式画出依赖图看两个类之间是不是有回环。如果只是一条单向依赖比如OrderService → UserService那很干净。一旦出现OrderService → UserService → OrderService这种回环就要警惕了——这个回环里往往藏着哪个类做了不该它做的事。2.3 Spring Boot新版本为什么默认关掉循环依赖这里有个非常关键的背景。Spring Boot 2.6版本开始默认禁止了循环依赖。也就是说即使你的代码真有循环依赖而且理论上Spring能帮你解决Spring Boot也会直接报错不给你解决的机会。Spring这么做是有苦衷的。Spring框架本身支持循环依赖解决是历史遗留功能它带来了一系列复杂性和隐性成本。比如循环依赖下Bean的生命周期不再标准某些Bean会被提前实例化导致AOP代理、事件监听等行为出现诡异时序。开发者一旦依赖循环依赖代码质量容易滑坡反正怎么乱写都能启动大家就不愿意花心思做设计了。未来Spring团队想优化容器启动性能和实例化流程时循环依赖支持是很大的阻碍。所以新版本Spring Boot用一刀切的方式把这个口子堵上。如果你用的是Spring Boot 2.6你在配置里能看到一个专门的开关spring: main: allow-circular-references: true把这个开关打开循环依赖又能跑起来了。但我建议你把这段配置当成最后的逃生通道而不是第一反应。为什么因为它关闭了Spring Boot给你的安全检查你的应用会回到一个“依赖循环也能跑”的宽松状态里过一阵子循环依赖像野草一样长出来最终欠下的技术债会越来越多。3. Spring源码带你走一遍三级缓存为什么它能解一部分3.1 初始化和依赖注入的微妙时序要理解Spring为什么能解一部分循环依赖关键就一句话Spring管理Bean时实例化new出来和初始化填充属性、执行初始化方法不是一步完成的而是分两步走的。结合我刚才说的A构造器依赖B这种场景Spring就没了辙。原因很简单A在构造器阶段就必须要B存在构造都没完成A的半成品都不存在拿什么给B用但如果依赖不是通过构造器而是通过字段注入或setter注入情况就完全不一样了。看个例子Service public class OrderService { Autowired private UserService userService; }Service public class UserService { Autowired private OrderService orderService; }同样是双向依赖但这次是字段注入。Spring创建OrderService时先执行构造器把OrderService new出来。虽然此时OrderService里的userService还是null但OrderService这个实例已经真实存在了。Spring一看OrderService已经有一个“半成品实例”了于是先把它的引用放到缓存里然后继续去创建UserService。创建UserService时它需要OrderServiceSpring从缓存里拿到了那个半成品OrderService把引用塞给UserService。UserService初始化完成后Spring回头再把UserService的引用塞给OrderService。两边都补全了流程走完。这个操作像什么呢就像你装修房子先让水电工进场把管线铺一半然后把钥匙交给木工进场干活木工干完活再让水电工回来补齐剩下的。只要工人不互相等对方彻底完工才进场项目就能推进。3.2 第三级缓存奥妙为什么不是两级而是三级Spring解决这个问题的核心机制叫“三级缓存”。这个名词你可能看过很多遍但很多人没真正弄懂为什么非要三级甚至很多人把三级缓存背下来就觉得自己懂了面试时一问细节就露馅。三级缓存本质上是三个Map在Spring的框架源码里都挂在单例Bean注册表名称叫DefaultSingletonBeanRegistry上缓存层级存储内容用途第一级 singletonObjects已完全初始化好的单例Bean平时getBean拿到的就是这里面的第二级 earlySingletonObjects提前暴露的、尚未完成属性填充的早期Bean解决“拿到半成品引用”的问题第三级 singletonFactories早期Bean的对象工厂解决“半成品Bean需要做AOP代理”的问题一级缓存好理解就是最终的成品仓库。二级缓存是什么呢你想啊如果只有三级缓存工厂每次获取早期Bean都要执行工厂创建创建了多少次就得到多少个代理对象那Singleton就不单例了。所以得有个地方把提前暴露的earlyBean存起来保证同一个Bean在循环依赖过程中拿到的是同一个引用。这就是二级缓存的意义。但问题来了既然二级缓存都能拿到早期Bean了为什么还要设计第三级如果在Bean刚实例化完成时就直接把原始对象放进二级缓存那也能实现“提前暴露”的效果啊。答案是AOP代理。假设OrderService被事务切面代理了正常情况下Spring会创建一个代理对象作为最终的Bean。如果循环依赖发生时UserService拿到的只是OrderService的原始对象而不是代理对象那UserService里持有的OrderService就不会有事务能力。等到OrderService完成初始化后一级缓存里的Bean换成了代理对象UserService手里的引用还是旧原始对象事务就失效了。为了避免这种问题三级缓存的工厂会在“有人需要提前引用”的时候执行AOP逻辑生成代理对象。简单说正常流程下代理在Bean初始化完成后才创建循环依赖场景下代理在有人提前拿引用时就得创建否则代理就白装了。3.3 三级缓存的完整运作流程我按照代码执行顺序给你串一遍创建OrderService实例化得到原始对象。向三级缓存放入一个工厂这个工厂能生产OrderService的早期引用必要时生成代理。开始填充OrderService属性发现需要UserService。创建UserService实例化得到原始对象向三级缓存放入UserService工厂。填充UserService属性发现需要OrderService。调用getBean(orderService)一级缓存没有二级缓存没有三级缓存有工厂执行工厂拿到早期OrderService引用。如果此时OrderService需要代理工厂会返回代理对象并把它存入二级缓存下次直接复用。UserService拿到OrderService引用属性填充完成UserService完成初始化进入一级缓存。回到OrderService的流程填充userService属性OrderService完成初始化进入一级缓存。这里有个细节值得注意从三级缓存拿到早期引用后这个引用会被移到二级缓存。一级缓存里始终没有半成品只有完成初始化的成品。那第三级缓存同时可以判断另一种场景如果getBean的Bean一直被循环依赖提前引用Spring不知道它是否还在初始化中所以二级缓存只是“中转”。整个三级缓存的设计核心是既要保证单例又要保证代理不被破坏还要保证初始化时序可控。这套机制很精巧但也不是万能的——它只对“单例Bean 非构造器注入”的组合有效。构造器注入根本没法用三级缓存因为Bean连原始对象都还没有你没法提前暴露一个不存在的实例。原型作用域prototype也没法解决循环依赖每次getBean都是新实例缓存没有任何意义。4. 实操现场哪些场景能解哪些场景注定无解4.1 能解的字段注入与setter注入在实际业务代码里最常见的循环依赖就是两个Service类互相Autowired。这种场景Spring完全能处理前提是你用的是Spring Boot 2.6之前的版本或者新版本里手动开启了allow-circular-references。我用自己的一个模拟项目X验证过场景很简单一个支付流程的服务和订单服务之间有双向调用的需要几个同事图省事直接互相Autowired。开发者本地用的还是旧版Spring Boot启动一切正常直到把代码合到新版本Spring Boot环境里才炸了。这就是新版本默认关闭带来冲击的典型例子。setter注入同理因为setter注入也是“先实例化后赋值”期间有窗口期可以提前暴露。这里放一个setter注入的例子Service public class PaymentService { private OrderService orderService; Autowired public void setOrderService(OrderService orderService) { this.orderService orderService; } }这种写法虽然能用但如果你去看现在主流Spring官方推荐无论是构造函数注入还是字段注入日常项目里其实大量仍是字段注入。我的态度是字段注入在业务代码里没问题用起来方便但你要是想规避循环依赖的坑写新代码时尽量用构造器注入。构造器注入的优点很明显依赖关系在编译期就能看出问题Bean被new的时候就必须把依赖凑齐不会拖泥带水。哪有循环依赖启动时立刻炸出来逼着你早点解决。4.2 解不了的构造器注入、prototype作用域先说构造器注入。如果你写了两个类构造器互相依赖那无论你是否开启循环依赖开关Spring都没法帮你直接报错。这种场景下的报错信息特别有辨识度BeanCurrentlyInCreationException: Error creating bean with name xxx: Requested bean is currently in creation: Is there an unresolvable circular reference?为什么无解因为构造器是一个Bean的“出生证”出生之前什么缓存都救不了。就像你还没出生就开始要求别人给你签字盖章对方找不到你的人。那constructor注入真的一点回旋余地都没有吗不你可以在构造器参数上使用Lazy这个后面细说。prototype作用域就更特殊了Spring官方文档明确说“循环依赖在prototype作用域下无法解决容器会直接抛出异常。”因为Spring不为prototype缓存实例每个getBean都新建提前暴露根本没有意义。你要是真在一个prototype的Bean和另一个单例Bean之间搞出了循环依赖那基本就是设计跑偏了建议回炉重做。4.3 莫名其妙的情况AOP代理引发的循环依赖排查还有一类场景让很多人懵圈就是代码里看不出循环依赖但启动时莫名报循环依赖错误。这种“隐性循环依赖”常出现在涉及AOP代理的情况下。比如一个Service被Transactional管理同时它又依赖了另一个会触发它代理创建的Bean又比如两个Service都用Async注解彼此调用异步代理在初始化时也会提前产生代理对象进而干扰Bean创建顺序。在我之前的实操中遇到过一个最经典的坑在一个带Async的方法所在类里注入了另一个Service那个Service又反向注入了这个类。如果你不开循环依赖开关报错异常开了之后表面能启动但注入进去的对象根本不是代理对象而是原始对象于是直接调用Async方法时异步失效。这个场景排查非常费时间因为你要同时懂三级缓存和AOP代理时序两套机制。5. 动手解决不靠开关的四套正经方案5.1 方案一重构——把循环依赖拆成单向依赖我始终把这个方案放在第一位因为这是唯一一个长远看百利而无一害的方案。怎么拆最常见的思路是找出双向依赖中“打破了对称性”的那部分逻辑把它抽出来放到一个新类里。比如A依赖B的方法xB依赖A的方法y。你可以把方法x和方法y都抽到一个新类C里A和B各自依赖CA和B之间的纠缠自然消失。还是拿订单和用户举例子如果OrderService需要调用UserService获取用户信息UserService需要调用OrderService获取订单统计那不如新建一个OrderUserFacade服务把跨模块的编排逻辑放在Facade里OrderService和UserService各自只关心自己的领域逻辑。这不但解决循环依赖还让职责边界更清晰。代价是你要动代码结构可能要动接口。很多团队工期紧不愿意走这条路。但我的建议是新建的代码如果遇到循环依赖优先重构别偷懒老代码改不动的话再考虑下面的应急方案。5.2 方案二Lazy——延迟注入打破初始化死锁Lazy是Spring提供的一个注解放在注入点上能让Spring推迟依赖Bean的创建。比如构造器注入场景下你可以这样写Service public class OrderService { private final UserService userService; public OrderService(Lazy UserService userService) { this.userService userService; } }这里加了Lazy之后Spring创建OrderService时不会立即创建UserService而是注入一个UserService的代理对象。等OrderService真正调用UserService的方法时代理对象才去触发UserService的真实创建。这个方案的优点改动极小只加一个注解能解决构造器注入的循环依赖。它也有代价多了一层代理调用路径长了那么一点点如果UserService的真实创建时机比你预期得晚可能导致第一调用时性能抖动。而且因为注入的是代理排查问题时看堆栈会多出CGLIB代理的调用链初次接触会觉得很奇怪。Lazy平时还可以用在初始化耗时长、希望延迟到首次真正调用的依赖上。但别忘了Lazy解决的是“创建顺序”问题没有消除“依赖回环”本身。如果项目里有洁癖的同事审查代码看到你哪哪都加Lazy你的代码可读性是会打折扣的。5.3 方案三DependsOn——控制Bean创建顺序Spring提供DependsOn注解可以显式指定某个Bean在另一个Bean之前创建。它的语义是被标注的Bean依赖于指定的BeanSpring容器会先创建被依赖的那个。Service DependsOn(userService) public class OrderService { // ... }这个注解在什么场景下有用呢比如你的循环依赖其实只是因为Bean初始化顺序不对造成的而不是真正的“运行时双向绑定”。举个例子OrderService初始化时需要读取UserService里某个缓存数据但UserService也要在初始化时往OrderService里注册回调。只要你调整创建顺序让UserService先创建问题就迎刃而解。但要注意DependsOn不解决注入意义上真正的双向依赖它解决的只是“创建顺序”上的前后关系。如果两个Bean在运行时确实需要互相调用光是调顺序没用。还有DependsOn需要你明确知道Bean在容器里的名字字符串的写法对重构不友好类名一改就得跟着改。5.4 方案四ApplicationContext手动获取——最后的兜底在某些老项目或者无法大改的场景下有人会用一个很“暴力”的办法不要在类初始化时注入对方而是等到方法执行时通过ApplicationContext手动getBeanComponent public class OrderService { Autowired private ApplicationContext applicationContext; public UserService getUserService() { return applicationContext.getBean(UserService.class); } }这样OrderService和UserService之间没有任何初始化期的依赖循环依赖自然就没了。这个方案有一定的使用场景比如处理历史遗留代码几个组件交错太深来不及做完整重构时用这个方式先把项目跑起来。但我要说清楚这是不是好实践不是。它把依赖关系从容器管理变成了运行时查找依赖关系变得隐晦测试也更难做。你每次调用getBean都要走一遍容器查找逻辑代码可读性变差。我一般只在“临时救火”时用救完火还是得想办法重构把它换回正常的注入方式。6. 我最常用的一套解决流程与真实踩坑记录6.1 判断顺序与决策清单当我在项目中遇到循环依赖异常我一般不直接去改代码而是按下面这个顺序判断第一步看报错日志中涉及的Bean列表理清是谁和谁互为依赖。如果这个回环只涉及两个Bean通常比较好处理如果日志里出现了三四个Bean串成环那说明依赖链更深得耐心梳理。第二步判断循环依赖的注入类型。构造器注入的直接上Lazy或者重构字段/setter注入的先看版本允不允许再看看要不要开启开关应急。注意看自己的Spring Boot版本2.6之前默认允许循环依赖2.6之后默认禁止。很多人升级Spring Boot版本后突然大面积报循环依赖就是版本策略变化引起的。第三步确认是否存在AOP代理参与。有Transactional、Async等切面时要特别注意即使开了循环依赖开关AOP代理时序也会带来额外麻烦。你最好看看有没有“AOP代理提前创建”的日志确认代理是否正常。第四步结合业务价值决策这是新写的代码还是老代码改动范围大不大如果这是刚写出来的循环依赖花半小时重构掉最值得如果是年久失修的老代码先加Lazy或开关救火后续再排期重构。6.2 一个实战排查过程记录我之前调试一个模拟项目X时遇到过这么个情况项目原本是Spring Boot 2.3一切正常。升级到Spring Boot 2.7之后启动直接报循环依赖。因为2.6开始默认禁止循环依赖日志里明确提示了一行Spring Boot 2.6.0 now disables circular references by default.我当时的处理流程是先看异常信息里涉及哪几个Bean发现是一个缓存服务和一个配置服务互相Autowired。我把两个类代码翻出来看发现它们的循环依赖其实很弱——缓存服务只是在配置服务初始化时需要读取一个配置项而配置服务也只需要缓存服务在启动完成后做一次刷新操作。本质上这就是初始化顺序问题。我没有急着加allow-circular-references开关而是选择了重构把配置项读取改成构造器入参传入缓存服务配置服务的刷新动作改成事件监听通过Spring的事件机制解耦。改完以后两个类不再互相依赖启动完全正常。这个案例最有价值的点是如果不是升级Spring Boot新版本强制暴露了这个循环依赖我们团队压根不会注意到这里埋着设计问题。所以说Spring Boot 2.6以后默认禁止循环依赖短时间看是增加了很多摩擦但长期看它逼着开发团队直面那些“能跑但跑得别扭”的代码。6.3 开启开关的负面教训说实话我也见过团队走捷径的结局。很早以前有一个内部对接系统一个资深开发图快把循环依赖开关直接打开代码里不去治理。刚开始没事后来这个系统里循环依赖越来越多很多类是互相Autowired改一个类牵扯另外三四个类。想重构已经没有勇气了一发动全身。后来模块里面Async还失效过排查了一整天才发现是循环依赖代理导致Bean不是代理对象。那次之后我下了个死规矩新写的代码一律构造器注入出现循环依赖必须先审视设计开关只能作为临时豁免且需要备注原因后面必须清账。7. 问题排查速查表与避坑指南7.1 常见场景对照表场景表现推荐处理构造器注入互相依赖启动报BeanCurrentlyInCreationExceptionLazy标注其一或重构拆分依赖字段/setter注入互相依赖启动报循环依赖旧版本新版本默认直接拒绝审视设计确实合理可开启开关但建议优先重构或LazySpring Boot 2.6升级后出现升级后突然启动失败检查是否有循环依赖处理而不是直接开开关涉及Transactional代理开启循环依赖后发现事务失效或代理异常优先避免循环依赖如需代理确保拿到代理对象涉及Async代理调用异步方法没生效直接在原线程执行检查注入对象是否为代理对象最好解法是彻底去掉循环依赖prototype Bean之间互相依赖启动报错Spring直接拒绝无解必须重构不能走缓存方案7.2 避坑指南与经验教训先说“开开关”这个事。它只要一行配置看起来成本最低但它带来的是长期隐患。如果你决定开那就一定把开发规范立起来比如代码评审时对新增的循环依赖零容忍否则你会发现三个月后项目里到处是这种“历史遗留”。然后说Lazy。它的隐藏问题是代理对象会让你的调试体验变差断点进不到目标类方法而先进到CGLIB代理。如果你定位问题时用IDE的“跳到实现”功能会跳到代理生成的字节码那体验真的酸爽。所以能用重构解决的就别贪Lazy省事。再补充一个很多人踩过的坑在循环依赖场景下如果你让Spring初始化A时去调用B的方法而B又需要A完全初始化后才能正常工作就会遇到“A的半成品被B用了”的问题。表现出来就是B某些方法里拿到A的属性是null或者调用A方法时NPE。这个从日志上很难跟循环依赖联系起来但本质就是Bean初始化顺序被打乱。我遇到这种问题会直接检查循环依赖链路把牵连的Bean先断掉而不是在NPE堆栈里瞎转。最后说一个实用工具技巧排查循环依赖时可以用Spring的ApplicationContext在启动时打印出所有Bean的依赖关系或者直接用可视化工具生成依赖图。但是实际上遇到复杂回环时最有效的方式还是靠“人类阅读代码”——把报错涉及的Bean类全部打开从构造器和字段上画出依赖箭头纸上推演一遍创建顺序很快就知道卡在哪了。7.3 我的开发规范建议我现在的做法是写进团队开发规范里的几条硬要求新代码禁止使用字段注入推荐构造器注入理由前面说过了编译期就能暴露循环依赖旧代码遇到循环依赖报错首次先分析设计能在半小时内重构的绝不加注解绕过去允许Lazy用于个别确有必要的延迟加载场景但代码注释里必须写清楚为什么加allow-circular-references开关属于“备案制”谁开谁在评审时解释原因并登记预期清理日期。另外我还会在小组内推广一个反模式检查每次新写一个类大家可以在IDE里看一眼依赖面板如果发现一个类依赖了太多同层级的Service那多半说明类职责太多需要拆。这个习惯坚持下来以后循环依赖自然就少多了。我个人在实际排查循环依赖时越来越觉得这问题像一个信号灯它亮起来的时候别想着把灯罩遮住而是该下车检查一下发动机了。Spring给了你Lazy、DependsOn、开关这些“遮罩”手段它们都有各自的位置但只有把类结构理顺了这个问题才算真正解决。遇到循环依赖先问自己一句“这两个类真的应该互相认识吗”很多时候答案是否定的。