Spring Bean 循环依赖实战:三级缓存怎么解、什么场景仍会报错

发布时间:2026/7/29 21:38:48
Spring Bean 循环依赖实战:三级缓存怎么解、什么场景仍会报错 Spring Bean 循环依赖实战:三级缓存怎么解、什么场景仍会报错启动 Spring 项目突然报BeanCurrentlyInCreationException,或者一句Requested bean is currently in creation: Is there an unresolvable circular reference?。你盯着代码看半天,发现是 A 依赖 B、B 又依赖 A。奇怪的是,同样的循环依赖,有的项目能正常启动,有的却直接崩。这篇讲清楚 Spring 到底是怎么解循环依赖的、为什么有时候能解有时候不能,以及遇到报错该怎么改。先复现:什么样的循环依赖能过最常见的字段注入形式,Spring 默认能解:ServicepublicclassAService{AutowiredprivateBServicebService;// A 依赖 B}ServicepublicclassBService{AutowiredprivateAServiceaService;// B 又依赖 A}这段代码能正常启动。很多人以为循环依赖一定报错,其实 Spring 对单例 属性注入的循环依赖是有解的。理解它怎么解,才能理解什么时候解不了。Spring 创建 Bean 的两个阶段关键前提:Spring 创建一个 Bean 分两步,这两步是分开的。实例化(instantiation):调用构造器new出对象,此时字段还都是 null。属性填充(populate):给字段注入依赖(Autowired的那些)。正是因为「先有对象、后填字段」,Spring 才有机会在对象还没填完字段时,先把它的引用暴露出去给别人用。这就是解循环依赖的突破口。三级缓存到底怎么工作Spring 用三个 Map 来存不同状态的单例(在DefaultSingletonBeanRegistry里):// 一级缓存:完全创建好的成品 BeanprivatefinalMapString,ObjectsingletonObjects;// 二级缓存:实例化了但还没填完属性的「半成品」privatefinalMapString,ObjectearlySingletonObjects;// 三级缓存:能产出 Bean 引用的工厂(为了兼容 AOP 代理)privatefinalMapString,ObjectFactory?singletonFactories;以 A 依赖 B、B 依赖 A 为例,创建流程是这样:创建 A:new AService(),然后把「能拿到 A 引用的工厂」放进三级缓存。给 A 填属性,发现需要 B,去创建 B。创建 B:new BService(),把 B 的工厂放进三级缓存。给 B 填属性,发现需要 A,去查缓存。一级没有(A 还没造完),三级里有!调用工厂拿到 A 的早期引用,升级放进二级缓存,注入给 B。B 填完属性,创建完成,进一级缓存。回到步骤 2,A 拿到完整的 B,填完属性,A 也创建完成。注意步骤 5 注入给 B 的 A,是个「字段还没填完」的半成品引用。但没关系——因为最终 A 会填完,而 B 持有的是同一个对象的引用,A 填完后 B 看到的 A 自然也是完整的。这就是引用传递救了场。为什么需要三级而不是两级一个高频面试题,也是实战理解的关键:既然引用传递就够了,为什么不直接用二级缓存存半成品,非要绕一层工厂?答案是AOP。如果 A 被 AOP 增强(比如加了Transactional),那么最终注册进容器的应该是 A 的代理对象,而不是原始对象。如果提前把原始对象塞进二级缓存给了 B,B 持有的就是没被代理的原始 A,事务、切面全失效。三级缓存里放的是工厂ObjectFactory,它的getObject()会判断:如果这个 Bean 需要被代理,就现在提前生成代理对象返回。这样 B 拿到的就是代理后的 A。// 三级缓存工厂内部逻辑(简化)singletonFactories.put(beanName,()-getEarlyBeanReference(beanName,mbd,bean));// getEarlyBeanReference 会走 SmartInstantiationAwareBeanPostProcessor// 如果有 AOP,这里返回代理对象;没有就返回原始对象一句话:二级缓存解决「引用共享」,三级缓存额外解决「提前暴露的应该是代理对象」。什么场景仍然会报错理解了机制,就能预判哪些循环依赖 Spring 解不了。1. 构造器注入的循环依赖这是最经典的解不了的情况:ServicepublicclassAService{privatefinalBServicebService;publicAService(BServicebService){// 构造器注入this.bServicebService;}}ServicepublicclassBService{privatefinalAServiceaService;publicBService(AServiceaService){// 构造器注入this.aServiceaService;}}启动直接报BeanCurrentlyInCreationException。原因回到那两个阶段:三级缓存的前提是「对象已经实例化、只差填属性」。而构造器注入要求在new的那一刻就把依赖传进去——A 的构造器还没执行完,根本没有 A 的对象可以提前暴露,循环就死锁了。2. 用了 Async 的 BeanAsync的代理不是通过三级缓存的早期引用机制生成的,而是在 Bean 完全初始化后由AsyncAnnotationBeanPostProcessor包装。于是提前暴露给别人的是原始对象,最后容器里的又是代理对象,两者不一致,Spring 检测到后抛异常:The dependencies of some of the beans in the application context form a cycle... Bean with name aService has been injected into other beans [bService] in its raw version as part of a circular reference, but has eventually been wrapped.3. Spring Boot 2.6 默认禁止循环依赖从 Spring Boot 2.6 起,即使是能解的属性注入循环依赖,默认也直接报错,官方希望你重构掉。想临时放行加配置:spring:main:allow-circular-references:true但这只是应急,不是解决方案。正确的修法:别放行,要重构遇到循环依赖,加allow-circular-references: true是最差的选择——它把设计问题藏起来了。更好的思路:首选:抽出第三个类,打破循环。如果 A 和 B 互相调用,通常说明有一块公共逻辑该独立出来。把这块逻辑抽成 C,让 A、B 都依赖 C,循环就没了。这往往能顺带改善设计。次选:用 Lazy 延迟注入。让其中一方的依赖变成懒加载,注入的是代理,真正用到时才解析:ServicepublicclassAService{privatefinalBServicebService;publicAService(LazyBServicebService){// 注入代理,打破构造期死锁this.bServicebService;}}Lazy让 Spring 先注入一个 B 的代理对象,不立即创建真正的 B,从而绕开构造期的循环。构造器注入场景很实用,但它治标——循环依赖的设计本身还在。小结Spring 解循环依赖靠实例化与属性填充分离三级缓存:半成品对象的引用能提前暴露。三级(工厂)而非二级是为了 AOP:提前暴露的必须是代理对象,工厂在需要时现场生成代理。构造器注入的循环依赖解不了:new的那一刻没有对象可提前暴露,死锁。Async的 Bean、Spring Boot 2.6 默认配置也会让循环依赖报错。正确修法是抽第三个类打破循环,其次Lazy,别靠allow-circular-references掩盖。一句话记忆:能解的循环依赖靠「先造对象后填字段」,构造器注入没这个空档,所以解不了。