
Spring循环依赖这个题算是面试Java岗位绕不过去的一个“硬骨头”。网上解析三级缓存的文章一抓一大把但我发现很多人是背答案式的理解你问他“一级缓存和二级缓存分别是什么”能答上来再追问一句“为什么非要三级二级不行吗”就直接卡壳。这篇文章我就从源码运行的角度把循环依赖的整个解决链路拆开揉碎讲一遍顺带把手写Spring时最容易踩的坑也聊透。无论你是准备面试、想提升框架认知还是在Spring Boot项目里被循环依赖报错折磨过都能从中找到有用的东西。1. 从问题到本质先搞清楚循环依赖为什么会被卡住1.1 一个最典型的循环依赖长什么样循环依赖在代码里非常常见比如下面这种Service public class ServiceA { Autowired private ServiceB serviceB; } Service public class ServiceB { Autowired private ServiceA serviceA; }ServiceA创建的时候需要注入ServiceB而ServiceB创建的时候又需要注入ServiceA两者互相引用形成了一个环。这种“字段注入”造成的循环依赖Spring自身能够处理掉靠的就是我们常说的三级缓存。但如果把Autowired换成构造器注入情况就完全不一样了这个后面细说。很多人不理解的是Spring为什么要管这个事儿你去创建ServiceA发现缺ServiceB那就老老实实报错不好吗确实有这个选项。Spring在设计上提供了“允许循环引用”的开关只是这个开关从 Spring Boot 2.6 开始被默认关掉了。换言之三级缓存这个机制一直在那儿但能不能触发还要看容器允不允许。1.2 容器在“创建Bean”时到底在哪个环节翻车要理解循环依赖必须先知道Spring创建Bean的完整生命周期。抛开各种增强和回调核心步骤其实就三步实例化Instantiation通过构造器new一个对象出来此时对象内的属性全是null。属性填充Populate扫描字段上的Autowired等注解把依赖的其他Bean注入进来。初始化Initialization执行InitializingBean接口、PostConstruct方法、AOP代理生成等收尾工作最终得到可用对象。问题就出在第2步。创建ServiceA时执行到属性填充发现需要ServiceB于是转头去创建ServiceB创建ServiceB时又需要ServiceA可此刻ServiceA明明已经实例化过了却因为还没有走完生命周期没有出现在最终的singletonObjects一级缓存里于是Spring误以为这是个“新的Bean”又重新去new ServiceA()。这一来一回线程就被锁死了最终抛出BeanCurrentlyInCreationException。循环依赖的本质不是对象实例化不出来而是“中间状态的Bean”没有被后续创建流程感知到。所以解决方案也很直白得有一个地方先把“半成品Bean”放进去让后面的依赖方能够拿到它。2. Spring三级缓存不是三个内存Map那么简单2.1 三级缓存分别存什么对应源码如何命名Spring解决循环依赖的核心代码在DefaultSingletonBeanRegistry这个类里它定义了三个Map// 一级缓存存放已经完整初始化的单例Bean这是对外暴露的真正成品 private final MapString, Object singletonObjects new ConcurrentHashMap(256); // 二级缓存存放从三级缓存中提前取出来、但尚未完成初始化的早期Bean private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); // 三级缓存存放ObjectFactory工厂对象它负责在需要时生成“早期Bean的引用” private final MapString, ObjectFactory? singletonFactories new HashMap(16);为了更好地理解把三个Map用大白话翻译一下缓存存的是什么什么时候写入什么时候读取一级缓存singletonObjects完整成品Bean整个Bean创建完成后外部获取Bean时优先查这里二级缓存earlySingletonObjects已实例化、未完成初始化的早期Bean可以是代理对象从三级缓存取出工厂并调用后当前Bean正在创建且被循环引用时三级缓存singletonFactories生成早期Bean引用的工厂对象实例化完成、还没有属性填充时出现循环依赖、需要提前暴露引用时之所以叫“三级”其实就是访问顺序和创建阶段不同。正常情况下外部拿Bean直接命中一级缓存。只有Bean正在创建中并且被其他Bean反过来引用才会先走三级缓存生成早期引用再把早期引用放进二级缓存供后续重复读取。2.2 三级缓存是如何被“安排”进去的doCreateBean中的关键代码真正把三级缓存写进去的位置在AbstractAutowireCapableBeanFactory的doCreateBean方法里。实例化完成后在属性填充之前会做这么一个判断// 如果是单例、允许循环引用、且当前Bean正处于创建中 boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }addSingletonFactory干的事情很纯粹就是把当前半成品Bean包装成一个ObjectFactory扔进singletonFactories这个Map里。protected void addSingletonFactory(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, singletonFactory); this.earlySingletonObjects.remove(beanName); } } }这一步是整个机制最精妙的地方。它不直接放一个Bean对象进缓存而是放一个工厂对象。为什么不直接放Bean因为后面有可能需要对这个半成品做AOP代理如果此时此刻就把原始对象定死了后续做代理就来不及了。而工厂对象可以在“真正需要拿引用”的那一刻才决定返回原始对象还是代理对象。2.3 为什么必须是三级二级缓存方案的两难很多讲循环依赖的文章到这里会说“三级缓存是为了延迟代理对象的创建”但没说透为什么延迟代理创建就必须要三级二级存不下吗我们做一次思维实验。如果去掉三级缓存只保留一级和二级也就是早期Bean直接放进二级缓存那么会出现两种情况方案一实例化后立刻把原始对象放进二级缓存。问题在于如果这个Bean最终需要AOP代理那么依赖方从二级缓存拿到的就是原始对象而不是代理对象。等到AOP代理真正生成一级缓存里放的是代理二级缓存里已经给出去的却是原始对象两个引用的功能不一致事务、切面逻辑全都会失效。方案二实例化后立刻生成代理对象放进二级缓存。问题变成了并不是每个Bean都会被循环依赖也不是每个Bean都需要AOP代理。如果一个Bean根本没有被循环依赖却在实例化后马上做代理那么所有代理都会提前创建白白浪费性能而且会破坏AOP代理创建的时机导致很多基于BeanPostProcessor的增强逻辑错乱。所以二级缓存做不到两全其美。要么提前暴露原始对象失去AOP要么提前生成代理牺牲性能与时机。Spring的选择是用一个ObjectFactory作为“延迟计算”的中间层谁需要谁就触发生成需要几次就生成几次但最终只会有一个引用被记住。这个“延迟能力”就是三级缓存存在的根本原因。2.4 从一级到三级到底怎么流转把getSingleton的核心逻辑拉出来看一遍会非常直观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) { // 从三级缓存拿到工厂调用getObject()生成早期引用 ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }注意这里的顺序一级没有查二级二级没有才从三级工厂拿。拿到之后立即把结果升级到二级缓存同时从三级缓存移除。这个“升级动作”保证了同一个Bean的早期引用只生成一次后续其他Bean再来依赖时直接从二级缓存取不会再调用一遍getObject()也就保证多依赖方拿到的是同一个引用。3. 当循环依赖遇到AOP三级缓存真正的杀手锏3.1 问题代理对象必须只有一个否则后面全是坑先明确一件事一个Bean在Spring容器里最终应该只有一个对象。如果有循环依赖A依赖B、B依赖AA还要被事务管理器做代理那么B拿到的A和最终一级缓存里的A必须是同一个对象而且这个对象必须是代理对象。否则B里调A的方法走不到事务增强代码还能跑但是隐藏着非常难以排查的逻辑错误。这就引出一个问题代理对象什么时候创建如果等到Bean全部初始化完毕后再创建代理B在之前拿到了原始A那就装错引用了。如果实例化完就创建代理又可能制造一堆无用代理。三级缓存用“工厂延迟获取”把这个矛盾处理得刚刚好。3.2 getEarlyBeanReference与SmartInstantiationAwareBeanPostProcessor的默契再看2.2小节里那段代码工厂方法调的是getEarlyBeanReferenceprotected 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; }这里遍历的是SmartInstantiationAwareBeanPostProcessor所有需要提前暴露代理的后置处理器都会实现这个接口。以AbstractAutoProxyCreator为例它的getEarlyBeanReference方法会记录一个“早期代理标记”Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }wrapIfNecessary才会真正判断当前Bean需不需要代理如果需要就返回代理对象如果不需要原样返回。同时把“这个Bean已经提前做过代理判断”的标记记在earlyProxyReferences里防止后续initializeBean阶段再重复创建一次代理。整个过程可以理解为工厂在第一次被调用时会去问后置处理器“这个半成品Bean现在要不要代理”要的话立刻给出代理对象。这个时机不是在Bean刚实例化时而是在“确实有人依赖它”的那一刹那。3.3 一个“母版”和多次getObject需要再明确一点三级缓存里的工厂被调用后生成的结果会被放入二级缓存并移除工厂。后续所有的依赖方都从二级缓存拿同一个引用不会再触发第二次getObject()。回到代码流程来说如果Bean A在实例化后就被B依赖B触发了一次getObject()拿到了A的早期代理这个代理被放进二级缓存。接着C也依赖AC直接从二级缓存拿跟B拿到的是同一个对象。最终A自己完成初始化后还会有一个getEarlyBeanReference与getSingleton的最终确认动作保证一级缓存里的成品和提前暴露出去的引用是同一个。这一步靠的是doCreateBean后段的exposedObject校验逻辑核心代码如下if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { exposedObject earlySingletonReference; } } }如果exposedObject还是最原始的Bean说明后续没有生成新的代理那就直接把早期引用覆盖过去保证容器里最终存的是一开始暴露出去的那个对象。这么绕一圈就是为了让“半成品”和“成品”保持一致。3.4 为什么构造器注入救不回来很多人面试被问到一个问题构造器注入的循环依赖为什么Spring解决不了答案其实藏在这个机制的启动时机里。构造器注入发生在Bean实例化阶段。doCreateBean第一步就是调用构造器去new对象而把三级缓存工厂注册进去的动作在实例化之后。换句话说一个Bean还没“new”出来的时候三级缓存里根本没有它的工厂自然也没法提前暴露引用。A的构造器需要BB的构造器需要A两个Bean都卡在new这一步谁也进不了三级缓存只能死锁最终抛异常。这也是为什么Spring官方更推荐构造器注入的原因之一——它能强制暴露循环依赖问题让你早发现早解决而不是靠三级缓存把问题藏起来。当然如果你就是想用构造器注入又确实有循环依赖可以用Lazy打破这个环这个后面再展开。4. 手把手复盘“A唯一创建B依赖A”的完整时序4.1 单线程下的执行轨迹逐行解读以最经典的A依赖B、B依赖A为例把执行轨迹列出来// 第1步用户向容器索要AgetBean(serviceA) // 第2步A进入创建流程标记为“正在创建” ServiceA a new ServiceA(); // 实例化完成a属性为null addSingletonFactory(serviceA, factoryA); // 注册三级缓存工厂 // 第3步A填充属性发现需要B调用getBean(serviceB) ServiceB b new ServiceB(); // B进入创建实例化完成 addSingletonFactory(serviceB, factoryB); // B注册三级缓存工厂 // 第4步B填充属性发现需要A调用getBean(serviceA) // 一级缓存没有、二级缓存没有但是三级缓存有A的工厂 Object aEarlyRef factoryA.getObject(); // 生成A的早期引用若需要AOP则生成代理 earlySingletonObjects.put(serviceA, aEarlyRef); // 升级到二级缓存 singletonFactories.remove(serviceA); // 移除三级缓存 // B拿到aEarlyRef完成属性填充 // 第5步B继续初始化执行后置处理器最终完成 singletonObjects.put(serviceB, b); // B放入一级缓存 // 第6步A拿到B的完整引用完成属性填充 // 第7步A继续初始化校验exposedObject确认早期引用与最终成品一致 singletonObjects.put(serviceA, a); // A放入一级缓存这个过程中有几个容易被忽略的关键点。一是工厂factoryA.getObject()的触发时机它不是在A注册时执行而是在B真正需要A的那一刻才执行这就是“懒计算”。二是二级缓存的重要作用它保证了多个Bean依赖同一个Bean时不会反复调用工厂生成多个早期引用。三是B放在一级缓存的时间点早于A说明Spring并不要求所有Bean按照依赖顺序严格入缓存而是谁先完成初始化谁先进。4.2 getSingleton的两个重载别搞混了DefaultSingletonBeanRegistry里有两个名字一样但作用不同的getSingleton方法面试时最好能区分清楚第一个是getSingleton(String beanName, boolean allowEarlyReference)它纯粹是“获取已存在的缓存对象”大部分情况下不触发Bean创建。上面列的三次缓存查询就是它。第二个是getSingleton(String beanName, ObjectFactory? singletonFactory)它才是真正触发整个Bean创建流程的入口内部逻辑大致如下public Object getSingleton(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { beforeSingletonCreation(beanName); // 标记正在创建 try { singletonObject singletonFactory.getObject(); // 调用createBean } finally { afterSingletonCreation(beanName); // 移除创建标记 } addSingleton(beanName, singletonObject); // 加入一级缓存 } return singletonObject; } }beforeSingletonCreation会往singletonsCurrentlyInCreation这个集合里加当前Bean名isSingletonCurrentlyInCreation就是查它。这个集合也是判断“当前Bean是否仍处于创建中允许提前暴露引用”的依据。理解了这两个重载再去看源码会顺很多。4.3 从“手写Spring”的视角最小三级缓存模型要怎么写网上很多“手写Spring”教程里三级缓存是被绕开的重头戏。如果你要自己实现一个迷你IOC容器且想支持循环依赖核心代码并不复杂大概长这样public class MiniApplicationContext { private final MapString, Object singletonObjects new ConcurrentHashMap(); private final MapString, Object earlySingletonObjects new ConcurrentHashMap(); private final MapString, ObjectFactory? singletonFactories new ConcurrentHashMap(); protected T T doCreateBean(String beanName, ClassT clazz) { // 1. 通过构造器实例化 T bean instantiate(beanName, clazz); // 2. 注册三级缓存工厂注意此时还没有填充属性 singletonFactories.put(beanName, () - getEarlyBeanReference(bean, beanName)); // 3. 填充属性 populateBean(beanName, bean); // 4. 初始化 initializeBean(beanName, bean); singletonFactories.remove(beanName); singletonObjects.put(beanName, bean); return bean; } private Object getEarlyBeanReference(Object bean, String beanName) { // 如果有AOP逻辑在这里返回代理对象 return bean; } }这段示例写得比较简化但骨架是对的实例化后立刻注册工厂属性填充时依赖其他Bean其他Bean反向依赖当前Bean就能从singletonFactories里取到工厂并生成早期引用。如果面试中你能够把这段逻辑说清楚比单纯背三个Map的名字要有说服力得多。5. 面试与实践中常踩的坑5.1 常见循环依赖错误的日志长什么样实际开发中遇到循环依赖报错信息往往是这样The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | serviceA (field private com.example.ServiceB) ↑ ↓ | serviceB (field private com.example.ServiceA) └─────┘Stack Overflow上把这个错误叫做BeanCurrentlyInCreationException。看到这类日志第一件事就是去检查代码里是不是出现了互相引用。但要注意的是日志里列的“循环链”有时候并不是最原始的根因它只是最早触发失败的链条。真实项目中循环依赖往往不是两个类那么简单可能是A依赖B、B依赖C、C依赖A的三方环也可能是通过ConfigurationProperties、BeanPostProcessor传递形成的隐式环排查的时候要顺着报错链路一层层剥离。5.2 构造器循环依赖为什么无解以及Lazy的正确用法前面已经解释了构造器注入的循环依赖发生在实例化阶段三级缓存无力回天。项目里如果强行写了这样的代码Service public class ServiceA { private final ServiceB serviceB; public ServiceA(ServiceB serviceB) { // 实例化A就需要B this.serviceB serviceB; } } Service public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { // 实例化B又需要A this.serviceA serviceA; } }启动大概率直接抛异常。标准的解决姿势有两种一种是打破依赖环把其中一段改成接口或者干脆让A不依赖B、B不依赖A另一种是给其中一个构造参数加LazyService public class ServiceA { private final ServiceB serviceB; public ServiceA(Lazy ServiceB serviceB) { this.serviceB serviceB; } }加了Lazy后Spring在实例化A时不再立刻创建完整的B而是生成一个B的代理对象塞进构造器。真正调用B的方法时代理才触发B的完整创建。这相当于把一个“硬依赖”变成了“延迟依赖”环自然就解开了。实际项目里Lazy是解决构造器循环依赖最省事的办法代价是失去了编译期类型检查的那部分严格性但比推翻重来要快得多。5.3 用了Async、事务代理循环依赖会出现哪些坑依赖了三级缓存也不代表万事大吉。最典型的一个坑是Bean A被循环依赖提前暴露了原始对象但A在初始化阶段又通过其他后置处理器被做成了代理对象。此时早期引用和最终代理是两个不同对象Spring发现后会在日志里打一个警告Bean serviceA is a proxy of a non-eligible bean and was not eligible for auto-proxying这种问题在Async、Transactional、配置类代理等场景里非常常见。原因是AOP代理的创建被提前了但Spring并不确定该代理是否完整应用了所有增强器。解决办法有几个一是尽量消除循环依赖本身二是用Lazy打断引用链三是把需要代理的Bean配置成Scope(proxyMode ScopedProxyMode.TARGET_CLASS)或调整后置处理器的order但这几种都是权宜之计最推荐的还是不要循环依赖。5.4 Spring Boot 2.6之后默认行为变了别再依赖三级缓存从 Spring Boot 2.6 开始循环依赖默认是不再被允许的。官方在升级说明里写得很明确应用启动时会直接报错除非你在配置文件里手动打开开关spring: main: allow-circular-references: true到了3.x版本这个配置依然保留但官方已经不推荐用它了。核心原因是循环依赖会让Bean的生命周期变得不透明隐藏设计缺陷也影响未来对单例Bean做更激进的并发优化。所以新项目启动时如果看到循环依赖报错我的建议是别急着打开开关先重构代码结构。开关是项目部级别的打开后所有循环依赖都会被放过属于“所有问题一揽子隐藏”的馊主意。我维护的老项目里也出现过这种情况有个服务启动就报循环依赖团队为了赶版本给allow-circular-references设成了true。后来加需求的同事又写了一个新的循环依赖启动照常通过但运行期每次请求都莫名卡顿查了半天才定位到是代理循环引用的初始化死锁问题。把这个开关关掉把循环依赖拆掉问题自然消失。所以我的体会是这个开关绝对不能当成救火队员。5.5 常见问题速查表问题现象根本原因推荐解决方式构造器注入触发循环依赖Bean实例化阶段就需要对方三级缓存尚未建立改用字段注入或加Lazy或重构依赖关系字段注入循环依赖启动报错Spring Boot 2.6默认禁止循环依赖打开allow-circular-references但强烈建议重构循环依赖中存在AsyncBean早期引用与最终代理对象不一致用Lazy打断环或移除Async涉及的循环关系循环依赖Bean上的事务切面失效代理对象创建时机与早期引用不一致优先拆环不要依赖三级缓存来兜底代理循环依赖链路过长、定位困难多个Bean互相引用形成复杂环根据启动报错链条逐步打断优先从业务上解耦5.6 面试追问怎么答才有亮点面试官如果问“为什么Spring能解决字段注入的循环依赖却不能解决构造器注入的循环依赖”你可以围绕“三级缓存工厂注册的时机是在实例化之后”来答。如果问“三级缓存和二级缓存的区别是什么”落脚点应该放在“延迟代理创建、避免无谓AOP”上。如果问“是不是所有循环依赖都能解”要明确说不是只有单例、非抽象、允许提前暴露的Bean才适用原型Bean根本不会进三级缓存构造器循环依赖也救不了。能讲清楚这些面试效果会比干巴巴背一句“Spring用三级缓存解决循环依赖”好得多。最近我也在看手写Spring相关的资料发现网上不少示例代码在实现三级缓存时把getEarlyBeanReference直接写死了返回原对象忽略了AOP场景。这种实现解决常规循环依赖没问题但一旦涉及代理写出来的容器行为和真正的Spring差别很大面试官一下就听出有没有理解到点子上。6. 我的实战体会与建议从实际写代码的角度看三级缓存这个设计确实精巧但它在现代Spring Boot工程里的应用场景越来越窄。因为循环依赖本身就是一种“设计味道”很重的写法两个Bean互相依赖往往意味着两者的职责边界没有划清楚。我在重构老项目时定过一个原则任何新的循环依赖一律不允许出现字段注入发生的循环依赖必须通过提取新的Service类来拆环。这样虽然会多写几个类但每个类都变得更容易测试依赖关系也更清晰。至于面试把三级缓存的运行原理吃透不是为了让你在项目里刻意制造循环依赖而是为了遇到报错时能快速判断“这是不是循环依赖、要不要拆、怎么拆”。这才是读源码最有价值的地方。最后分享一个小技巧排查循环依赖时不用看整条报错链直接从第一个Autowired爆点开始往下数两层基本就能定位到哪两个Bean在互相引用。改代码时也不要一次改完整个链路先从环上最“外围”的一段拆起每次只解一个依赖关系然后重启验证效率比盲目大改高得多。