Spring循环依赖深度解析:三级缓存原理与源码实战
循环依赖这个话题在Spring面试里几乎是必问项在真实项目里也经常踩坑。我见过不少同事代码跑起来报了个BeanCurrentlyInCreationException一脸懵地来问我“这啥意思”然后我一看好嘛两个Service互相new来new去典型的构造器循环依赖。这篇文章我就把Spring解决循环依赖这件事彻底讲透。从最基础的概念到三级缓存的设计原理再到源码层面的执行流程最后聊聊Spring Boot 2.6之后官方默认禁止循环依赖这件事以及我们实际项目里到底该怎么处理。不管你是准备面试还是排查线上问题这篇都能给你一个完整的参考。先说清楚一个前提Spring解决循环依赖有一个非常严格的前提条件必须是单例BeanSingleton而且注入方式不能是构造器注入有例外后面细说。脱离这两个大前提谈Spring解决循环依赖都是耍流氓。1. 循环依赖是什么先分清Bean之间是怎么“绕圈”的在深入源码之前我们先把问题本身想清楚。1.1 概念还原两个或多个Bean相互引用循环依赖说人话就是A对象创建时需要用到B对象B对象创建时需要用到A对象形成一个环。比如Component public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }上面这种就是典型的循环依赖。A要实例化得先有BB要实例化得先有A。如果处理不好这就是一个死锁程序直接卡死或者报错。更常见的还有三个甚至更多Bean形成的环Component public class A { Autowired private B b; } Component public class B { Autowired private C c; } Component public class C { Autowired private A a; }A依赖BB依赖CC依赖A绕了一圈又回来了。这种情况在业务代码里不算罕见尤其是当Service层的拆分粒度不够清晰的时候。1.2 注入方式差异为什么Setter注入能解构造器注入却不行循环依赖能不能被解决很大程度上取决于两个Bean是通过什么方式互相引用的。我们把情况拆开看构造器注入的循环依赖A的构造器需要BB的构造器需要A。创建A的时候发现得先创建B创建B的时候发现得先创建A……二者都在等对方构造完成谁都无法先创建出来。这就像两个人站在独木桥中间谁也不肯先退一步结果谁也别想过桥。Spring对这种情况无能为力直接抛BeanCurrentlyInCreationException。Setter/字段注入的循环依赖A的创建分为两步——先通过构造器把对象new出来此时A还是一个“半成品”B属性是null再往A里填充B属性。只要A能先把“空壳”对象创建出来让B那边有的可依赖问题就解决了。B同样可以先new出空壳再回头把A填进去。整个过程的核心是对象实例化和对象属性初始化被拆成了两步中间留出了一个“提前曝光”的空间。注意这里说的是“Spring框架本身无法解决构造器循环依赖”不是“代码无法解决”。后面我会专门讲怎么通过Lazy或重构来处理构造器循环依赖。1.3 单例与原型为什么Prototype的Bean救不回来除了注入方式Bean的作用域也是硬性限制。单例Singleton整个容器里就一个实例每个Bean都有明确的“缓存槽位”所以可以将“已实例化但未完全初始化”的早期对象暂存起来供其他Bean先行引用。原型Prototype每次获取都是新对象Spring没有地方保存它的“半成品”更不可能等它初始化完再给下一个Bean用。原型Bean的循环依赖100%会报错。简单记忆循环依赖的解决方案本质上是“缓存复用”而缓存复用的前提是“这个Bean最终只有一份”。2. 三级缓存机制Spring解决循环依赖的底层设计要说清楚Spring怎么解决循环依赖三级缓存是绕不开的核心。但很多文章一上来就扔三个Map搞得人一头雾水。我换个思路先问你一个问题如果你来设计这个机制要解决“A依赖B、B依赖A”的问题你会怎么做你大概率会想行啊我先创建A的空壳把这个空壳放到某个地方暂存然后去创建BB里面填A的时候从暂存区把那个空壳A拿来用B就创建成功了回头再把A的B属性填上不就完事了吗没错Spring的思路跟你一模一样但多了一个关键设计——它用了三层Map而不是一层。2.1 三个Map各管什么从“成品区”到“半成品区”Spring里这三级缓存定义在DefaultSingletonBeanRegistry中是三个成员变量// 第一级缓存存放已经完全创建好的单例Bean private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 第二级缓存存放早期暴露的单例BeanBean已经实例化但属性未填充完 private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); // 第三级缓存存放单例Bean的ObjectFactory用于生成早期暴露的对象 private final MapString, ObjectFactory? singletonFactories new HashMap(16);我习惯用大白话解释它们一级缓存成品区Bean已经完整创建可以直接使用了。二级缓存半成品区Bean刚被new出来属性还没填充完。它存在这里的唯一意义就是让其他Bean在循环依赖时能拿到它的引用。三级缓存半成品的工厂区存放的不是Bean本身而是一个ObjectFactory函数式接口。这个工厂能产出Bean的早期引用原始对象或代理对象。你可能已经看出来了二级和三级缓存之间存在一个“升级”关系。当某个Bean从三级缓存里取出并放入二级缓存后三级缓存里那个工厂就会被移除。换句话说同一时刻一个Bean只能出现在二级或三级缓存之一但不会同时挂在两级缓存里。2.2 缓存查找顺序为什么是逐级向下找Spring在获取单例Bean的时候会严格按照一级、二级、三级的顺序查找protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); // 一级缓存没有且当前Bean正在创建中 if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); // 二级缓存没有且允许提前引用 if (singletonObject null allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }注意几个细节第一isSingletonCurrentlyInCreation(beanName)这个判断很关键。如果一个Bean压根没在创建中那确实可能还没开始创建而不是处于“半成品”状态这时候不能从二三级缓存里拿。第二从三级缓存取出工厂并调用getObject()之后结果会被放到二级缓存同时移除三级缓存里的工厂。这么做的目的是防止同一个工厂方法被多次调用导致同一个Bean被重复创建或者代理对象被重复生成。第三整个查询过程加了synchronized锁是为了防止多线程环境下并发创建同一个Bean时出现状态不一致。2.3 为什么必须是三级缓存二级到底行不行这是面试最爱追问的点也是真正考验理解深度的地方。很多人以为三级缓存是为了解决性能问题多一层就多一次判断。其实三级缓存的核心目的只有一个延迟代理对象的创建时机。假设我们的代码里有这样一个场景A和B互相依赖而且A需要被AOP代理比如方法上有Transactional注解。先看二级缓存方案会怎么走A实例化完成发现A需要代理。Spring把A的代理对象放入二级缓存。B创建时从二级缓存拿A的代理对象注入成功。B创建完成A继续完成属性填充、初始化。最后A也被完整创建。看起来能跑通但问题出在第二步在创建A的代理对象时A的属性还没填充、BeanPostProcessor还没完整执行完。AOP代理通常是通过AnnotationAwareAspectJAutoProxyCreator这个BeanPostProcessor的postProcessAfterInitialization方法创建的这个方法在Bean初始化完成之后才调用。如果在实例化阶段就提前创建代理对象此时A内部的依赖都还是null代理的逻辑可能基于一个不完整的状态。用二级缓存也能解决只要在实例化阶段就创建好代理对象并且此后不再改变即可。但这会破坏Spring声明式AOP的一个重要特性代理创建的时机应在Bean初始化完成之后。三级缓存的设计思路更巧妙我们把代理创建的“决定权”往后推迟。三级缓存里存的是一个ObjectFactory它返回的不一定是原始Bean也可以是代理Bean。这个工厂的getObject()方法内部会回调SmartInstantiationAwareBeanPostProcessor的getEarlyBeanReference方法在这里判断是否需要创建代理。关键逻辑来了如果没有循环依赖这个ObjectFactory可能从头到尾都不会被调用Bean走的就是正常的生命周期实例化 - 属性填充 - 初始化 - 创建代理。代理对象只创建一次时机正确。如果发生循环依赖在“被其他Bean需要”的那个时间点Spring才通过ObjectFactory获取早期引用。此时如果判断需要代理就生成代理对象放入二级缓存如果不需要代理直接返回原始对象。说白了三级缓存让Spring具备了一个“按需提前代理”的能力而这一切并不会影响非循环依赖场景下AOP的正常创建时机。用自己的话说二级缓存要求Bean“创建后立刻决定是否代理”而三级缓存允许Spring“等到有人需要时再决定是否代理”。聪明就聪明在这个“延迟决策”上。3. 源码流程拆解一个A依赖B、B依赖A的完整创建过程理论说清楚了我们来走一遍真实的源码流程。为了不让你被源码细节淹没我用最典型的“A依赖BB依赖A且都是Setter注入”场景来演示。3.1 第一次getSingleton从一级缓存查起假设容器启动时先创建A。AbstractBeanFactory.doGetBean方法里会调用getSingleton(beanName)此时一级缓存、二级缓存、三级缓存全都为空当前也没有“正在创建中”的记录所以直接跳过缓存查询进入创建逻辑。public Object getSingleton(String beanName) { return getSingleton(beanName, true); } public Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); // ... return singletonObject; }拿到null之后Spring调用getSingleton(String beanName, ObjectFactory? singletonFactory)进入带工厂的创建方法public Object getSingleton(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { // 标记当前Bean正在创建中 beforeSingletonCreation(beanName); boolean newSingleton false; try { // 核心调用createBean singletonObject singletonFactory.getObject(); newSingleton true; } catch (BeanCreationException ex) { // ... } finally { afterSingletonCreation(beanName); } // 放入一级缓存 addSingleton(beanName, singletonObject); } return singletonObject; } }这个synchronized锁很有讲究后面排查并发问题的时候会用到先记着。3.2 doCreateBean实例化后立刻“提前曝光”createBean调用链会经过AbstractAutowireCapableBeanFactory.doCreateBean。这里发生了整个循环依赖解决过程中最关键的一步——提前曝光。protected Object doCreateBean(String beanName, RootBeanDefinition mbd, Object[] args) { BeanWrapper instanceWrapper null; // 1. 实例化Bean调用构造器new一个对象出来 if (mbd.isSingleton()) { instanceWrapper this.factoryBeanInstanceCache.remove(beanName); } if (instanceWrapper null) { instanceWrapper createBeanInstance(beanName, mbd, args); } Object bean instanceWrapper.getWrappedInstance(); // ... // 2. 提前曝光将ObjectFactory放入三级缓存 boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); } Object exposedObject bean; // 3. 填充属性这里会触发B的创建 populateBean(beanName, mbd, instanceWrapper); // 4. 执行初始化方法、BeanPostProcessor等 exposedObject initializeBean(beanName, exposedObject, mbd); // ... return exposedObject; }重点看第2步。addSingletonFactory方法把一个Lambda表达式放进了singletonFactoriesprotected void addSingletonFactory(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, singletonFactory); this.earlySingletonObjects.remove(beanName); } } }这个Lambda内部是getEarlyBeanReference它的作用我们前面提过在真正需要早期引用的时候判断是否要生成代理对象。刚才创建出来的A对象此时属性B还是null但它已经被“半曝光”了。这里有个很反直觉的地方Spring并不是等A完全创建好才放缓存而是实例化完成后立刻就把自己标记为可获取。3.3 populateBean填充属性时发现要找B接着往下走populateBean要对A做属性填充。A依赖B所以要调用getSingleton(b)获取B。B同样不在缓存里所以B走和A一样的创建流程实例化B对象。在B的属性填充阶段发现需要A。调用getSingleton(a, true)获取A。这次情况不一样了。当前A处于“正在创建中”的状态前面beforeSingletonCreation标记过一级缓存里没有A所以我们进入二级缓存查询逻辑。二级缓存earlySingletonObjects里没有A继续到三级缓存singletonFactories里找。找到了A的三级缓存里存着一个ObjectFactory调用它的getObject()方法protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) { exposedObject bp.getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }如果没有AOP拦截直接返回原始A对象。如果有AOP需求则返回代理对象。然后把这个结果放入earlySingletonObjects同时从singletonFactories移除A的工厂。B拿到了A的早期引用成功完成B的属性填充、初始化、创建并被放入一级缓存。B创建完成后A的populateBean也拿到了B的完整对象填进A的b属性里。接着A完成初始化、放入一级缓存。整个过程结束。你看关键时间线其实是A先实例化并提前曝光 - B创建 - B从三级缓存拿到A的早期引用 - B完成 - A拿到B完成注入。这就是三级缓存的完整流程。3.4 提前曝光后的二次检查为什么initializeBean之后还要校验这里有个很隐蔽的细节很多讲循环依赖的文章都不提。在doCreateBean的最后Spring有一段二次检查逻辑if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { exposedObject earlySingletonReference; } else if (!this.allowRawInjectionDespiteWrapping hasDependentBean(beanName)) { // ... throw new BeanCurrentlyInCreationException(beanName); } } }这么做的目的很简单如果有人提前拿到了A的早期引用而A又在后续流程中被动态代理了那就可能出现两个不同的“A”。看最后那几行代码的逻辑如果getSingleton(beanName, false)返回非null说明A曾经被提前引用过发生在循环依赖中。如果当前exposedObject和bean还是同一个对象说明A后来没有发生变化直接用早期引用即可。但如果exposedObject ! bean说明Bean在初始化时被某个BeanPostProcessor替换成了新的对象比如AOP生成代理此时如果还存在其他Bean依赖了旧的A就会出错Spring会抛异常。这个检查就是为了保证所有依赖A的Bean拿到的必须是同一个对象。4. 为什么构造器注入无法解决死锁场景拆解前面已经提过构造器循环依赖无法被Spring解决这里从执行机制上彻底拆透。你会发现这不只是Spring的局限而是问题本身的无解。4.1 构造器场景下三级缓存为何失效三级缓存解决循环依赖的第一个前提就是Bean必须能先实例化出来。Setter注入能做到这一点因为Spring可以先调用无参构造器或者参数独立的构造器创建空壳对象然后把依赖关系放到populateBean阶段去解决。构造器注入就不一样了。A的构造器形参是BSpring在createBeanInstance阶段就必须解析构造器参数来调用构造器。此时bean还没创建出来连“提前曝光”的机会都没有。我们回到代码逻辑看看protected BeanWrapper createBeanInstance(String beanName, RootBeanDefinition mbd, Object[] args) { // 解析构造器A(B b)发现参数B Constructor? constructorToUse ...; // 需要调用getBean(b)来获取B Object[] argsToUse ...; BeanWrapperImpl bw new BeanWrapperImpl(); bw.setBeanInstance(ctor.newInstance(argsToUse)); return bw; }Spring在调用A构造器之前需要解析参数B于是先执行getBean(b)。B走创建流程解析构造器时又需要参数A于是再执行getBean(a)。可A此时还停留在“构造器参数解析”环节它连实例都还没有更不可能进入三级缓存于是系统发现A正在创建中但三级缓存里没有它的引用最终抛出BeanCurrentlyInCreationException。简单理解实例化都没有完成的对象不可能被提前引用。构造器让对象失去了“先出生后完善”的机会所以三级缓存再巧妙也没用。4.2 常见异常BeanCurrentlyInCreationException的产生过程实际项目里构造器循环依赖最常见的报错长这样Error creating bean with name a defined in file [A.class]: Requested bean is currently in creation: Is there an unresolvable circular reference?这个异常信息明确指出当前Bean正在创建中但无法解析循环引用。说人话就是“你要找的Bean还没创建完但它正卡在等待你的创建过程中你俩互相等着谁也动不了。”有一种看似矛盾的场景也容易踩坑虽然不是直接构造器循环依赖但A通过构造器注入了BB通过Setter注入了A。这种场景其实依然无法解决因为Spring在创建A的构造器参数时就触发了B的创建而B的Setter注入在填充属性时需要A的完整对象A还没实例化完一样会失败。判断的关键看触发依赖链的源头是不是构造器。5. Spring Boot 2.6之后的默认策略与我们该怎么写代码在Spring Boot 2.6.0之前循环依赖是默认允许的项目里出现了循环依赖也只是默默跑着大家也不太在意。但从2.6.0开始官方默认禁止了循环依赖——只要启动时检测到直接启动失败。这个变化让不少人叫苦不迭但其实这是个好事。我们来分析下背后的原因。5.1 官方为什么默认禁用循环依赖循环依赖从设计角度看本身就是一种“代码坏味道”它往往意味着两个模块耦合过重、职责边界模糊。很多循环依赖在实际运行时依赖链中的某个Bean在每次请求中表现并不稳定会引发各种隐藏Bug。从性能层面看循环依赖依赖的是“半成品Bean的提前引用”而代理对象可能在生命周期早期就被创建导致后续的某些AOP增强逻辑没有完整作用在最终对象上。这对事务、缓存等声明式功能有时会带来难以排查的问题。另外循环依赖的实现严重依赖三级缓存里singletonFactories这个Map而它本身不是线程安全的用的是普通HashMap只是借住了singletonObjects这把锁来同步。一旦涉及并发创建单例Bean这个锁竞争和状态迁移的复杂度会显著上升。官方显然是认为与其冒这些风险不如在框架层直接拉响警报。5.2 实际的三种改造方案如果你在Spring Boot 2.6的项目中遇到循环依赖启动报错有三种常规解法按推荐程度排序方案一重构代码打破循环最推荐两个Service互相调用通常不是好设计。可以尝试把公共的依赖提取到第三个类中或者把A调用B的那部分逻辑下沉到一个独立的组件让A和B都依赖这个新组件而不是互相依赖。这是最彻底的解决方式虽然涉及代码调整但没有后患。方案二使用Lazy延迟代理如果不想大改代码可以在依赖的注入点上加LazyComponent public class A { private final B b; public A(Lazy B b) { this.b b; } } Component public class B { private final A a; public B(Lazy A a) { this.a a; } }Lazy会让Spring为B和A分别生成一个延迟代理对象。创建A时构造器里传入的并不是真正的B而是一个B的代理等真正调用B的方法时代理才去解析并执行真正的B。这个方式能解决构造器循环依赖但引入了一层代理调用链上多了一次间接跳转不算最优解。方案三用Setter或字段注入如果循环依赖发生在Setter注入上Spring Boot 2.6依然允许前提是spring.main.allow-circular-referencestrue。但要注意这个开关只是兜底方案不建议长期开启。沙盘演练时可以用生产环境还是要想办法重构。5.3 用了代理就高枕无忧了吗很多开发者以为用了Lazy就万事大吉其实还有一个坑。如果A和B互相依赖而且A上有事务注解Spring在创建A的代理对象时可能会触发对B的真实解析。而B正在创建中且也需要代理这个递归解析在某些复杂场景下会陷入死循环。我记得早年遇到过一个线上案例A调用B的接口B又调用A的另一个方法两个方法都加了事务在2.6版本报错后改成了Lazy结果启动没问题了但调用时偶尔出现代理初始化顺序导致的空指针。后来我们干脆把公共逻辑抽成了C彻底解耦问题才根除。6. 常见问题排查与经验总结最后这部分我整理了一些写代码和排查问题时实际会遇到的典型情况。6.1 循环依赖问题排查清单遇到BeanCurrentlyInCreationException或启动失败时按这个顺序自查检查项判断标准解决方法是否有构造器注入循环依赖类构造器参数里是否形成环增加Lazy或重构是否有原型Bean参与循环Bean是否配置了Scope(prototype)改成单例或重新设计Spring Boot版本是否2.6且未开启allow-circular-references开启临时开关或重构是否存在AOP早期引用组合错误报错信息是否提到“Raw injection despite wrapping”重构避免对半成品Bean做代理6.2 我踩过的两个实际坑第一个坑是多个线程并发获取同一个Bean时出现的诡异状态。项目里有一个定时任务和多线程任务同时触发导致同一个未创建完成的Bean被多个线程并发访问由于三级缓存加了synchronized锁大部分情况都能挡住但在某个高并发瞬间偶尔会出现代理对象被创建两次的问题。排查了很久才发现是某个Bean的ObjectFactory被手动从外部调用触发的。后来我们规范了代码不要在业务代码里直接操作DefaultSingletonBeanRegistry的缓存方法这些内部方法只该被框架调用。第二个坑是依赖了第三方库中的Bean无法改源码。当时我们引用的一个内部SDK它的两个组件类存在环状依赖而且是构造器注入。升级Spring Boot 2.6后直接启动失败。我们没法改SDK源码最终方案是在配置类里通过Bean手动构造对象绕开了组件扫描和自动装配。6.3 面试与项目实战中的理解建议如果你准备面试关于循环依赖多数面试官会问这几个角度什么是循环依赖Spring能解决哪种类型的循环依赖三级缓存中每级的具体作用是什么为什么不能用二级缓存解决构造器注入循环依赖为什么无法解决深挖“为什么不能用二级缓存”时重点讲清楚“代理创建时机”这个点基本就能体现你的真实理解深度。如果你是在项目实战中用到记住一句我总结的话就够了循环依赖不是框架的bug也不是必须要支持的feature它就是代码设计层面告诉你“该重构了”的信号。能用重构解决的优先重构确实需要临时快速处理的用Lazy过渡都不行的再考虑开启allow-circular-references。从源码细节回到日常开发我个人最大的体会是很多人把三级缓存背得滚瓜烂熟但遇到实际报错还是不知道从哪里排查。原因就在于只记住了“三个Map”没理解“实例化和初始化的分离是这一切能成立的前提”。搞懂了这个前提再回头看构造器失败、原型失败、代理时机这些衍生问题思路就通了。Spring这套设计虽然精巧但它解决的终究是代码结构不理想时的兜底问题日常写代码还是应该尽量避免制造循环依赖这句话值得贴在工位上。