先问一个扎心的问题循环依赖这个知识点你是不是已经把三个 Map 的名字背得滚瓜烂熟可真到线上启动报出 BeanCurrentlyInCreationException 时还是只能对着日志发呆我见过不少人在群里问这个异常答案永远绕不开“三级缓存”。但如果你追问一句为什么非要三级一级行不行二级行不行很多人就卡壳了。这篇文章我想换一种讲法——不背结论从头推导一遍 Spring 的设计思路。你会发现三级缓存真没有多玄乎它就是在“对象还没创建完但别人已经急着要用它”这种尴尬场景下被逼出来的一套缓存策略。1. 循环依赖的根因Java new 对象的顺序问题比背异常有意思1.1 一个随手就能复现的失败案例先说一个最小复现。两个类互相依赖用最常见的字段注入Component public class ComponentA { Autowired private ComponentB componentB; } Component public class ComponentB { Autowired private ComponentA componentA; }启动容器大概率会看到这样一段日志BeanCurrentlyInCreationException: Error creating bean with name componentA: Requested bean is currently in creation: Is there an unresolvable circular reference?这个异常本身的字面意思很直白你在创建一个 Bean 的过程中又回头去要一个正在创建的 Bean容器判断它无法在不死锁的前提下完成于是直接抛异常。但换个角度想ComponentA 需要 ComponentBComponentB 又需要 ComponentA这在业务上真的很罕见吗其实不罕见。一个典型的业务场景是订单服务依赖用户服务用户服务在某个特殊业务里又需要查询订单统计信息。设计上当然可以重构但现实里总有绕不开的时候。1.2 不用 Spring自己怎么打破这个循环先放下 Spring想想如果手写 Java 代码你会怎么解决ComponentA a new ComponentA(); ComponentB b new ComponentB(); a.setComponentB(b); b.setComponentA(a);这段代码是能正常运行的。关键在哪在于我们允许先创建出两个“空的”对象然后通过 setter 把彼此塞进去。这里有一个很核心的时序概念实例化instantiation和初始化initialization是两回事。new出来的是原始对象哪怕属性全是 null它也是一个合法的 Java 对象。在 setter 注入的场景下你只要在属性填充之前把对象引用提前暴露出去循环依赖就能被打破。而 Spring 把构建 Bean 的过程进一步拆成了实例化、属性填充、初始化三个阶段这就给“提前暴露半成品”留出了操作空间。构造器注入为什么不行因为new ComponentA(componentB)在执行的一瞬间就要求参数非空此时 ComponentB 根本还没创建出来没有任何“半成品”可以占位。两种注入方式的差异直接决定了循环依赖问题在不同场景下是“可解”还是“无解”。2. 从一级缓存推导到三级每一步都是被实际场景逼出来的2.1 一级缓存装成品的“单例池”先看最简单的缓存设计一个 Mapkey 是 BeanNamevalue 是创建完成的单例对象这就是singletonObjects也是平时说的“单例池”。private final MapString, Object singletonObjects new ConcurrentHashMap(256);问题来了如果 ComponentA 还没走完整个生命周期它能不能进一级缓存不能。因为一级缓存对外暴露的是“成品”任何从getBean拿到它的调用方都会认为这是一个完全初始化好的 Bean。如果 Spring 把只new出来、还没有填充属性的半成品放进一级缓存其他线程拿到这个 Bean 直接使用就会读到一堆 null 属性这比循环依赖本身更可怕。所以当出现循环依赖时需要一块不属于一级缓存、但又能让依赖方找到目标引用对象的临时区域。2.2 二级缓存半成品仓库看起来够用了顺着上面的思路很自然会想到增加一个earlySingletonObjects专门放“提前暴露”的半成品对象。private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16);ComponentA 实例化完成后立刻把它丢进二级缓存。后续 ComponentB 填充属性时发现需要 ComponentA直接来二级缓存拿问题是不是就解决了对于完全没有代理场景的普通 Bean答案确实是解决了。这也是很多人疑惑“为什么要三级缓存”的原因。二级缓存看起来已经把循环依赖解决了为什么还需要第三级问题出在另一个场景里。假设 ComponentA 需要被 AOP 代理。Spring 在 Bean 初始化完成后会通过postProcessAfterInitialization生成代理对象容器里最终存的、对外提供的都是代理对象。如果 ComponentA 是在没有生成代理的情况下被 ComponentB 提前引用那 ComponentB 持有的就是原始对象。等 ComponentA 后续完成初始化变成代理对象后B 手里的原始引用和容器里的代理对象就不是同一个了。后面所有基于 A 的 AOP 增强在 B 里面全部失效。所以二级缓存不是不行而是它必须在“提前暴露那一刻”就知道目标 Bean 是否需要代理。可现实是对象刚实例化完后续还有一堆 BeanPostProcessor 要执行目前并不能确定最终代理长什么样。如果盲目地提前生成一个代理放进二级缓存既可能生成多余代理也可能因为后置处理链还没走完而导致代理对象并非最终形态。2.3 三级缓存缓存的不再是对象而是一个“按需生产的工厂”这就是singletonFactories存在的理由。它缓存的是ObjectFactory不是对象本身。private final MapString, ObjectFactory? singletonFactories new HashMap(16);调用方需要提前引用时先拿到这个工厂对象再通过getObject()生成当前阶段真正应该暴露出去的引用。代理与否、增强与否都在这个工厂里实时决策。这里有个很精妙的地方同一个 Bean 只会被提前引用有限次但最终只会有一个引用被实际使用。用工厂延迟生产可以避免给所有不参与循环依赖的 Bean 白白做一次代理。参与循环依赖的 Bean 毕竟是少数绝大多数 Bean 根本不需要提前暴露直接走完生命周期后进一级缓存就行。我把这三个缓存的关系用一个类比来讲。一级缓存是已经出锅端上桌的菜二级缓存是切好但还没下锅的半成品备菜三级缓存则是后厨手里那张“按顾客需求加工”的菜单。你报一个菜名后厨立刻按需加工给你一份而不是把所有菜都提前切好堆在那里。为什么这张“菜单”很重要因为 Spring 在一开始并不知道这道菜最终要加多少调料要经过哪些 BeanPostProcessor它只能在有人真的点菜时才把当前状态下最合适的“菜品”交出去并且保证之后不会换一份新的。3. 一次完整循环依赖的链路走查A 和 B 互相注入时缓存到底怎么流转3.1 从创建 A 到创建完成的分步流水账下面这张表是 ComponentA 和 ComponentB 互相依赖时三级缓存走过的完整路径。请仔细盯着每一行里三个缓存的变化。步骤关键事件A 的缓存位置B 的缓存位置1容器开始创建 AgetSingleton(A)未命中无无2A 实例化完成将singletonFactory放入三级缓存三级缓存无3A 填充属性发现需要 B触发getSingleton(B)创建 B三级缓存无4B 实例化完成将singletonFactory放入三级缓存三级缓存三级缓存5B 填充属性发现需要 A触发提前引用 A升级到二级缓存三级缓存6B 完成初始化注册进一级缓存二级缓存一级缓存7A 通过属性注入或字段注入拿到 B 的引用二级缓存一级缓存8A 完成初始化从二级缓存移除并注册进一级缓存一级缓存一级缓存看到第 5 步到第 8 步核心逻辑已经清楚了A 通过三级缓存暴露整体引用从三级挪到二级最终 A 完成全部创建后进入一级缓存。B 在第 6 步就完成了创建它持有的 A 引用一直没变。3.2 关键动作一什么时候把 A 放入三级缓存源码位置在AbstractAutowireCapableBeanFactory.doCreateBean实例化完成后、属性填充之前有一段非常关键的代码boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }三个条件缺一不可Bean 必须是单例容器必须允许循环引用当前 Bean 必须正在创建中。这里出现的getEarlyBeanReference就是三级缓存和二级缓存的分水岭。earlySingletonExposure这个命名非常有意思。“提前暴露”本身是一种容器主动承担的兜底策略而不是默认的、理所当然的行为。如果你明确知道自己项目里有循环依赖又因为某些原因不想让 Spring 介入处理可以把allowCircularReferences设为 false容器会立刻恢复成最严格的那套检查逻辑。顺带提醒一点Spring Boot 2.6 之后默认把spring.main.allow-circular-references设为了 false也就是默认禁止循环依赖。这并不代表 Spring 框架本身不支持而是官方希望大家正面处理这个设计问题。所以很多旧项目升到新版本 Spring Boot 后突然启动失败大概率就是踩了这里。3.3 关键动作二getEarlyBeanReference 到底做了什么getEarlyBeanReference并不只是简单地返回原始对象。它的内部会遍历所有SmartInstantiationAwareBeanPostProcessor把决定权交给这些后置处理器。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; }多亏了这个设计Spring AOP 才得以和循环依赖共存。AbstractAutoProxyCreator作为SmartInstantiationAwareBeanPostProcessor的典型实现在getEarlyBeanReference阶段就会提前创建代理对象并把代理引用交给调用方。这样 ComponentB 拿到的就是代理后的 ComponentA而不是原始对象。还有一个很容易被忽略的细节一旦提前暴露时生成了代理对象后续 A 走完生命周期进入postProcessAfterInitialization时Spring 会通过earlyProxyReferences判断“这个 Bean 是不是已经暴露过代理了”。如果暴露过就不再二次包装确保整个容器内只有一个代理引用。这块逻辑保证了最终不会出现两个代理对象打架的问题。3.4 为什么不会出现“两个 A”这种脏数据很自然的一个疑问是A 已经在二级缓存里暴露过了后面初始化完成又要进一级缓存会不会产生两个对象答案是不会。A 的引用从创建开始就是同一个 Java 对象只是在不同阶段被放在不同 Map 里。最后注册一级缓存时Spring 做的事情是把二级缓存里的引用转移到一级缓存而不是重新创建一个对象。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; }注意这段代码里还有一个allowEarlyReference参数。它决定创建过程中其他 Bean 是否有资格拿到“半成品”。这个参数的开关级别很细既保留了对绝大多数 Bean 的严格管理又给循环依赖场景开了个小口子。4. 三级缓存使不上劲的场景构造器注入、prototype 与增强型 Bean4.1 构造器注入半成品没法参与构造前面已经说过构造器注入要求目标 Bean 在new出来之前就把依赖准备好。当 ComponentA 的构造器需要 ComponentB而 ComponentB 的构造器又需要 ComponentA 时两个对象都没有完成实例化就不存在任何“半成品”可以被提前引用。三级缓存解决循环依赖的前提是目标对象已经new出来了只差填充属性。如果连new这一步都需要对方存在那问题在根源上就无解。Spring 早年文档里也明确表示过构造器注入的循环依赖是无法被容器自动解决的。实际开发中遇到构造器循环依赖最干净的方案是换注入方式比如把其中一个依赖改成Lazy让 Spring 注入一个代理占位符或者改成 setter 注入。当然更建议从设计层面审视两个类是否真的需要互相持有完整引用。4.2 prototype 作用域容器根本不会缓存它singleton对应整个容器内一份实例prototype则是每次getBean都新建对象容器不会为它维护任何缓存也不存在“正在创建中”的登记状态。循环依赖解决链条里的三级缓存、二级缓存全部建立在“单例正在创建”的上下文上。换成 prototype 之后A 创建时发现需要 BB 创建时发现需要 A两边都在创建新对象但又都没有缓存可查系统只能递归到栈溢出。所以如果你看到循环依赖报错先看一眼作用域配置。某些依赖被误配成了 prototype可能根本不需要改代码结构把作用域改回 singleton问题就消失了。4.3 增强型 Bean 的隐藏坑Async 循环依赖可能拿到原始对象很多人以为 AOP 和循环依赖是天生兼容的其实这是建立在代理处理器恰好实现了getEarlyBeanReference的前提下。Spring 自家 AOP 体系的代理处理器确实实现了但一些非标准的增强逻辑并没有完整参与“提前暴露”这个过程。举个例子Async注解创建的代理其增强时机和 AOP 代理并不完全相同。如果某个 Bean 本身带Async又恰好处于循环依赖链路中它在提前暴露阶段拿到的可能还是原始对象而不是最终那个带异步增强的代理。依赖方拿到的引用不对异步逻辑就静默失效。这种问题极其隐蔽日志里不会报错只能靠业务表现去猜。这也是为什么官方一直在强调不要主动依赖循环依赖容器能兜底不代表这种方式是好的设计。三级缓存解决的是“已经发生循环依赖时不让系统崩溃”而不是鼓励大家制造循环依赖。4.4 遇到绕不开的循环依赖实际怎么改真遇到循环依赖我一般按这个顺序处理先看能不能抽公共依赖。A 和 B 互相依赖很多时候是把 A 里依赖 B 的那部分逻辑抽出来放到一个新组件里让 A 和 B 都依赖新的组件而不是互相依赖。确实抽不动的在注入点上加Lazy。比如 ComponentA 里注入 ComponentB 时写Lazy Autowired private ComponentB componentBSpring 会先注入一个懒加载代理对象真正调用时才去容器里拿目标对象。这本质上是在引用中间加了一层“延迟转发”也是打破循环依赖的常见招法。还可以用ObjectProviderT。注入ObjectProviderComponentB业务代码里需要的时候再调用getObject()获取目标实例把创建时机和引用时机解耦。这三种方式里Lazy是最省事的但要注意它引入的代理对象成本和调用时机的变化。对性能敏感的核心链路要谨慎使用。5. 源码断点排查法三个 Map 是怎么协作的以及怎么验证自己的理解5.1 先记住三个 Map 的源码位置三个 Map 都在DefaultSingletonBeanRegistry这个类里/** 一级缓存存放完整的单例对象 */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存存放提前暴露的半成品或早期代理对象 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存放 ObjectFactory按需生成提前引用 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);有个细节值得注意三级缓存用的是HashMap而一级、二级缓存用的都是ConcurrentHashMap。原因是三级缓存只在单线程创建 Bean 的阶段被访问不存在并发问题而一级缓存会被多线程getBean并发读取必须用并发容器。5.2 断点应该打在哪些方法上很多人读源码找不着北是因为直接从doCreateBean开始读一下子撞进几百行代码里。我的建议是只盯两个方法。第一个是DefaultSingletonBeanRegistry.getSingleton(String beanName, boolean allowEarlyReference)。这个方法每次只做三件事查一级缓存、查二级缓存、调三级缓存工厂。在这里打断点你能实时看到每个 Bean 被请求时三个 Map 的命中情况。第二个是AbstractAutowireCapableBeanFactory.doCreateBean里调用addSingletonFactory的那一行。在这里打断点你能看到 Spring 是在什么时刻决定“这个 Bean 可以提前暴露”的。实际操作时可以在 IDEA 里给getSingleton方法添加条件断点比如当beanName等于componentA时才命中。这样过滤掉无关的框架内部 Bean非常清爽。第一次调试建议用最小复现项目只有 A 和 B 两个 Bean观察栈帧的变化会更直观。5.3 自己写一个最小版三级缓存把原理变成肌肉记忆读完源码之后强烈建议自己实现一个简化版加深印象。下面这个代码不是 Spring 源码只是模拟它的核心逻辑但足以帮助理解三级缓存的流转过程。import java.util.HashMap; import java.util.Map; import java.util.function.Supplier; public class SimpleCycleCache { private final MapString, Object singletonObjects new HashMap(); private final MapString, Object earlySingletonObjects new HashMap(); private final MapString, SupplierObject singletonFactories new HashMap(); public Object getSingleton(String name) { Object bean singletonObjects.get(name); if (bean null singletonFactories.containsKey(name)) { if (earlySingletonObjects.containsKey(name)) { bean earlySingletonObjects.get(name); } else { bean singletonFactories.get(name).get(); earlySingletonObjects.put(name, bean); singletonFactories.remove(name); } } return bean; } public void registerSingletonFactory(String name, SupplierObject factory) { singletonFactories.put(name, factory); } public void registerSingleton(String name, Object bean) { singletonObjects.put(name, bean); earlySingletonObjects.remove(name); singletonFactories.remove(name); } public static void main(String[] args) { SimpleCycleCache cache new SimpleCycleCache(); cache.registerSingletonFactory(A, () - { B b (B) cache.getSingleton(B); A a new A(); a.setB(b); return a; }); cache.registerSingletonFactory(B, () - { A a (A) cache.getSingleton(A); B b new B(); b.setA(a); return b; }); A a (A) cache.getSingleton(A); System.out.println(a.getB()); } }跑一下这段代码你就会直观感受到A 在创建时先注册工厂填充属性时触发 B 创建B 填充属性时从“提前引用”里拿到 A最后两边互相引用成功。这里最核心的一行是 B 的创建逻辑里调用cache.getSingleton(A)它命中的正是二级缓存或者由三级工厂生成的引用。5.4 遇到循环依赖报错时的自查清单最后分享一份我自己的排查清单。遇到 BeanCurrentlyInCreationException按顺序过一遍看报错栈里是不是构造器正在创建 Bean。如果是构造器注入引发的循环依赖三级缓存救不了换注入方式或者加 Lazy。确认 Bean 是不是 prototype。prototype 本身就不存在缓存循环依赖必然炸改成 singleton 或者解除互相依赖。确认启动配置里有没有关闭 allow-circular-references。Spring Boot 2.6 之后默认关闭旧项目升级时容易踩。检查是否存在增强类 Bean 参与循环依赖。如果里面有 Async、自定义 BeanPostProcessor 等优先重构而不是依赖容器兜底。实在看不出来问题就在getSingleton方法上打断点观察 Bean 是否真的已经注册了三级工厂。如果连earlySingletonExposure条件都没满足说明问题根本不在缓存层面而在创建流程本身。我在实际排查中最大的体会是循环依赖这种问题十次里有七次是设计问题两次是作用域配置问题真正需要靠三级缓存硬扛的场景其实很少。把三级缓存的原理搞清楚不是为了让大家在系统里肆无忌惮地写互相依赖的 Bean而是为了真正报错时能快速判断“这到底能不能救、该不该救”。下次再有人问“Spring 三级缓存怎么解决循环依赖”你可以先反问一句他那个循环依赖是字段注入还是构造器注入如果是构造器注入三级缓存还真管不着。