Spring三级缓存底层原理:如何破局循环依赖与AOP代理
2026/9/9 13:32:18 网站建设 项目流程

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 Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** Cache of singleton factories: bean name --> ObjectFactory */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16); /** Cache of early singleton objects: bean name --> bean instance */ private final Map<String, 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 从无到有,大体经历这么几步:

  1. getBean(beanName)触发创建流程
  2. getSingleton(beanName)试图从一级缓存拿成品
  3. 拿不到,且判断这个 Bean 不在创建中,就调用createBean
  4. createBean里通过反射实例化(构造器执行完,对象地址确定)
  5. 实例化后立刻向三级缓存放入一个 ObjectFactory
  6. 执行属性填充(populateBean),如果属性是个 Bean,触发依赖 Bean 的创建
  7. 执行初始化(initializeBean),包括BeanPostProcessorafterPropertiesSetinit-method
  8. 创建完成,移出三级缓存和二级缓存,放入一级缓存

关键在第 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" 注入给Ba字段
  • B属性填充完成,初始化完成,作为一个成品放入一级缓存
  • 回到A的创建流程,把刚创建好的成品B注入给Ab字段
  • 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,但这里仍然加了重量级锁。为什么?因为earlySingletonObjectssingletonFactories都是普通 HashMap,在并发创建不同 Bean 时可能产生竞争。只用ConcurrentHashMap保证单个 Map 的线程安全是不够的——这里需要的是"三个 Map 组合操作"的原子性。

Spring 的做法是整个getSingleton的"三级缓存查询链路"都放在同一个锁里,确保同一个 Bean 的创建状态在任何时刻只有一个线程能推进。这种写法看似笨重,但在单例注册表这种高频读、低频写(相对于启动阶段)的场景下完全够用,而且逻辑简单,不容易出错。

4. 为什么第三级要放 ObjectFactory 而不是直接放 Bean

4.1 直接放 Bean 会踩 AOP 代理的坑

这是三级缓存机制里最容易被忽略、但也最精彩的设计点。我先提出一个常见疑问:既然实例化之后对象地址就定了,直接把早期 Bean 放到二级缓存不就行了吗?三级缓存纯粹多此一举吧?

答案是:如果没有 AOP,二级缓存就够了,甚至一级缓存+提前暴露也能凑合。但 Spring 的整个生态里,AOP 代理是绕不开的。举个例子:

@Component public class A { @Autowired private B b; @Transactional public void doSomething() { } }

如果A配了事务,Spring 容器里真正对外暴露的应该是一个A的代理对象(JdkDynamicAopProxyCglibAopProxy),而不是原始A对象。这个代理对象是在什么时候创建的呢?

关键点:AOP 代理的创建时机在 Bean 初始化完成后,由AbstractAutoProxyCreator这个BeanPostProcessorpostProcessAfterInitialization阶段触发。

这里出现了一个致命的时序矛盾:如果我在实例化后直接把原始对象aInstance放进二级缓存,而B在依赖注入时拿到的是aInstance,但A最终初始化完放进一级缓存的是aInstance的代理对象aProxy。两个对象,地址不同,类型可能不同,B持有的aInstance没有被增强,事务、切面全都失效了。

4.2 getEarlyBeanReference 的作用

Spring 的解决方案是:三级缓存里不存原始 Bean,而存一个工厂。这个工厂在执行时,会调用AbstractAutoProxyCreator里的getEarlyBeanReference方法——提前把代理对象创建出来。

这个机制是通过SmartInstantiationAwareBeanPostProcessor接口实现的。AbstractAutoProxyCreator实现了这个接口,所以在 Spring 容器里只要存在 AOP 相关的后置处理器,三级缓存里取出来的对象就有机会是代理对象。核心代码在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); }

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。这个不归三级缓存管,它是在AbstractBeanFactorygetBean流程里通过dependsOn集合做检查的。

所以如果面试官问你"三级缓存能解决所有循环依赖吗",标准答案要拆成三层:能解决单例 Bean 的 Setter/字段注入循环依赖;不能解决构造器注入循环依赖、prototype 循环依赖、@DependsOn循环依赖;对 AOP 场景只能解决支持getEarlyBeanReference的那一类代理(事务、切面可以,@Async不行)。

6. 从源码理解 Bean 的完整生命周期后,你该掌握的调试技巧

6.1 用断点和日志观察三级缓存的读写时序

读源码光靠看代码容易眼晕,我建议用一个最笨但也最有效的方法:断点调试。

在你自己的 Spring Boot 项目里写两个互相依赖的类,然后在DefaultSingletonBeanRegistry#getSingleton(String, boolean)方法入口打一个断点,条件表达式写成"a".equals(beanName) || "b".equals(beanName)。跑起来之后,单步跟一遍,你会直观看到:

  1. A第一次进来时,一级缓存查到null
  2. B第一次进来时,一级缓存查到null
  3. B的属性填充阶段,再次调用getSingleton("a"),此时一级缓存为空、二级缓存为空、三级缓存里有A的工厂
  4. 取出工厂对象后,观察earlySingletonObjectssingletonFactories的变化
  5. 最终A创建完成后,singletonObjects里出现完整的A

我强烈建议你把"单例创建期间的三级缓存状态"打印成日志,自己写一个小后置处理器都可以。有一个速查表你可以直接保存:

缓存层级Map 名称存放内容写入时机移除时机
一级singletonObjects完整 Bean(含代理)创建流程全部完成容器关闭时清理
二级earlySingletonObjects早期 Bean 引用(可能是代理)从三级缓存首次取出后一级缓存写入时移除
三级singletonFactoriesObjectFactory 工厂实例化完成后立即从三级缓存取出后移除

6.2 常见异常和排查思路整理

我在带团队过程中,收集了几个和循环依赖直接相关的经典报错,整理成速查表:

现象抛错点典型原因解决方式
BeanCurrentlyInCreationExceptiondoGetBean构造器注入循环依赖其中一个 Bean 改为字段注入或 Setter 注入,或通过@Lazy打破
BeanCurrentlyInCreationException但用的是字段注入doGetBean循环引用被allowCircularReferences=false关闭检查配置,或重构依赖关系
Bean 创建成功后类型不对运行时@Async等后置处理器未参与早期代理消除循环依赖,或从架构上拆分
循环依赖发生在@Configuration类之间ConfigurationClassPostProcessor配置类之间方法调用问题通过独立配置类或@Lazy隔离
三级缓存取不到工厂getSingletonBean 确实没被实例化过检查作用域是否是单例

6.3 和不循环依赖的 Bean 相比,别忽略了性能损耗

还有一种印象很多人没有:三级缓存机制在解决循环依赖的同时,会带来额外的工厂调用和锁竞争。虽然启动期微乎其微,但如果你的应用启动时要创建几千个 Bean,而其中有大量循环依赖,每次getSingleton都要走一遍三级查询链路,启动时间会明显变慢。

所以从工程角度讲,循环依赖能消除就尽量消除。不是因为它跑不起来,而是因为它制造了隐性的代码耦合和启动期开销。我通常建议团队在代码审查阶段重点盯@Autowired字段注入的循环引用,逐步改成构造器注入。构造器注入配合@Lazy,既能明确依赖关系,又不会因为循环依赖被迫走三级缓存这条慢路径。

6.4 手写一个简化版三级缓存

理解到这个程度,你可以试着不看 Spring 源码,自己撸一个最小实现。核心就三个类和三个 Map:

public class SimpleSingletonRegistry { private Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); private Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(); private Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(); private Set<String> 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 作用域、allowCircularReferences=false,这四个边界一摆出来,说明你既看过源码,又在真实项目里踩过坑。

最后一层,如果你愿意,可以做个小升华:三级缓存最精妙的设计其实不是三张 Map,而是"延迟决定对象最终形态"这个思路。它把一个本该在实例化时就确定的问题,推迟到了"第一次被引用"的时候去解决,用一层工厂换来了极大的灵活性。这也是你做架构设计时可以借鉴的地方——当多个处理阶段可能改变对象的最终形态时,中间插入一层工厂或回调,往往比提前固化状态更优雅。

我自己读这部分源码读过三遍,第一遍看个热闹,知道有三级缓存这么个东西;第二遍跟着调试走通了A-B循环;第三遍才真正悟到 ObjectFactory 那层设计的高明之处。希望这篇梳理能帮你把第三遍的时间大幅缩短。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询