Spring三级缓存底层原理:如何破局循环依赖与AOP代理
1. 从一道面试题说起三级缓存到底在解决什么前阵子帮团队做技术分享我抛了个问题给组里几个写了三四年 Spring 的后端同学Spring 容器初始化A的时候发现A依赖BB又依赖A这俩货到底是怎么被创建出来的能答上来的人不多能答到三级缓存这个关键词的更少但能解释清楚为什么一定要三级、二级行不行的一个都没有。这不怪大家。说实话DefaultSingletonBeanRegistry里那三张 Map 的代码量不多但逻辑绕得很——它不是一个独立的功能模块而是散落在单例 Bean 创建和依赖注入两条主线里的关键拼图。你单拎出来看会觉得莫名其妙但一旦结合循环依赖的场景就会明白每一级缓存存在的意义。这篇文章我不打算做成源码逐行注释那种文章你已经看过很多了看完就忘。我尝试用一种更接近调试心态的方式把三级缓存的设计思路、触发时机、以及 AOP 代理为什么会牵扯进来一层层拆开讲。适合正在读 Spring 源码但卡在doCreateBean这块的人也适合准备面试想把这个点讲深一点的 Java 开发。先说结论三级缓存解决的是单例 Bean 在实例化后、初始化前被提前暴露从而让循环依赖中的双方都能拿到可用引用的问题。它不解决所有循环依赖也不负责性能优化它就是一套精心设计的先给个半成品用着后面再补全的机制。2. 三张 Map各自管什么活2.1 三级缓存的定义和各自放什么先看DefaultSingletonBeanRegistry里这段经典代码/** Cache of singleton objects: bean name -- bean instance */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** Cache of singleton factories: bean name -- ObjectFactory */ private final MapString, ObjectFactory? singletonFactories new HashMap(16); /** Cache of early singleton objects: bean name -- bean instance */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16);注释写得很清楚但很多人只记住了名字没记住每一层存的到底是什么类型、什么时候写入、什么时候移除这是理解整个机制的关键。singletonObjects一级缓存存的是成品 Bean。也就是BeanPostProcessor执行完、属性填充完、初始化方法执行完一个真正可用的 Bean。getBean默认先从它里面拿。earlySingletonObjects二级缓存存的是早期 Bean 引用。注意措辞——早期意味着这个 Bean 已经实例化内存分配完成、构造器执行完但属性还没填充、初始化方法还没跑。它是一个半成品但引用地址是确定的了。singletonFactories三级缓存存的是ObjectFactory 对象工厂。它不直接存 Bean 实例而是存一个可以产出 Bean 实例的工厂。工厂的getObject()方法返回的可能是普通 Bean也可能是经过 AOP 代理的 Bean这点后面会重点讲。很多人把三级缓存理解成三个 Map 存同一个对象的三个状态这个方向对了一半但要特别注意三级缓存里的工厂是在 Bean 刚实例化完就放进去的它不存对象本身二级缓存里的早期引用是第一次从工厂里取完之后才放进去的一级缓存里的成品则是整个创建流程走完才放进去的。2.2 和 Bean 创建流程的对应关系为了把三张 Map 串起来必须先建立一条时间线。一个单例 Bean 从无到有大体经历这么几步getBean(beanName)触发创建流程getSingleton(beanName)试图从一级缓存拿成品拿不到且判断这个 Bean 不在创建中就调用createBeancreateBean里通过反射实例化构造器执行完对象地址确定实例化后立刻向三级缓存放入一个 ObjectFactory执行属性填充populateBean如果属性是个 Bean触发依赖 Bean 的创建执行初始化initializeBean包括BeanPostProcessor、afterPropertiesSet、init-method创建完成移出三级缓存和二级缓存放入一级缓存关键在第 5 步——你刚 new 出来的对象还没来得及灌属性就主动告诉容器我在这里谁要引用我先按这个工厂拿。这个裸奔的设计就是循环依赖能够被打破的支点。3. 循环依赖的完整破解过程3.1 A 依赖 B、B 依赖 A 的经典场景我直接用最经典的A - B - A场景走一遍。假设你有两个类Component public class A { Autowired private B b; public A() { System.out.println(A 构造器执行); } } Component public class B { Autowired private A a; public B() { System.out.println(B 构造器执行); } }容器启动后按某种顺序先创建A整个过程用大白话拆解创建A执行构造器得到一个半成品aInstance立刻把aInstance封装成ObjectFactory放进三级缓存开始给A填属性发现需要B去创建B执行构造器得到半成品bInstance立刻把bInstance封装成ObjectFactory放进三级缓存开始给B填属性发现需要A调用getSingleton(a)一级缓存没有二级缓存没有但是发现A正在创建中于是从三级缓存里取A的工厂调getObject()拿到aInstance或者代理对象把拿到的引用放到二级缓存同时删掉三级缓存里的工厂把这个 早期 A 注入给B的a字段B属性填充完成初始化完成作为一个成品放入一级缓存回到A的创建流程把刚创建好的成品B注入给A的b字段A初始化完成从二级缓存删除早期引用把成品放入一级缓存流程结束。最终一级缓存里有两个完整对象它们的属性都指向对方。3.2 getSingleton 方法的源码视角对应到源码核心在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) { 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; }这段代码有几个地方值得细品isSingletonCurrentlyInCreation(beanName)是个关键闸门。它判断这个 Bean 是不是正在创建中。如果是说明它已经开了头可以从三级缓存里找早期引用如果不是说明这个 Bean 压根没开始创建那就要走完整的createBean流程。allowEarlyReference参数默认是true它的字面意思是是否允许提前拿到还未初始化的引用。有人会把这个参数和循环依赖开关搞混实际上allowEarlyReference控制的是能不能从工厂里提前暴露而setAllowCircularReferences(false)才是从全局关闭循环依赖支持。前者在getSingleton层面卡后者在DefaultSingletonBeanRegistry的顶层控制。第一次从三级缓存拿完工厂对象后会立刻把它放进二级缓存并且从三级缓存移除。这个升级动作非常重要——同一个 Bean 的单例保证就靠这一步后续再有人要A直接从二级缓存拿不会再执行第二次工厂方法。3.3 对象升级过程的细节有人会问为什么拿到工厂对象之后不直接删掉三级缓存就完事了还要折腾一个二级缓存出来因为工厂方法getObject()不是免费的。如果 A 被循环引用了五次A 依赖 B、C、D、E它们全都反过来依赖 A没有二级缓存的话每次都要执行一遍singletonFactory.getObject()。这个工厂方法本身可能触发getEarlyBeanReference里的后置处理逻辑执行多次会产生多个不同代理对象Bean 的单例性就崩了。二级缓存把首次从工厂取出的结果缓存下来后续引用直接走缓存既不重复执行工厂逻辑也保证了引用一致。这也是理解三级缓存向二级缓存升级的最核心原因。3.4 singletonObjects 加锁的微妙之处再注意synchronized (this.singletonObjects)这个锁。Spring 的singletonObjects用的是ConcurrentHashMap但这里仍然加了重量级锁。为什么因为earlySingletonObjects和singletonFactories都是普通 HashMap在并发创建不同 Bean 时可能产生竞争。只用ConcurrentHashMap保证单个 Map 的线程安全是不够的——这里需要的是三个 Map 组合操作的原子性。Spring 的做法是整个getSingleton的三级缓存查询链路都放在同一个锁里确保同一个 Bean 的创建状态在任何时刻只有一个线程能推进。这种写法看似笨重但在单例注册表这种高频读、低频写相对于启动阶段的场景下完全够用而且逻辑简单不容易出错。4. 为什么第三级要放 ObjectFactory 而不是直接放 Bean4.1 直接放 Bean 会踩 AOP 代理的坑这是三级缓存机制里最容易被忽略、但也最精彩的设计点。我先提出一个常见疑问既然实例化之后对象地址就定了直接把早期 Bean 放到二级缓存不就行了吗三级缓存纯粹多此一举吧答案是如果没有 AOP二级缓存就够了甚至一级缓存提前暴露也能凑合。但 Spring 的整个生态里AOP 代理是绕不开的。举个例子Component public class A { Autowired private B b; Transactional public void doSomething() { } }如果A配了事务Spring 容器里真正对外暴露的应该是一个A的代理对象JdkDynamicAopProxy或CglibAopProxy而不是原始A对象。这个代理对象是在什么时候创建的呢关键点AOP 代理的创建时机在 Bean 初始化完成后由AbstractAutoProxyCreator这个BeanPostProcessor的postProcessAfterInitialization阶段触发。这里出现了一个致命的时序矛盾如果我在实例化后直接把原始对象aInstance放进二级缓存而B在依赖注入时拿到的是aInstance但A最终初始化完放进一级缓存的是aInstance的代理对象aProxy。两个对象地址不同类型可能不同B持有的aInstance没有被增强事务、切面全都失效了。4.2 getEarlyBeanReference 的作用Spring 的解决方案是三级缓存里不存原始 Bean而存一个工厂。这个工厂在执行时会调用AbstractAutoProxyCreator里的getEarlyBeanReference方法——提前把代理对象创建出来。这个机制是通过SmartInstantiationAwareBeanPostProcessor接口实现的。AbstractAutoProxyCreator实现了这个接口所以在 Spring 容器里只要存在 AOP 相关的后置处理器三级缓存里取出来的对象就有机会是代理对象。核心代码在AbstractAutoProxyCreatorOverride 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 是否需要代理需要就返回代理对象不需要就返回原对象。而且earlyProxyReferences这个缓存记录了哪些 Bean 被提前代理过等正常初始化流程走到postProcessAfterInitialization时会发现自己已经被提前处理过了就不会再代理一次。所以完整的链路是实例化 → 三级缓存放工厂 → 依赖注入时触发getEarlyBeanReference→ 返回可能被代理的对象 → 放入二级缓存。这样B拿到的就是A的代理对象和最终A放进一级缓存的对象是同一个引用——类型一致、增强生效问题解决。4.3 没有 AOP 的时候二级缓存也够用从这个角度倒推如果系统里没有任何SmartInstantiationAwareBeanPostProcessor三级缓存确实可以退化成二级缓存实例化后直接把对象放二级缓存依赖注入时拿这个半成品。很多手写 MiniSpring 的教程就是这么干的能跑通基本循环依赖但无法处理 AOP 场景。Spring 选择三级缓存 ObjectFactory的方案本质上是用一层工厂方法延迟了决定这个 Bean 到底长什么样的时机。在实例化那一刻容器还不知道要不要给它生成代理干脆放一个工厂进去等真正有人需要提前引用时再去咨询所有BeanPostProcessor得出最终答案。这种延迟决策的思路在框架设计里非常常见但放到 Bean 创建流程里确实让刚开始读源码的人绕了不少弯。你只要抓住一个核心问题就不会迷路循环依赖中的 Bean 被提前暴露时它可能还不是最终形态我们需要一个机制防止暴露错误的形态ObjectFactory 就是这个机制。5. 三级缓存解决的边界哪些循环依赖它管不了5.1 构造器注入导致循环依赖必死搞清楚三级缓存能做什么之后更重要的可能是搞清楚它做不了什么否则容易被面试官追问到墙角。循环依赖分两种主要形态构造器注入循环依赖和 Setter/字段注入循环依赖。三级缓存只能解决后者解决不了前者。原因很直白三级缓存暴露对象的前提是实例化已完成只是未初始化。但构造器注入发生在实例化过程中——你要创建A先得把构造器参数B创建出来要创建B又得先创建A。此时A还没走到放入三级缓存那一步也就是还没有暴露工厂所以getSingleton(a)不可能拿到早期引用创建直接以BeanCurrentlyInCreationException告终。Spring 还专门在AbstractAutowireCapableBeanFactory里用singletonsCurrentlyInCreation这个 Set 来跟踪正在创建的单例一旦发现A在创建中又被要求创建A就会提前抛出异常而不是无限递归。同理Async注解的问题也出在这里。Async的代理是通过AsyncAnnotationBeanPostProcessor的后置处理器生成的但它不是通过SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference支持的——在早期引用阶段不会主动生成代理。所以即使字段注入能解决A - B - A的循环依赖只要其中一个 Bean 加了Async拿到的早期引用就不是代理对象注入进去的属性实际上还是原始对象切面失效。这种问题非常隐蔽日志不报错但运行结果就是不对。5.2 Prototype 作用域的 Bean压根不缓存另外需要注意三级缓存的全部机制只对单例 Bean 生效。如果某个 Bean 是Scope(prototype)Spring 不会把它的工厂放进任何一级缓存因为它需要的是每次拿到不同的实例。那如果 prototype 作用域的 Bean 也产生了循环依赖呢直接爆异常。你连正在创建中的信号都检测不到——因为singletonsCurrentlyInCreation只跟踪单例。这种情况 Spring 根本不去尝试解决它的设计哲学是prototype Bean 本就不应该有循环依赖这种状态。5.3 allowCircularReferences 全局开关DefaultSingletonBeanRegistry有个布尔属性allowCircularReferences默认是true。如果你在配置类里强制关掉SpringApplication app new SpringApplication(XxxApplication.class); app.setAllowCircularReferences(false);那么即使 Bean 满足字段注入条件三级缓存机制也会被禁用。getSingleton里的allowEarlyReference参数会变成falsesingletonFactories.get(beanName)这段逻辑就不走了。出现循环依赖时直接抛异常容器无法启动。这个开关放在这里更多是为了提供一种宁可启动失败也不要带病运行的选择。因为循环依赖本身是一种代码异味通常意味着职责边界不清晰所以有一部分团队会强制关闭它要求所有 Bean 通过构造器注入构造清晰的依赖树。5.4 dependsOn 和显式依赖关系还有一种伪循环依赖值得提——DependsOn。它控制的是 Bean 初始化顺序如果A声明依赖B则B要优先初始化。反过来B又声明依赖A容器会直接抛BeanCurrentlyInCreationException。这个不归三级缓存管它是在AbstractBeanFactory的getBean流程里通过dependsOn集合做检查的。所以如果面试官问你三级缓存能解决所有循环依赖吗标准答案要拆成三层能解决单例 Bean 的 Setter/字段注入循环依赖不能解决构造器注入循环依赖、prototype 循环依赖、DependsOn循环依赖对 AOP 场景只能解决支持getEarlyBeanReference的那一类代理事务、切面可以Async不行。6. 从源码理解 Bean 的完整生命周期后你该掌握的调试技巧6.1 用断点和日志观察三级缓存的读写时序读源码光靠看代码容易眼晕我建议用一个最笨但也最有效的方法断点调试。在你自己的 Spring Boot 项目里写两个互相依赖的类然后在DefaultSingletonBeanRegistry#getSingleton(String, boolean)方法入口打一个断点条件表达式写成a.equals(beanName) || b.equals(beanName)。跑起来之后单步跟一遍你会直观看到A第一次进来时一级缓存查到nullB第一次进来时一级缓存查到null在B的属性填充阶段再次调用getSingleton(a)此时一级缓存为空、二级缓存为空、三级缓存里有A的工厂取出工厂对象后观察earlySingletonObjects和singletonFactories的变化最终A创建完成后singletonObjects里出现完整的A我强烈建议你把单例创建期间的三级缓存状态打印成日志自己写一个小后置处理器都可以。有一个速查表你可以直接保存缓存层级Map 名称存放内容写入时机移除时机一级singletonObjects完整 Bean含代理创建流程全部完成容器关闭时清理二级earlySingletonObjects早期 Bean 引用可能是代理从三级缓存首次取出后一级缓存写入时移除三级singletonFactoriesObjectFactory 工厂实例化完成后立即从三级缓存取出后移除6.2 常见异常和排查思路整理我在带团队过程中收集了几个和循环依赖直接相关的经典报错整理成速查表现象抛错点典型原因解决方式BeanCurrentlyInCreationExceptiondoGetBean构造器注入循环依赖其中一个 Bean 改为字段注入或 Setter 注入或通过Lazy打破BeanCurrentlyInCreationException但用的是字段注入doGetBean循环引用被allowCircularReferencesfalse关闭检查配置或重构依赖关系Bean 创建成功后类型不对运行时Async等后置处理器未参与早期代理消除循环依赖或从架构上拆分循环依赖发生在Configuration类之间ConfigurationClassPostProcessor配置类之间方法调用问题通过独立配置类或Lazy隔离三级缓存取不到工厂getSingletonBean 确实没被实例化过检查作用域是否是单例6.3 和不循环依赖的 Bean 相比别忽略了性能损耗还有一种印象很多人没有三级缓存机制在解决循环依赖的同时会带来额外的工厂调用和锁竞争。虽然启动期微乎其微但如果你的应用启动时要创建几千个 Bean而其中有大量循环依赖每次getSingleton都要走一遍三级查询链路启动时间会明显变慢。所以从工程角度讲循环依赖能消除就尽量消除。不是因为它跑不起来而是因为它制造了隐性的代码耦合和启动期开销。我通常建议团队在代码审查阶段重点盯Autowired字段注入的循环引用逐步改成构造器注入。构造器注入配合Lazy既能明确依赖关系又不会因为循环依赖被迫走三级缓存这条慢路径。6.4 手写一个简化版三级缓存理解到这个程度你可以试着不看 Spring 源码自己撸一个最小实现。核心就三个类和三个 Mappublic class SimpleSingletonRegistry { private MapString, Object singletonObjects new ConcurrentHashMap(); private MapString, Object earlySingletonObjects new ConcurrentHashMap(); private MapString, ObjectFactory? singletonFactories new ConcurrentHashMap(); private SetString singletonsCurrentlyInCreation Collections.newSetFromMap(new ConcurrentHashMap()); public Object getSingleton(String beanName) { Object singleton this.singletonObjects.get(beanName); if (singleton null isSingletonCurrentlyInCreation(beanName)) { singleton this.earlySingletonObjects.get(beanName); if (singleton null) { ObjectFactory? factory this.singletonFactories.get(beanName); if (factory ! null) { singleton factory.getObject(); this.earlySingletonObjects.put(beanName, singleton); this.singletonFactories.remove(beanName); } } } return singleton; } public void addSingletonFactory(String beanName, ObjectFactory? factory) { synchronized (singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, factory); this.earlySingletonObjects.remove(beanName); } } } protected boolean isSingletonCurrentlyInCreation(String beanName) { return this.singletonsCurrentlyInCreation.contains(beanName); } }配合一个带有populateBean逻辑的createBean方法就能完全模拟出循环依赖的破解过程。如果你能把这个迷你版跑通再回头看 Spring 源码会发现所有看似复杂的逻辑都只是在这个骨架上填充了细节。7. 面试中如何把三级缓存讲出深度很多同学面试聊到三级缓存就只会背三张 Map 的名字和作用然后说为了解决循环依赖。其实面试官真正想听的是你能不能说清楚设计的权衡和边界。这里给你一套递进的话术框架可以作为记忆锚点第一层讲清楚问题本身。循环依赖是什么为什么构造器注入的循环依赖会直接失败而字段注入的循环依赖只是半失败因为字段注入时对象已经完成实例化只剩下属性没填可以在创建的同时把引用暴露出去。第二层讲清楚三级缓存的完整链路。实例化后向singletonFactories放一个工厂依赖注入时getSingleton发现 Bean 在创建中就从工厂取出早期引用放进earlySingletonObjects最终成品放进singletonObjects。第三层讲清楚为什么是三级而不是二级。核心是 AOP 代理用getEarlyBeanReference让代理对象在暴露引用之前就能确定下来。你可以主动反问一句如果去掉 AOP其实两级缓存就够用了。这句话基本能让面试官眼睛一亮。第四层讲清楚解决不了的场景。构造器注入、Async、prototype 作用域、allowCircularReferencesfalse这四个边界一摆出来说明你既看过源码又在真实项目里踩过坑。最后一层如果你愿意可以做个小升华三级缓存最精妙的设计其实不是三张 Map而是延迟决定对象最终形态这个思路。它把一个本该在实例化时就确定的问题推迟到了第一次被引用的时候去解决用一层工厂换来了极大的灵活性。这也是你做架构设计时可以借鉴的地方——当多个处理阶段可能改变对象的最终形态时中间插入一层工厂或回调往往比提前固化状态更优雅。我自己读这部分源码读过三遍第一遍看个热闹知道有三级缓存这么个东西第二遍跟着调试走通了A-B循环第三遍才真正悟到 ObjectFactory 那层设计的高明之处。希望这篇梳理能帮你把第三遍的时间大幅缩短。