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 Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); private final Map<String, 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的早期引用注入给B,B继续完成属性填充和初始化,最后作为成品放进一级缓存。
- 回到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到底做了什么
三级缓存里的工厂最终会调用getEarlyBeanReference:
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; }它遍历的是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还是最开始的bean,Spring会把earlySingletonReference(早期代理或原始对象)作为最终结果。这样容器最终保存的,跟循环依赖中B拿到的引用是同一个。如果初始化过程中,exposedObject被后置处理器替换成了另一个对象,同时还有别的Bean注入了早期引用,Spring会认为容器可能处于不一致状态,进而抛出BeanCurrentlyInCreationException,提示“这个Bean已经被其他Bean以原始状态注入,但最终又被包装了”。
这个细节非常重要,它说明Spring对待循环依赖并不是无脑容忍:允许你提前暴露引用,但必须保证最终暴露给你的是同一个对象,否则宁可启动失败。
5. 高频问题与排查经验
5.1 有三级缓存,为什么还会报BeanCurrentlyInCreationException
很多人以为三级缓存能解决所有循环依赖,实际上只能解决单例Bean的setter/字段注入循环依赖。以下情况都会报错:
- 构造器注入循环依赖:A的构造器需要B,B的构造器需要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-references=true。但更推荐的做法是重构,避免循环依赖本身。
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场景下会出现什么结果。亲手踩过一次坑,比背十遍源码理解都深。