Spring Boot循环依赖深度解析:三级缓存、构造器注入与重构思路

发布时间:2026/10/9 9:14:39
Spring Boot循环依赖深度解析:三级缓存、构造器注入与重构思路 如果你在启动Spring Boot项目时见过这么一行报错——The dependencies of some of the beans in the application context form a cycle后面跟着一串-箭头链比如orderService - userService - orderService那你大概率和我一样当场脑子是懵的。我第一次踩这个坑是在一个商城服务改版的时候原本代码跑得好好的我只是把两个Service之间的调用方式理顺了一下顺手把字段注入改成了构造器注入结果启动直接崩掉。控制台那一长串红色堆栈我盯了快二十分钟才反应过来——这不是什么配置写错了而是Spring容器在创建Bean的过程中遇到了互相引用、谁都等不到谁的“死循环”。这篇文章我会把循环依赖整个链路讲透它到底怎么产生的、Spring引以为傲的“三级缓存”为什么能解决一部分场景、构造器注入为什么永远救不回来、以及Spring Boot 2.6版本之后官方为什么默认把循环依赖直接禁掉。文章最后会给出我在实际项目中落地过的几种重构思路而不是单纯教你在配置文件里开一个开关掩盖问题。无论你是刚学Spring Boot、遇到报错一头雾水的初学者还是被生产环境循环依赖折磨过头疼的老手这篇都值得你完整看完。1. 循环依赖的本质两个Bean互相等待谁都创建不完1.1 先搞清楚Spring容器创建Bean的顺序要理解循环依赖必须先明白Spring的Bean不是“一次性全部造好”而是一个一个按需创建、按依赖关系依次完成的。容器扫描到Service、Component这类注解后会把它们注册成BeanDefinition然后根据依赖关系逐个实例化并完成属性填充。假设有一个最简单的场景OrderService需要注入UserServiceUserService需要注入OrderService这两个类互相持有对方的引用这在业务代码里太常见了。比如订单服务要查用户信息用户服务要查订单数量两边一拍即合写成了互相调用。表面上代码编译没有任何问题但Spring容器在启动时就开始尴尬了先创建OrderService发现需要UserService于是转头去创建UserService结果发现UserService又需要OrderService而此时OrderService还正在创建中没有成品可用。这种情况在源码里会被判定为循环引用Spring不会傻傻地无限递归下去而是直接抛出BeanCurrentlyInCreationException提示你形成了cycle。1.2 一个必现报错的Demo代码用构造器注入写一下就是最典型的必现案例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 Boot启动时控制台会抛出类似这样的信息*************************** APPLICATION FAILED TO START *************************** Description: The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderService ↑ ↓ | userService └─────┘看到这个箭头结构基本可以确定是循环依赖没跑了。注意一个细节报错发生在“启动阶段”而不是在运行时调用方法才报。这意味着只要两个Bean构成循环引用项目根本连启动的机会都没有。1.3 为什么业务代码层面容易踩坑循环依赖很少是刻意设计的更多是重构过程中“长”出来的。我见过几种典型情况Service层之间交叉调用越来越深A调BB又因为某个新需求调A没人意识到已经形成回环一个方法从Service A搬到Service B但调用方没有跟着调整依赖方向失控团队为了图省事直接在两个Service里互相注入对方的Repository慢慢变成互相依赖说到底循环依赖是一种代码坏味道的信号它告诉你组件之间的依赖边界已经模糊了需要重新审视职责划分。2. 三级缓存机制Spring为什么能自动化解部分循环依赖2.1 三个缓存各管什么Spring的DefaultSingletonBeanRegistry里维护了三层缓存专门用来处理单例Bean的提前暴露问题。它们长这样缓存名称数据结构存放对象状态singletonObjectsMapString, Object完全初始化完成的Bean成品earlySingletonObjectsMapString, Object提前暴露的半成品Bean引用半成品singletonFactoriesMapString, ObjectFactory?生成提前暴露Bean的工厂工厂第一层放的是最终成品所有正常初始化的Bean最后都会进到这里。第二层放的是“还没完全初始化但已经暴露出去的引用”这个引用可以被其他Bean注入使用。第三层存的是ObjectFactory它不是为了拿到对象本身而是为了在“被需要的那一刻”生成一个提前暴露的Bean引用。一句话记法第一层管成品第二层管半成品第三层管怎么临时造半成品。2.2 三级缓存处理循环依赖的完整流程现在我们把刚才OrderService和UserService互相依赖的例子代入三级缓存走一遍整个创建时间线容器开始创建OrderService发现一级缓存里没有于是调用createBeanInstance实例化出一个OrderService对象——注意此时对象刚new出来属性还是空的将这个刚new出来的对象包装成ObjectFactory放进三级缓存singletonFactorieskey是“orderService”开始填充属性发现需要注入UserService容器转去创建UserService同样先new出一个空对象放进三级缓存填充UserService的属性时发现需要注入OrderService容器执行getSingleton(orderService, true)先去一级缓存找没有发现OrderService正处于创建中于是去二级缓存找还是没有三级缓存里有取出ObjectFactory并调用它的getObject()方法拿到OrderService的提前引用把提前引用放入二级缓存earlySingletonObjects同时从三级缓存移除UserService拿到OrderService的引用完成属性填充走完后续初始化流程变成一个成品放进一级缓存回到OrderService的属性填充阶段这次很顺利地从一级缓存拿到底UserService成品完成自己的初始化最终两个Bean都进入一级缓存核心点在于第6步取得“提前引用”时OrderService的构造器已经执行完了只是业务字段还没全被填上。而字段注入和setter注入都发生在对象实例化之后所以这种半成品引用对它们来说完全够用。2.3 为什么三级缓存能解决的一大前提是“实例化已完成”这个前提极其重要。UserService在第6步拿到的OrderService只是一个已经new出来、但属性还大多是null的对象。它虽然在内存中真实存在可用性却有限。如果OrderService的构造器还没执行完那连这个引用都不可能存在因为对象都还没造出来。所以Spring官方文档里的结论是构造器注入的循环依赖无法被三级缓存解决。原因很简单构造器参数必须在实例化之前确定而三级缓存的触发时机在实例化之后。这个区别直接决定了我们在第3节要做的实测结论。3. 实测三种常见场景下循环依赖到底能不能启动3.1 场景一Autowired字段注入代码改成这样Service public class OrderService { Autowired private UserService userService; }Service public class UserService { Autowired private OrderService orderService; }在Spring Boot 2.5及更早版本里这个配置启动完全没问题靠的就是三级缓存机制。但如果你用的是Spring Boot 2.6或更高版本这里会直接启动失败因为2.6版本开始默认禁止循环引用。具体后面第4节细说这里先记住结论字段注入本身是支持循环依赖的只是新版Spring Boot默认把门焊死了。3.2 场景二构造器注入永远跑不通这是最容易让人崩溃的场景因为它的报错和版本无关。就算你把allow-circular-references开关打开构造器注入的循环依赖依然会失败。原因就是2.3节说到的构造器执行之前对象还不存在三级缓存根本派不上用场。面对这种局面最常见也最实用的临时处理手段是给其中一方的构造器参数加上LazyService public class OrderService { private final UserService userService; public OrderService(Lazy UserService userService) { this.userService userService; } }加上Lazy之后Spring在创建OrderService时不会再急着去拿到完整的UserService实例而是注入一个UserService的代理对象。真正要调用UserService的方法时代理对象才去触发实际的初始化。这样就从时间维度上切断了循环的紧耦合。不过说句实在话Lazy治标不治本它会延迟初始化还会让启动阶段的某些问题被掩盖到运行时才暴露后面我会详细讲为什么它不是首选方案。3.3 场景三AOP代理对象参与循环依赖这是三级缓存真正发挥全部威力的地方。假设UserService里有一个Transactional方法Spring在创建它时会生成一个代理对象。如果OrderService在填充属性的时候拿到了UserService的原始对象那事务增强就全都白搭了。为了应对这种情况三级缓存里存放的不是直接的对象引用而是ObjectFactory。在循环依赖触发提前暴露时ObjectFactory.getObject()会走到SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference这一步。如果这个Bean需要被代理在这里就会返回代理对象不需要代理就返回原始对象。这就是为什么Spring要设计第三层而不是两层。假设只有一级缓存和二级缓存那提前暴露的对象只能放原始引用代理就来不及织入。有了ObjectFactory这一层可以在真正暴露的那一刻把“该生成代理的生成代理、该返回原对象的返回原对象”这个决策延迟到最合适的时机。实际开发中一个很容易被忽略的坑当UserService在循环依赖过程中被提前暴露了代理引用后续UserService内部方法自调用时走的可能是代理对象里的逻辑也可能走原始对象里的逻辑这取决于具体的切面实现。用Async、Transactional这类注解时尤其要小心别以为启动能过就万事大吉。4. Spring Boot 2.6之后官方默认禁止循环依赖是矫枉过正吗4.1 版本升级后“全军覆没”的真实场景Spring Boot 2.6的发布说明里有一条非常醒目的变更spring.main.allow-circular-references默认值改成了false也就是说循环依赖从“默认允许”变成了“默认禁止”。记得我们项目从Spring Boot 2.3升到2.6的那次测试环境一启动直接冒出一堆循环依赖报错。当时团队群里瞬间炸了锅好几个人跑过来问“是不是升级升坏了”实际上代码一行没改只是新版本的默认行为变了。以前靠字段注入悄悄维持的循环依赖全部现出原形。这个决定的背景是Spring团队希望大家正视循环依赖的坏味道。允许循环依赖会让Bean之间的调用关系变得晦涩也让未来重构的难度成倍增加。官方宁可让你启动失败也不希望你在错误的路上越走越远。4.2 开关到底在哪里调如果你暂时接手的是老项目改动成本高必须立刻上线那临时放开循环依赖限制是现实的选择。配置方式很简单application.properties里加一行spring.main.allow-circular-referencestrue如果用YAML配置spring: main: allow-circular-references: true也可以通过Java代码设置SpringApplication app new SpringApplication(MyApplication.class); app.setAllowCircularReferences(true); app.run(args);开了这个开关之后字段注入和setter注入的循环依赖重新回到Spring Boot 2.6以前的行为能正常启动。但注意构造器注入照样还是失败这跟开关没有关系。4.3 我的态度这个开关能不开就别开我见过很多团队遇到循环依赖报错第一反应就是甩一行配置上去。说实话作为临时应急手段可以理解但我强烈不建议把它当成长期解决方案理由有三第一循环依赖是业务模块之间职责边界模糊的警报。你打开开关警报是没了问题还在而且后续新成员还会照着这种模式继续写依赖只会越来越乱。第二Spring团队已经明确表态不推荐循环依赖保不齐后续某个版本再把相关支持整个删掉。届时不光配置会失效整个应用可能都要推到重来。第三从排障角度说循环依赖隐藏得越深出问题的时候越难查。与其等它在生产环境变成玄学问题不如现在就把依赖结构捋清楚。我在实际项目中的原则是这样的能重构就立刻重构短时间内确实动不了的先开开关保证业务上线同时把改造任务列入技术债清单定好日期清理掉。5. 比打补丁更靠谱的思路从结构上拆掉循环5.1 先追问一句这段业务真的需要双向调用吗处理任何循环依赖第一步不是去百度怎么配置而是把两个类之间的调用关系画出来逐个问“这个调用真的应该由对方提供吗”。很多时候我们会发现A依赖B的方法是因为数据归属放错了位置或者两个领域概念被不恰当地塞进了同一个Service。举个例子OrderService需要UserService.getUserName()来拼订单详情UserService需要OrderService.getOrderCount()来算用户总单量。乍一看这俩就是典型的双向调用但仔细分析会发现getOrderCount()本质上是“订单维度”的统计查询它压根不应该待在OrderService里更合理的归宿是一个独立的订单统计服务。5.2 拆分公共依赖把循环的两个方向掰成单向把上面那个例子重构一下新建一个OrderStatisticService专门负责订单统计Service public class OrderStatisticService { public long getOrderCountByUserId(Long userId) { // 独立查询订单表统计数量 return 100L; } }然后让依赖关系变成OrderService依赖UserService要用户姓名UserService依赖OrderStatisticService要订单统计OrderService不再依赖UserService的统计能力原先的OrderService和UserService之间不再互相引用循环被彻底拆掉。改造之后不仅循环依赖消失类的职责边界也更清楚。这种抽取不一定非要新建类也可以把公共能力下沉到已有但更底层的组件里总之原则只有一个依赖方向的箭头必须保持单向流动。5.3Lazy作为兜底方案可以但别当主力极少数场景下业务确实绕不开两个模块的互相引用比如两个领域服务在业务状态流转时需要互相感知对方的状态而这种耦合短期内又没有更好的抽象方式。这种时候用Lazy是合理的兜底选择。但你必须清楚它的代价代理对象的引入会让一些Bean的真实初始化时机推迟到第一次调用时如果初始化过程本身依赖其他Bean很可能在业务方法执行中突然触发连锁初始化导致你的接口第一次调用特别慢。而且一旦初始化抛异常报错位置会离真正的根因非常远排查难度直线上升。5.4 长期视角让依赖方向始终向“下”流动从更大的设计视角看循环依赖经常是分层混乱的副产品。一个标准的依赖关系应该是单向向下的Controller依赖ServiceService依赖Mapper或外部网关而不是平级Service之间来回横跳。如果你在一个Service里发现它要依赖同层级的另外四五个Service先别急着把循环依赖解决掉反而该想想是不是业务逻辑被过度集中在这个Service里了。我现在的习惯是在项目里做Code Review时专门留心几个信号构造器参数超过五六个、同事新建的类之间有互相引用的倾向、以及某个Service的 import 列表里出现大量同包下的类。出现这些信号我基本都会停下来掰一下依赖结构而不是等到启动失败再补救。循环依赖最好的解决方案永远是根本不形成循环。聊到这儿想起自己去年深夜排查那个启动报错的场景心里其实挺感慨的。当时我满脑子都是“怎么让项目赶紧启动起来”后来才意识到真正值得花时间的不是处理报错本身而是把依赖结构重新想清楚。如果你现在也被循环依赖卡住我的建议很简单先用Lazy或者临时开关把手头的活保住然后务必排个时间把依赖抽开。不要迷信三级缓存能解决所有问题更不要觉得开个配置就一劳永逸技术的账早晚是要还的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询