
Spring的循环依赖处理一直是面试高频点也是很多人看源码时最容易绕晕的地方。面试官问“spring为什么使用三级缓存而不是两级”表面上是考三个Map实际上是在问一个更底层的问题容器如何安全地在Bean还没初始化完成时把半成品引用递给别人。如果你只背得出singletonObjects、earlySingletonObjects、singletonFactories三个名字一旦追问“为什么不能砍掉一级”大概率会卡壳。这篇文章不抄源码我就从循环依赖的产生讲起把三级缓存的每一步都拆开然后重点分析“两级缓存到底会出什么事故”。1. 先搞清楚一个问题没有缓存Spring根本玩不转循环依赖1.1 什么是循环依赖它怎么出现的先看一个最简单的场景。两个Service互相注入对方Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }Spring启动时创建AService发现它需要BService于是去创建BService又发现需要AService如果没有任何干预创建逻辑就会在A - B - A - B之间无限递归下去直到栈溢出。这就是循环依赖也叫循环引用。这种互相引用的代码在业务项目里并不少见尤其是老系统里Service层职责不清的时候。两个服务互相调用对方的方法看起来也不是不能运行但Spring的默认单例Bean创建流程决定了必须有一种机制来打断这种递归否则容器根本启动不起来。1.2 单例Bean的创建分成三个阶段要理解缓存必须先理解Spring创建单例Bean的三个关键阶段。第一是“实例化”也就是通过构造器或工厂new出一个原始对象此时对象的属性都是null或者说是默认值第二是“属性填充”Spring会把Autowired、Resource、XML里配置的依赖逐个塞进字段或setter第三是“初始化”执行InitializingBean、PostConstruct、Aware接口等动作同时也会跑一遍BeanPostProcessor比如生成AOP代理。这里的核心点在于实例化和属性填充之间有一个时间窗口。Spring完全可以在实例化之后、属性填充之前把“半成品对象”提前暴露出去让循环依赖的另一方先拿到这个引用。否则等到属性填充时再去找依赖对方可能还在创建中就永远等不到结果。1.3 没有提前暴露时会发生什么假设没有中间缓存只有一张“成品单例池”的Map。AService创建时发现需要BService去BService的单例池查询发现没有于是开始创建BService而BService又反过来找AService单例池还是没有只能再创建AService。这就变成了递归创建永远不会结束。如果你自己写一个最简单的IoC容器肯定也会遇到这个问题。解决办法从直觉上想就是既然一个Bean已经实例化出了原始对象为什么不先把它临时放进某个“中间状态”的容器这样一来BService再找AService时虽然拿到的不是最终成品但至少它不是空引用可以继续完成自己的创建。这就是缓存的朴素起点。2. 三级缓存不是三个Map那么简单关键是每一级存的什么2.1 先直接看三张缓存的真面目在Spring的DefaultSingletonBeanRegistry里这三张缓存是这样定义的private final MapString, Object singletonObjects new ConcurrentHashMap(256); private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); private final MapString, ObjectFactory? singletonFactories new HashMap(16);singletonObjects一级缓存也就是大家常说的单例池存放已经完全初始化、可以直接使用的成品Bean。earlySingletonObjects二级缓存存放“提前暴露”的早期Bean引用。这个引用可能是原始对象也可能是被AOP代理过的对象但它的状态是“未完整初始化完成”。singletonFactories三级缓存存放的是ObjectFactory不是Bean实例本身。每次调用这个工厂的getObject()才会真正得到当前要暴露的早期引用。这个区别非常重要。很多人以为三级缓存里存的是“半成品对象”其实存的是工厂。用一句话概括一级存成品二级存早期引用三级存能生成早期引用的延迟工厂。2.2 为什么第三级不直接放Bean要包一层ObjectFactory因为Spring在Bean实例化完成后的那一刻并不知道这个Bean是否真的会被其他Bean提前引用。如果没有循环依赖这个Bean的getEarlyBeanReference就根本不需要执行它可以老老实实等到属性填充、初始化完成之后再走正常的BeanPostProcessor流程。三级缓存里的ObjectFactory做的事情是延迟计算。Spring在实例化完成后干的第一件事不是往缓存里放成品而是往singletonFactories里放一个lambda表达式addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean));只有当别人真正依赖这个Bean并在创建过程中触发了getSingleton(beanName, true)时这个getEarlyBeanReference才会被调用。这样的设计避免了每个Bean在创建初期都白白做一次AOP判断。不是不需要而是“等你真正需要的时候再给”。2.3 三级缓存如何协同解决循环依赖用一个完整时序来说明。假设容器按顺序创建AService和BService两者互相依赖Spring实例化AService得到一个原始A对象把ObjectFactory放入三级缓存。开始给A填充属性发现需要BService于是调用getBean(bService)。Spring实例化BService同样把B的工厂放入三级缓存。开始给B填充属性发现需要AService于是getBean(aService)。一级缓存里没有A但发现A正在创建中于是去二级缓存查也没有继续去三级缓存找到A的工厂调用getObject()拿到A的早期引用把这个早期引用放进二级缓存同时把A的三级缓存删除。把A的早期引用注入给BB继续完成属性填充和初始化最后作为成品放进一级缓存。回到A的创建流程B已经就绪A拿到B对象继续完成自己的初始化最后A也作为成品放进一级缓存。这个流程里最关键的一步是第5步从三级缓存拿到工厂结果之后立刻放进二级缓存。为什么要多这一步因为如果只有三级缓存每次其他Bean来获取A的引用时都要执行一次工厂可能会生成不同的代理对象破坏单例语义。二级缓存负责把工厂结果固定住保证整个容器里同一个 Bean 的早期引用只有一份。2.4 为什么说“至少需要三级”才成立如果要做两级缓存能砍掉哪一级砍掉一级缓存显然不行没有地方存最终成品。那砍掉二级缓存呢保留“成品Map”和“工厂Map”。这种情况下每次B和其他Bean找A时都要执行一次A的singletonFactories里的工厂。如果工厂每次都创建一个新的代理对象那么Bean之间的引用就出现多份实例单例名存实亡。即便工厂返回原始对象在并发场景下也存在重复执行和状态同步问题。所以工厂执行结果必须缓存下来这个缓存就是二级缓存。于是自然有了三张Map成品Map、早期引用Map、延迟工厂Map。所以从“数据结构”的角度看最少确实需要三个容器。当然这只解释了数量问题还没解释为什么二级缓存不能直接用Bean更关键的原因在AOP和BeanPostProcessor的时机上。3. 只保留两级会发生什么从AOP代理和Bean后置处理器找答案3.1 如果二级缓存直接放原始对象会出什么事假设我们把三级缓存去掉只剩一级成品缓存和二级早期缓存。在A实例化完成后直接把原始A对象放进二级缓存。表面上看B创建时可以从二级拿到A打破循环。但问题在于如果A需要在初始化后生成AOP代理对象比如加了Transactional最终一级缓存里存的是代理对象而不是原始对象。而B在创建过程中拿到的却是原始A对象。两个对象不一致B注入的是未代理的原始Service事务注解、切面逻辑在B调用A时全部失效。这不是简单的性能问题而是正确性问题。Spring依赖注入的原则是你拿到的对象应该跟容器里最终暴露的是同一个。早期引用和成品分离会导致一个Bean在容器里有“两副面孔”这是绝对不能接受的。3.2 如果二级缓存直接放代理对象又会出什么事为了避免不一致有人会说那把二级缓存里直接放代理对象不就行了也就是在A实例化完之后立即执行getEarlyBeanReference把生成的代理放进二级缓存。这个做法确实可以让B拿到A的代理也保证最终容器里使用的就是代理。但问题是所有Bean都要在实例化后立刻进一遍“是否要生成代理”的逻辑不管它有没有循环依赖也不管它的属性有没有填充。这意味着每个普通Bean都要被提前执行一遍AOP判断显然是一种浪费。更麻烦的是有些BeanPostProcessor的处理依赖Bean完整的初始化状态。比如ApplicationListenerDetector要把容器里的ApplicationListener取出来依赖的是最终Bean如果过早地生成代理并把代理当成目标后续的初始化流程可能会对这个代理做重复处理。Spring对后置处理器的执行顺序和时机非常敏感把“生成代理”从初始化阶段强行提前到实例化阶段会打乱整个Bean生命周期的设计。3.3 Spring早就用earlyProxyReferences保持了代理的一致性Spring里有一个容易被忽略的细节就是AbstractAutoProxyCreator维护了一个earlyProxyReferences缓存。看一下它两个方法的关键逻辑public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); if (!this.earlyProxyReferences.contains(cacheKey)) { this.earlyProxyReferences.add(cacheKey); } return wrapIfNecessary(bean, beanName, cacheKey); } public Object postProcessAfterInitialization(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); if (this.earlyProxyReferences.remove(cacheKey) ! null) { return bean; } return wrapIfNecessary(bean, beanName, cacheKey); }它的作用就是如果某个Bean因为循环依赖已经被getEarlyBeanReference提前代理过了那么正常初始化完成后的postProcessAfterInitialization就不要再代理一次直接返回原始Bean。但最终容器保存的又是从二级缓存拿到的那个早期代理对象。这样既保证了只有一次代理又保证了容器最终和早期引用是一致的。这个细节恰好说明Spring设计三级缓存的核心目的代理对象的生成时机必须可控不要因为一个Bean被循环依赖了就破坏整个后置处理器链路。如果直接砍掉第三级在实例化后立刻生成代理那么所有没有循环依赖的Bean也会被粗暴地提前包装整个生命周期就乱了。3.4 为什么Spring不用“预判哪些Bean会循环依赖”的方案理论上可以做一个依赖分析提前把所有参与循环依赖的Bean标记出来只对这些Bean提前生成代理其他Bean保持正常流程。但这样需要在容器启动时做全局的依赖扫描和分析而且Bean定义可能在启动过程中被修改判断结果不一定可靠。相比这种复杂方案三级缓存的设计非常巧妙用ObjectFactory延迟判断谁真的在循环依赖发生时去取这个引用谁才触发代理生成。局部、懒惰、无侵入复杂度只集中在真正需要处理循环依赖的那一瞬间。4. 源码关键路径拆解看几行代码比背一百个面试题有用4.1 从doCreateBean看“提前暴露”是怎么被安排上的在AbstractAutowireCapableBeanFactory.doCreateBean中第一步是实例化Bean第二步就是判断要不要提前暴露boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这里有几个条件要仔细看mbd.isSingleton()只有单例Bean才可能走三级缓存因为单例容器缓存的是同一个实例。allowCircularReferences是否允许循环依赖Spring Framework里默认是true但Spring Boot 2.6及以后默认改成false这解释了为什么很多人在升级Boot版本后突然遇到循环依赖启动失败。isSingletonCurrentlyInCreation(beanName)如果当前Bean不在创建中说明不需要提前暴露。满足条件后Spring不是把Bean放进缓存而是把一个ObjectFactory放进去。这等于告诉其他Bean你可以来找我拿早期引用但先别急等我真正需要时再通过工厂计算。4.2 getSingleton第三级查找逻辑当B需要A时会调用DefaultSingletonBeanRegistry.getSingleton(String beanName, boolean allowEarlyReference)。核心代码很经典protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }查找顺序是一级、二级、三级。三级工厂执行完结果立刻放入二级缓存同时删除三级。这样做的好处是同一个Bean的早期引用只会生成一次不会因为多次循环查找导致多个代理对象。这里还有一个容易被忽略的点整段逻辑是放在synchronized块里的Spring在单例注册和早期暴露之间用了锁来保证多线程环境下不会出现重复创建。4.3 getEarlyBeanReference到底做了什么三级缓存里的工厂最终会调用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而不是普通的BeanPostProcessor。这里最常见的实现就是AbstractAutoProxyCreator。也就是说只有涉及AOP代理等“需要提前确定最终类型”的后置处理器才会参与早期引用处理。这也是为什么二级缓存如果一直放原始对象会有问题真正支持“提前暴露”的后置处理器没有得到执行机会其他Bean拿到的就是还没被包装的原始对象。4.4 初始化完成后Spring如何保证最终容器对象和早期暴露对象一致Bean完成初始化后doCreateBean还会再做一次校验代码大致是这样的if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { exposedObject earlySingletonReference; } // 如果发现exposedObject被后置处理器替换成了新对象且已经有其他Bean依赖了早期引用就会抛异常 } }如果初始化过程中没有后置处理器替换原始对象exposedObject还是最开始的beanSpring会把earlySingletonReference早期代理或原始对象作为最终结果。这样容器最终保存的跟循环依赖中B拿到的引用是同一个。如果初始化过程中exposedObject被后置处理器替换成了另一个对象同时还有别的Bean注入了早期引用Spring会认为容器可能处于不一致状态进而抛出BeanCurrentlyInCreationException提示“这个Bean已经被其他Bean以原始状态注入但最终又被包装了”。这个细节非常重要它说明Spring对待循环依赖并不是无脑容忍允许你提前暴露引用但必须保证最终暴露给你的是同一个对象否则宁可启动失败。5. 高频问题与排查经验5.1 有三级缓存为什么还会报BeanCurrentlyInCreationException很多人以为三级缓存能解决所有循环依赖实际上只能解决单例Bean的setter/字段注入循环依赖。以下情况都会报错构造器注入循环依赖A的构造器需要BB的构造器需要A双方实例化时谁都拿不到谁。因为构造器执行发生在“提前暴露”之前三级缓存还没有机会介入。prototype作用域循环依赖多例Bean不缓存即使提前暴露也不能保证后面每次获取都是同一个实例所以直接抛异常。Spring Boot 2.6以后默认关闭循环依赖如果项目从旧版本升级启动时经常会看到“The dependencies of some of the beans in the application context form a cycle”的提示。如果你只是想在老项目临时恢复循环依赖支持可以在application.properties里设置spring.main.allow-circular-referencestrue。但更推荐的做法是重构避免循环依赖本身。5.2 为什么prototype不能使用三级缓存解决因为三级缓存这套机制是跟单例注册表强绑定的。单例Bean有“正在创建中”的标记有singletonFactories可以重复使用而prototype每次getBean都要新建实例即使靠ThreadLocal判断出循环依赖也不能让双方共享同一个中间引用。多例Bean的作用域本来就是“每次都是新对象”如果为了解决循环依赖而共享半成品反而违背了prototype的语义。5.3 排查循环依赖的实战方法遇到循环依赖报错我一般按这个顺序排查看启动日志异常信息里会列出循环链路上的Bean名称先找出谁和谁互相引用。在IDEA里使用“Show Dependencies”或者安装依赖分析插件从Bean依赖关系图里看清引用方向。如果只是在某个注入点需要打断循环可以在字段或setter上直接加Lazy。Lazy的本质是生成一个代理对象注入真正调用时才去解析依赖把循环依赖的时机延后。长期来看优先把Service里互相调用的逻辑拆开或者把共用的底层能力下沉到另一个对象避免高层之间互相持有。5.4 几个容易搞错的认知误解实际情况三级缓存存的是半成品Bean实例三级缓存存的是ObjectFactory按需生成早期引用二级缓存存的是原始对象的引用二级缓存存的可能是原始对象也可能是AOP代理对象没有三级缓存就无法解决循环依赖从数据结构上看可以设计出两级方案但会带来代理时机和一致性问题Spring Boot 2.6用不到三级缓存了Boot只是默认关闭了循环依赖开关Spring底层的三级缓存机制仍然存在三级缓存可以解决构造器循环依赖不能构造器阶段还没有提前暴露机会6. 一些个人感受和扩展我从这个问题里收获最大的不是三个Map而是Spring对“时机”的控制。一个对象从实例化到初始化完成中间有太多可以被扩展的节点随便提前或推迟一个节点都可能破坏一致性。三级缓存允许Spring“延迟决策”直到真正发生循环依赖时才去生成早期引用这比一上来就大包大揽更加优雅。如果你正在啃Spring源码建议看完这篇文章后用最小代码写一个简化版容器自己实现一次循环依赖再尝试改成两级缓存运行一下看AOP场景下会出现什么结果。亲手踩过一次坑比背十遍源码理解都深。