资讯详情

资讯详情

Spring Boot循环依赖深度解析:三级缓存机制与工程实践解法

做Spring Boot开发循环依赖大概是每个团队都会撞上的经典问题之一。第一眼看到BeanCurrentlyInCreationException的时候那个“Requested bean is currently in creation”配合一串莫名其妙的依赖链日志很容易让人一头雾水。但本质上它不算复杂只是Spring在Bean创建顺序上遇到的一个死结你中有我我中有你谁都没法先完成初始化。这篇就来把循环依赖的前因后果、Spring底层的三级缓存机制、以及实际工程里最靠谱的几种解法讲透顺手把Spring Boot高版本默认禁用循环依赖这件事也一起捋清楚。1. 循环依赖它怎么发生的为什么启动会失败1.1 一个再典型不过的场景两个Service互相注入写业务代码的时候最常出现的就是A和B两个服务互相调用。比如一个订单服务需要查用户信息于是注入了用户服务用户服务里又有个方法需要判断用户最近是否有未完成订单于是反过来注入了订单服务。代码一写出来启动时直接炸了。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结果又发现需要OrderService。两个Bean都在等对方完成谁也没法先结束最终Spring抛出循环依赖异常。我在实际项目里见过更隐蔽的版本三个甚至四个Bean串成一个环比如A依赖B、B依赖C、C又依赖A。这种排查起来更头疼因为报错日志里的链可能跨度很长一眼看不出来源头在哪。1.2 底层原因Bean创建三个阶段之间的冲突要真正理解循环依赖得先记住Spring Bean的完整创建流程。一个单例Bean大致会经历实例化调用构造器new出对象、属性填充把Autowired、引用类型字段赋值、初始化执行PostConstruct、afterPropertiesSet等。构造器注入发生在实例化阶段字段和Setter注入发生在属性填充阶段。这看起来只是顺序差别却决定了循环依赖的“生死”。构造器注入一旦遇到循环依赖Spring还没法提前暴露半成品对象因为构造器不执行完对象根本不存在于是只能直接报错。而字段注入或Setter注入的时候对象已经new出来了Spring就有机会把这个“半成品”提前塞给对方双方就能踉踉跄跄地完成创建。很多人问为什么循环依赖在某些项目里能跑在另一些项目里一启动就挂多半就是栽在“构造器注入 vs 字段注入”这个差别上。1.3 循环依赖常见的三种产生渠道最直接的一种是双向引用A依赖B、B依赖A多见于Service层互相调用的业务。另一种是间接形成的“环”A依赖BB依赖CC又依赖A。比如某个缓存服务被多个Service依赖而它又反向依赖了其中一个Service来刷新数据。还有一种容易忽略的是通过工具类或上下文回调导致的嵌套创建比如在某个Bean的初始化逻辑里又主动调用了getBean去创建另一个正在创建中的Bean。无论哪种形态核心都是同一件事某个Bean在被创建的过程中又触发了对另一个“正在创建中”的Bean的获取请求。理解了这一点后面的三级缓存机制就顺理成章了。2. Spring的三级缓存到底是怎么把循环依赖救回来的2.1 三级缓存各司其职一张表看懂Spring容器在管理单例Bean时内部维护了三个重要的Map也就是常说的三级缓存。缓存层级内部名称存放内容作用一级缓存singletonObjects创建完成、属性填充和初始化都完成的完整Bean每次getBean优先查询这里二级缓存earlySingletonObjects提前暴露的半成品Bean对象已实例化但还未完成属性填充和初始化存放从三级缓存取出来的早期引用避免重复创建三级缓存singletonFactoriesObjectFactory函数式工厂可以生成早期引用对象真正决定“要不要提前暴露”的关键一步一级缓存是最终成果所有人日常Autowired拿到的都是这里的Bean。二级缓存是临时避难所专门放那些还在建设中的Bean。三级缓存最抽象它不是直接放对象而是放一个可以生成早期引用的工厂函数。2.2 从A和B的视角逐步走一遍完整流程假设现在有A和B两个单例BeanA依赖BB也依赖A。Spring解决循环依赖的流程可以拆成下面几步。第一步开始创建A通过构造器new出A的实例。此时A的字段还没赋值但对象已经存在了。Spring把A的ObjectFactory放进三级缓存key是A的Bean名称value是能返回A早期引用的工厂。第二步Spring开始给A做属性填充发现A需要B于是调用getBean(B)。第三步容器里没有B的成品于是开始创建B。同样实例化B之后把B的ObjectFactory放入三级缓存。第四步给B做属性填充发现B需要A调用getBean(A)。这时候一级缓存没有A二级缓存也没有A但三级缓存里能找到A的工厂。Spring调用这个工厂得到A的早期引用拿到后顺手放入二级缓存。第五步B拿到了A的引用虽然这个引用还是半成品但至少不是null了。B完成了属性填充走完初始化最后放入一级缓存。第六步B创建完成回到A的属性填充流程这时A需要B直接去一级缓存把B拿出来赋值给A的字段。第七步A完成属性填充接着执行初始化最后放入一级缓存。到这里整个循环依赖就“破”掉了。核心在于B需要的不是完整的A只是一个能被引用的早期对象。等A真正全部初始化完容器里所有引用它的地方拿到的仍然是同一个对象不会造成“A有两个实例”这种混乱。2.3 为什么必须三级缓存二级不够吗这个问题我在不少面试场合被问过也在实际改进代码时反复琢磨过。用一个具体场景就能说明白如果整个过程里没有AOP代理二级缓存就够了——直接把A的半成品放到二级缓存B来取的时候返回它就行。但问题在于如果A配置了AOP代理比如加了Transactional或者Aspect最终容器里期望存放的应该是一个代理对象而不是原始对象。假如只有二级缓存Spring只能在提前暴露A的时候就必须判断要不要生成代理。可是在创建A的早期阶段Spring还不确定这个Bean最终需不需要代理因为代理生成的触发点常常发生在初始化阶段的applyBeanPostProcessorsAfterInitialization里。如果提前就生成代理很可能造成代理时机混乱甚至重复代理。三级缓存里的ObjectFactory提供了延迟判断的空间。只有真的发生了循环依赖、B确实来提前获取A的时候才会执行真正取代理的getEarlyBeanReference。如果根本没有循环依赖这个工厂就永远不会被调用代理逻辑依然按照正常时序走一遍。这就是为什么三级缓存不是设计冗余而是为了兼顾“循环依赖的兜底”和“AOP代理的时机”两个问题。2.4 三级缓存解决不了的死角三级缓存虽然强大但也不是万能药。最典型的死角就是构造器注入前面已经解释了对象还没实例化没法提前暴露。还有一个是原型prototype作用域的Bean这类Bean根本不进缓存每次获取都是全新创建完全没有“提前暴露”这个概念遇到循环依赖直接失败。另外通过Async开启异步代理的Bean在循环依赖场景下也可能出现异常因为代理对象和原始对象之间的关系更复杂二级缓存兜不住。这些死角不是Spring的bug而是设计上主动留下的边界。与其想尽办法绕过不如在写代码的时候就警惕这些场景。3. 治本方案先重构再考虑编程解法3.1 循环依赖本身就是一个设计信号说实话代码里面出现循环依赖八成意味着设计上有耦合过深的嫌疑。两个Service互相调用往往说明它们各自的职责边界没有划分干净。OrderService非要了解用户的订单统计UserService又要反向查询订单数据业务上看起来“合理”但本质上两个模块已经变成一团泥。我个人的习惯是遇到循环依赖第一反应不是去改Spring配置而是先画一张依赖图把A和B之间的关系拆开看。很多时候只要把公共逻辑抽到第三层环就断了。比如可以把“订单价格计算”和“用户订单状态”抽到一个公共查询服务里让A和B都依赖这个公共服务双方不再互指。还有一种做法是引入事件机制。A做完某个操作后通过ApplicationEventPublisher发布一个订单事件B监听这个事件做后续处理。这样A和B之间从“直接调用”变成了“发布和订阅”依赖方向彻底解除代码反而更符合业务边界。3.2 构造器注入与Lazy的搭配玩法如果代码一时半会儿没法重构必须今天就把启动问题解决那最推荐的是在构造器注入上使用Lazy。例如Service public class OrderService { private final UserService userService; public OrderService(Lazy UserService userService) { this.userService userService; } }这个注解的本质是告诉Spring现在先别给我创建UserService给我一个代理对象占位等到运行时真正调用userService.someMethod()的时候再从容器里取。这样就绕开了创建期的强依赖构造器注入的安全性也保住了。但要注意Lazy并不是没有代价。它引入了一层代理每次调用比直接引用多一道转发。如果是高频调用的核心链路性能损耗虽然极小积少成多也让人不舒服。而且一旦出现序列化场景代理对象的处理会带来额外复杂度。所以我只会把Lazy用在“调用频率不高、但确实有依赖关系”的Bean上。3.3 Setter注入和Field注入能用但别当默认选项很多人会问字段注入是不是天生就支持循环依赖从机制上讲确实是的因为Spring可以在对象实例化后、属性填充阶段把半成品引用塞进来。我在早期项目里也见过大量纯字段注入的代码启动倒是没有问题可一旦想写单元测试或者想强制某个依赖不能为空字段注入就非常被动。更关键的是Spring Boot 2.6之后默认禁止循环依赖。就算你用了字段注入启动时一样会提示循环依赖错误必须手动开启spring.main.allow-circular-referencestrue才能恢复原来的行为。这个开关我一般不建议开它会掩盖真实的设计问题而且在高版本里官方已经明确表态“循环依赖默认是反模式”。如果确实遇到历史遗留项目需要快速修复我会优先把其中一个注入点改成Lazy而不是盲目开全局开关。全局开关影响的是整个容器代价太大。3.4 其他辅助手段和它们的适用边界ObjectProviderT延迟获取依赖适用于“有就用、没有也能跑”的弱依赖场景但无法解决真正必须双向调用的循环依赖。DependsOn控制创建顺序只对“Bean本身不互相注值、只是希望某个Bean先创建”的场景有帮助对循环依赖本身基本无效反而可能引发新的创建顺序错误。提取接口并使用接口注入有时候A依赖的是一个接口B依赖的也是一个接口只要实现类不再互相直接引用循环依赖就可能从构造层面解除。这些手段都不是银弹核心还是那句老话能重构优先重构重构不了才用技术手段规避。4. 实操全过程从复现报错到落地解决4.1 准备一个最小可复现项目我先用Spring Boot搭建了一个最小项目依赖只引了spring-boot-starter-web。因为Spring Boot 2.6开始默认禁止循环依赖所以直接用2.7或者3.x版本启动就能看到异常。项目结构非常简单两个Service、一个Controller以及一个验证用接口。先写两个互相依赖的Service用构造器注入Service public class InventoryService { private final ProductService productService; public InventoryService(ProductService productService) { this.productService productService; } public String checkStock(String skuId) { return 库存充足商品信息 productService.getProductName(skuId); } } Service public class ProductService { private final InventoryService inventoryService; public ProductService(InventoryService inventoryService) { this.inventoryService inventoryService; } public String getProductName(String skuId) { return 商品- skuId; } }启动之后控制台会抛出一长串内容。最显眼的一句是The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | inventoryService ↑ ↓ | productService └─────┘然后跟着BeanCurrentlyInCreationException以及Requested bean is currently in creation: Is there an unresolvable circular reference?这样的提示。这句“currently in creation”就是核心信号某个Bean正在被创建却又被另一个Bean反向请求。4.2 三步快速定位循环链路实际项目里的依赖链往往比两个Service复杂但定位方法可以固定成三步。第一步看异常日志最前面的The dependencies of some of the beans...form a cycle片段里面通常有ASCII画的依赖环一眼能看到哪些Bean参与了循环。第二步对照Spring MVC分层找出这些Bean分别属于Controller、Service还是Repository层确认哪一层出现了跨层反向依赖。第三步逐个打开提示的Bean定义看构造器参数或字段注入点找到依赖关系最强的那个位置这就是改造的切入点。我处理过的一个真实案例日志里显示的循环链是A→B→C→A但实际业务里A只依赖C的某个低频方法没必要让A直接注入C。把A里对C的调用改成了ApplicationEventPublisher发布事件整个环瞬间就没了。4.3 两种常用解决路径的实操对比路径一快速开启主配置开关。在application.yml里添加spring: main: allow-circular-references: true重新启动项目能够正常起来。这个方法确实最快但只适合本地临时验证我从来不会把它留到生产环境。因为开了这个开关之后Spring对循环依赖的“容忍度”提高了后续新增的坏味道代码很难在启动期暴露出来等于给项目埋雷。路径二构造器参数加Lazy。这是更推荐的做法。给其中一个依赖加上注解即可比如把ProductService的构造器改成Service public class ProductService { private final InventoryService inventoryService; public ProductService(Lazy InventoryService inventoryService) { this.inventoryService inventoryService; } }改动范围极小启动后项目恢复正常。我特地在Controller里加了一个接口分别调用inventoryService.checkStock和productService.getProductName确认两个服务都能正常返回说明启动成功不是假象实际调用链路是通的。4.4 叠加AOP代理后的第二轮排查Spring项目里加Transactional是再常见不过的操作。我故意给InventoryService的某个方法加上Transactional再把解决方案还原成最初的互相构造器注入重新启动。你会看到报错仍然存在而且日志里可能多了一些和CglibAopProxy相关的信息。原因在于Transactional会让InventoryService变成一个被代理增强的Bean而代理生成的时点在普通情况下靠后。循环依赖迫使Spring提前获取早期引用早期引用拿到的可能是原始对象的代理也可能在复杂场景下导致增强失效。此时在对应的构造器参数上加Lazy让代理延迟到真正调用时才解析问题就会迎刃而解。这个经验在实际项目里非常值钱。很多人踩着Transactional带了代理的坑改成这个思路后顺利落地。5. Spring Boot高版本的变化和配置陷阱5.1 为什么Spring Boot 2.6默认禁止循环依赖Spring Boot 2.6开始官方把spring.main.allow-circular-references的默认值设成了false。这是一个很明确的信号框架层面认为循环依赖不再是“被兜底支持的常规操作”而是一种应该被主动规避的设计缺陷。禁止的目的很简单就是让问题在项目启动阶段就暴露出来逼着开发者去重构。我见过不少从Spring Boot 2.5及以下版本升级上来的项目第一轮升级就是被这一堆启动异常拦下来的。处理办法其实不复杂要么认真重构掉循环依赖要么临时在配置里开启开关但开启开关会让项目长期停留在“带病运行”的状态。5.2 不同版本的开关位置和写法配置项在application.yml里是spring: main: allow-circular-references: true逗号分离的话也可以直接放在启动类注解前面或者通过环境变量传入。Spring Boot 3.x 基于Spring Framework 6同样默认禁止循环依赖行为和高版本Spring Boot 2.x保持一致。这意味着即便是到了2020年之后的现代框架版本里循环依赖都没被客气地对待。如果你在旧项目里没设置过这个值启动又没报错说明当时用的Boot版本低于2.6或者项目里根本没有循环依赖。升级之前最好全量扫描一遍代码里的 Bean 相互注入关系提前处理好升级风险。5.3 那些Spring明确解决不了的循环依赖场景场景结果关键原因构造器循环依赖且没有使用Lazy启动失败对象还未实例化无法提前暴露早期引用原型作用域Bean循环依赖启动失败原型Bean不缓存每次创建都是全新对象Async代理Bean循环依赖启动或运行阶段可能出现异常异步代理导致代理对象注册时机与早期引用不匹配同时存在多个AOP代理的循环依赖运行时行为可能不符合预期早期引用可能拿到非预期代理对象通过DependsOn强行控制顺序不解决环反而可能报新错只能调整顺序不能拆掉依赖环这张表是我自己整理的项目排雷清单。遇到某个循环依赖报错先查一下属于哪一行再选对应的处理策略比无头苍蝇一样乱改要高效得多。5.4 开启全局开关之前先问自己三个问题第一个问题这个项目是不是历史遗留代码当前没有时间重构如果是开开关属于短期续命。第二个问题循环依赖是不是必须存在的业务闭环绝大多数业务都能通过事件、中间层或查找表手段拆掉闭环。第三个问题团队是否有纪律保证后续不再新增循环依赖如果只是口头约定几周后大概率又会冒出新环。我的判断标准很简单全局开关只能出现在本地开发环境或一次性演示代码里一旦进入团队长期维护的工程必须回到重构路线。6. 常见问题与排查技巧实录6.1 高频报错信息速查表报错关键字实际含义典型解决方案BeanCurrentlyInCreationException某Bean正在创建过程中被其他Bean反向请求找到循环链用Lazy或重构The dependencies of some of the beans...form a cycle容器检测到Bean依赖环顺着日志的ASCII链条找入口currently in creation: 某个Bean名该Bean处于半成品状态尚未完成初始化检查是否在初始化阶段触发了getBeanIs there an unresolvable circular reference?Spring无法解开这个环优先看是否构造器注入或原型作用域Bean with name xxx has been injected into other beans该Bean已经被提前注入其他Bean但自身未完成排查AOP代理或Async场景这张表基本覆盖了我的日常工作场景。看到报错先别翻代码先对号入座定位效率会高很多。6.2 如何快速梳理项目里的Bean依赖关系最粗暴但有效的办法是在启动类里写一个临时代码循环打印出Bean之间的Autowired依赖图。虽然不能100%还原动态代理和条件装配但静态注入关系能看个大概。更简单的方法是用IDE的依赖分析功能管理类级别不直接暴露但依赖插件可以解析class文件结构。我在实际排查中发现优先关注Service层和工具类层最有效。Controller层很少会形成循环依赖Repository层一般也层独立重灾区的确集中在业务Service互相注入。另外如果你在启动日志里看到Currently in creation: [A]这种信息并且getBean的调用栈里有invokeInitMethods那说明问题往往出在某个Bean的PostConstruct方法或者InitializingBean的回调里。此时可以检查一下初始化逻辑中是否用ApplicationContext.getBean()创建了其他Bean。6.3 我踩过的几个坑写出来给你参考第一个坑是给字段注入加了Resource或者Autowired后误以为只有一种行为。Autowired按类型查找Resource默认按名称查找两者在循环依赖场景下都会经过三级缓存但又因为目标Bean不同实际命中的依赖路径可能完全不同。切换注解有时会改变报错对象不能盲目替换。第二个坑是在构造器里做太多逻辑。比如构造器里调用了一个需要其他Bean支持的方法这会让循环依赖问题加剧因为构造过程越长半成品暴露的窗口越窄。第三个坑是PostConstruct里直接调用了另一个尚在创建中的Bean。比如一个配置Bean在初始化时需要读某个缓存的预热状态而这个缓存Bean又依赖了配置Bean两者就可能卡死在初始化回调。这样的场景很难一眼看出问题日志里的invokeInitMethods会成为蛛丝马迹。第四个坑是序列化。Lazy注入的代理对象在序列化和反序列化时需要格外小心否则线上可能出现奇怪的代理序列化异常。解决方法是尽量让依赖关系的边界只限定在应用内部对外接口层不要持有内部代理对象。6.4 我的个人处理套路和最终建议首先沉住气看报错定位循环链路。其次能重构就重构优先把环拆成方向明确的单向依赖。如果代码量大牵扯模块多短期内只能改一行代码那就选择在“调用频率低”的那个Bean的构造器参数上加Lazy尽量不碰全局开关。最后把循环依赖问题带进代码评审和架构设计环节新代码里严禁新增环这种苗头越早灭掉越好。从我自己的项目经验看Spring三级缓存机制并不是为了鼓励大家随手写循环依赖而是给历史遗留问题留了一个缓冲带。真正干净的代码依赖图永远应该是一棵有向无环图。如果哪天你发现自己的依赖图画出来有环哪怕启动没报错我也建议认真审视一下设计因为Spring没拦你不代表这份代码就该存在。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →