1. 循环依赖这个老问题,到底卡在哪一步
先聊一个几乎所有做后端的人都会遇到的报错场景:项目启动的时候,Spring容器正在创建Bean,结果直接抛出一句BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?。看到这行日志的时候,大部分人的第一反应是去检查是不是 A 依赖了 B、B 又依赖了 A,然后开始纠结要不要加@Lazy,或者把某个依赖改成ApplicationContext.getBean()来绕开。
这个报错背后的机制,就是 Spring 单例 Bean 的创建流程里,为了解决循环依赖而设计的缓存体系。三层缓存——singletonObjects、earlySingletonObjects、singletonFactories——是 Spring 容器在创建单例 Bean 时的三个“停车场”。平时我们写代码感知不到它们的存在,可一旦循环依赖出现,这三层缓存就成了能不能把 Bean 顺利创建出来的关键。网上关于“三级缓存原理”的文章很多,但大多数只停留在“有三层缓存、存什么、取什么”这种表层,看完能跑通 Demo,却回答不了那个最扎心的问题:为什么要搞三级?二级到底行不行?
这篇文章我会从 Spring 源码的视角,把这个问题的来龙去脉拆开讲清楚。从容器创建 Bean 的过程、缓存的读写时机、到 AOP 代理和循环依赖如何纠缠在一起,最后给出实际排查方法。不看源码也能看懂,看完你会明白,Spring 这一套设计不是闲得慌,每一级都有它存在的理由。
2. 三级缓存分别都在干什么
2.1 三个 Map 的定位和分工
Spring 的DefaultSingletonBeanRegistry里定义了三层缓存,这是整个单例 Bean 管理的核心数据结构。去掉注释和并发控制,本质就是三个 Map:
// 一级缓存:完整单例Bean的最终存放位置 private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); // 二级缓存:早期暴露的Bean引用,此时Bean还没完成完整初始化 private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); // 三级缓存:存放Bean的ObjectFactory,可以理解为Bean的“生产工厂” private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);很多文章喜欢用“停车场”来类比,但我觉得更像一条“生产流水线”。一级缓存是成品仓库,里面的 Bean 属性填完了、初始化方法跑完了、代理也做好了,可以直接交给任何调用方使用。二级缓存是半成品暂存区,Bean 已经实例化出对象了,但是属性可能还没填完,初始化方法可能还没执行,这个对象是被提前暴露给别人的。三级缓存最特殊,它里面存放的是一个ObjectFactory函数式接口,不是 Bean 本身。Spring 把“如何创建一个早期引用”的算法封装成了工厂对象,存放在这里,等真正需要的时候再调用它来生成早期对象。
理解三个 Map 的定位,是理解整个三级缓存机制的入口。一级是最终归宿,二级是临时过渡,三级是延迟决策——这个“延迟”非常关键,后面讲 AOP 的时候你会看到它到底在等什么。
2.2 三级缓存的写入和读取时机
三级缓存的写和读,在getSingleton和createBean两个核心方法之间有非常清晰的顺序。用代码来感受一下最直接,Spring 在创建单例 Bean 时,getSingleton(String beanName)这个方法决定了从哪一层拿对象:
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; }再看写入时机。createBean流程走到实例化之后、填充属性之前,Spring 会调用addSingletonFactory把当前 Bean 的工厂放入三级缓存:
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,就去调getSingleton找那个 Bean;如果那个 Bean 也反过来需要当前 Bean,就能从三级缓存里找到工厂,提前暴露一个引用。
Bean 完整创建完成后,调addSingleton把成品放入一级缓存,同时清理二级和三级缓存中的临时数据:
protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); this.earlySingletonObjects.remove(beanName); } }到这里三层缓存的基本流程就清楚了:实例化 → 放入三级缓存 → 填充属性 → 初始化 → 放入一级缓存。中间如果发生循环依赖,二级缓存就是那个“被临时征用”的存放点,三级缓存负责在最后一刻决定暴露原始对象还是代理对象。
3. 二级缓存行不行,得从代码推演说起
3.1 去掉三级缓存,直接暴露原始对象会怎样
网络上经常有一种声音:“三级缓存是为了解决循环依赖,那我把三级缓存去掉,用二级缓存直接存原始对象不就行了?”这个问题其实隐藏了一个前提,就是大多数讨论循环依赖的文章,用的是最简单的、没有 AOP 参与的A依赖B、B依赖A场景。在这个场景下,二级缓存确实够用。
我们来推演一下“一级缓存 + 二级缓存”的方案。假设 A 和 B 相互依赖,且两者都不需要 AOP。没有三级缓存时,A 实例化后直接把原始对象放入二级缓存,然后填充属性时发现需要 B,于是去创建 B。B 实例化后同样把原始对象放入二级缓存,填充属性时发现需要 A,从二级缓存拿到 A 的原始对象,继续完成 B 的创建。B 创建完成后放入一级缓存,A 拿到 B 的引用,继续完成自己的属性填充和初始化,最终放入一级缓存。看起来一切正常,全程没有三级缓存参与,循环依赖也能解开。
那 Spring 为什么不这么做?如果只是把三级缓存里的ObjectFactory替换成直接存原始对象,就省了一层 Map,代码也简单了。问题的关键在于 AOP。一旦 AOP 介入,事情就变复杂了。
3.2 AOP 代理对象在循环依赖里的特殊要求
正常情况下,Spring 创建带 AOP 的 Bean 时,代理对象是在 Bean 初始化完成后,由AbstractAutoProxyCreator在postProcessAfterInitialization阶段创建的。这个过程发生在属性填充之后,所有依赖都注入完成,初始化方法也执行完了,这样代理对象内部的 target 才是一个完整的、可用的原始对象。
但循环依赖打破了这个顺序。看一个典型场景:A 需要依赖 B,B 需要依赖 A,并且 A 需要被 AOP 代理。A 实例化后放入三级缓存,填充属性时发现需要 B,开始创建 B。B 实例化后填充属性时发现需要 A,这时候 A 还没走到初始化阶段,更没走到创建代理的阶段。如果此时只把 A 的原始对象暴露给 B,等 A 的整个流程走完,生成一个代理对象放进一级缓存,B 手里拿到的还是那个原始对象。这个原始对象没有 AOP 的能力,事务、切面逻辑全部失效,这是一个非常隐蔽的 Bug。
三级缓存正好解决这个问题。singletonFactories里存放的ObjectFactory在执行getObject()时,会经过getEarlyBeanReference这个扩展点。SmartInstantiationAwareBeanPostProcessor在这里被调用,AbstractAutoProxyCreator正是实现了这个接口,才能够在“提前暴露引用”的时刻,抢先创建 AOP 代理。回头看那段源码里的关键一行:singletonObject = singletonFactory.getObject(),这一步拿到的可能已经是代理对象了,而不是原始对象。
所以“二级缓存到底行不行”这句话的正确回答是:在纯循环依赖场景下,二级缓存只是勉强能用,但它把“延迟创建代理”这个灵活性彻底牺牲了。你如果想去掉三级缓存,就得把代理创建时间从“初始化之后”提前到“实例化之后”,这会改变所有 Bean 的创建顺序,引发一堆负面连锁反应。
4. 源码里的关键机制:为什么工厂比直接存对象更聪明
4.1 getEarlyBeanReference 决定代理何时出手
三级缓存机制最核心的不是那三个 Map,而是getEarlyBeanReference这个扩展点。也就是说,三级缓存的价值本质上是一个“回调时机”,它让 Spring 有机会在“Bean 被其他人引用之前”完成代理的提前创建。源码在AbstractAutoProxyCreator中是这样实现的:
@Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey = getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }关键行为很明确:这个 Bean 一旦被提前引用,就记录在earlyProxyReferences里,同时调用wrapIfNecessary执行代理包装。wrapIfNecessary会判断当前 Bean 是否需要切面、是否符合 Pointcut 规则,如果没有命中切面,返回原始对象;命中切面,则返回代理对象。
拿到这个代理对象之后,Spring 会把它放到二级缓存earlySingletonObjects里,并移除三级缓存中的工厂。后续再有人引用这个 Bean,直接走二级缓存拿到代理对象,不会再重复创建工厂。等到这个 Bean 的完整创建流程走到postProcessAfterInitialization时,AbstractAutoProxyCreator会先检查earlyProxyReferences,如果发现这个 Bean 已经提前代理过了,就直接跳过,不再创建第二次代理。这就是为什么不会有“两个代理对象”的问题。
4.2 代理时序被三级缓存巧妙拉开
如果把三级缓存去掉,只留二级缓存,并且二级缓存里直接放原始对象,代理创建的唯一时机就只剩postProcessAfterInitialization。这在非循环依赖场景下没问题,但一旦出现循环依赖,早期引用的对象和最终成品对象就会不一致,导致 B 拿到的是“未代理”的 A,而容器最终保存的是“已代理”的 A。两套对象同时存在于内存里,行为还不一样,这种问题在线上排查起来极其痛苦。
再说把二级缓存改成“直接放代理对象”的方案。如果 Bean 实例化后立刻创建代理,那代理内部包装的是一个没有填充属性、没有执行初始化方法的空壳对象。@Autowired、@Value都还没来得及注入,这个代理一放出去,被其他 Bean 拿来使用,调用任何业务方法都可能因为内部属性为空而抛 NullPointerException。代理对象的 target 必须等所有属性填充完才有意义,这是 Spring 容器创建 Bean 的基本顺序。
所以三级缓存不是无缘无故多出来的。它把“实例化”和“代理生成”解耦开,让代理创建的决策延后到“有 Bean 真正需要引用当前对象”的时刻。如果整个创建链路顺利走完,没人提前引用,那就把原始对象留在三级缓存里,等postProcessAfterInitialization再统一创建代理,行为和非循环依赖场景完全一致。如果确实有人提前引用了,工厂就能在那一瞬间生成合适形态的引用,保证给出去的引用和最终成品是同一个代理。
简单说,Spring 在一开始不急着做决定,而是把决定权推迟到不得不做决定的那一步。这个思想在工程上很常见,本质就是一种延迟绑定、按需创建的策略。
5. 实操排查:循环依赖问题怎么定位和解决
5.1 怎么验证三级缓存真的生效了
看再多源码,都不如亲手验证一次来得有底气。想做实验的话,构造一个最简单的循环依赖:两个 Service,A 依赖 B,B 依赖 A,然后在创建过程中打断点。
我推荐三个观察点。第一个是DefaultSingletonBeanRegistry.getSingleton(String beanName, boolean allowEarlyReference)这个方法,断点打在里面可以看到一级、二级、三级缓存逐个查找的过程。第二个是DefaultSingletonBeanRegistry.addSingletonFactory,观察三级缓存是什么时候写入的:A 实例化完成、属性填充之前。第三个是AbstractAutoProxyCreator.getEarlyBeanReference,你可以在入口处看到earlyProxyReferences被记录,然后观察wrapIfNecessary返回的到底是原始对象还是代理对象。
如果只是用 Debug 观察还不够直观,可以打开 Spring 的循环依赖日志来跟踪触发顺序。在application.yml里加上下面两行:
logging: level: org.springframework.beans.factory.support.DefaultSingletonBeanRegistry: DEBUG日志级别调到 DEBUG 后,控制台会输出类似Creating shared instance of singleton bean 'a'、Singleton bean creation not allowed while singletons of this factory are in creation之类的信息。结合断点一起看,就能清晰还原 A 创建到一半、被 B 拉取引用、B 复用 A 的完整过程。
5.2 Spring Boot 2.6 之后默认禁用了循环依赖
这是很多刚升级版本的人踩到的坑。Spring Boot 2.6 版本开始,官方默认禁止循环依赖,哪怕你写的是能正常工作的循环依赖代码,启动时也会直接报错。如果你的项目大量依赖循环依赖来组织业务逻辑,升级后就得面对一堆报错。
处理方式有几种。最快的方式是在配置里显式放开限制:
spring: main: allow-circular-references: true但这只是治标不治本。我更建议把配置当成一个“过渡方案”,真正要做的还是调整代码结构。循环依赖往往是设计上一个信号:该拆分了。看看依赖的方向是否真的合理,能不能把公共逻辑抽到一个独立的 Service 中,或者让其中一方通过构造器注入的方式重新设计。
还有两个很实用的替代方案:
第一种是@Lazy注解,在注入点加一个延迟加载语义,让 Spring 先注入一个代理占位符,真正调用时才去获取目标 Bean。这种方式能快速绕开循环依赖问题,但也会掩盖真正的结构问题,建议谨慎使用。
第二种是ObjectProvider<T>,通过provider.getIfAvailable()的方式在运行时手动获取 Bean,把硬依赖变成软依赖。这种方案结合业务判断会比较灵活,不会直接改变 Bean 的创建时序,适合“某个 Bean 只有在特定条件下才需要另一个 Bean”的场景。
5.3 常见报错与排查思路速查表
实际工作中,循环依赖报错的场景其实比想象中多,而且不一定都那么直白。我把最常见的几类情况整理成一个速查表,方便你按图索骥:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
启动报BeanCurrentlyInCreationException | 存在无法通过三级缓存解决的循环依赖,比如构造器注入、非单例作用域 | 重构依赖关系,改用@Lazy或ObjectProvider |
| 属性注入的 Bean 是 null | 依赖关系设计有环,且涉及代理,提前暴露的对象没有完成属性填充就被使用 | 重新梳理依赖方向,避免依赖环;检查是否过度使用 AOP |
| 升级 Spring Boot 2.6+ 后启动报错 | 循环依赖被默认禁止 | 先开启allow-circular-references过渡,随后逐步重构 |
| 加了事务注解但切面不生效 | A 依赖 B、B 依赖 A,代理提前创建导致 B 拿到的是非代理引用 | 确认是否真的需要提前代理,尝试调整切面范围或调整 Bean 结构 |
| 同一个 Bean 出现两个不同对象 | 代理对象和原始对象被混用 | 在getEarlyBeanReference和postProcessAfterInitialization打断点,确认代理创建时机 |
排查这类问题,我的经验是先把配置里各种 Bean 的依赖关系画出来,找到形成环的那一段,再判断这个环是哪一种类型:构造器注入、字段注入还是方法注入。构造器注入形成的循环,三级缓存也救不了,因为实例化阶段就卡住了;字段注入和 setter 注入形成的循环,Spring 才有机会用提前暴露的工厂来化解。
6. 从缓存设计到工程取舍
聊到这里,回头看“为什么需要三级缓存?二级缓存到底行不行?”这个问题,答案其实已经摆在面前了:二级缓存在没有 AOP 的简单场景下勉强可行,但它牺牲了代理创建的灵活性,把“延迟决策”变成了“提前决策”,一旦 AOP 介入就会出现代理失效或者属性为空这类致命问题。三级缓存通过存放ObjectFactory的方式,把创建早期引用的决策延后到真正有人引用的时刻,既兼容了正常创建流程,也处理了循环依赖的异常场景。
我个人在实际排查中最大的体会是:不要一遇到循环依赖就想着靠缓存机制去兜底。三级缓存是 Spring 给开发者的一个安全网,不是让你随便把代码写成乱麻的理由。真正优雅的工程结构里,循环依赖应该是极少数情况。适当用@Lazy调整 Bean 获取时机,或者把公共逻辑抽离出来,都比依赖容器的“特殊能力”更稳妥。实在绕不开的时候,至少要知道这一层机制是怎么运作的,真出了问题也不至于两眼一抹黑,对着那一长串 Stack Trace 发呆。
最后再说一个我在团队里经常分享的检查技巧:写新模块之前,先把各个 Bean 之间的依赖关系和数据流向理清楚,尤其是涉及事务、异步、定时任务这些 AOP 能力的时候,多花十分钟梳理调用链,能省掉后面一整周的排查时间。Spring 的源码设计处处都在为灵活性和稳定性做平衡,理解三级缓存,不只是为了应付一道面试题,更是为了在遇到类似设计决策时,能多一层思考的维度。