☰
Spring三级缓存机制详解:循环依赖与AOP代理的源码实践
2026/10/12 21:13:01 网站建设 项目流程

1. 为什么Spring容器要设计“三级缓存”,而不是直接用Map

很多同学第一次接触Spring IOC的三级缓存,都是从面试八股文开始的。背得滚瓜烂熟:一级缓存是singletonObjects,二级是earlySingletonObjects,三级是singletonFactories,作用是为了解决循环依赖。但真到了实际项目里,遇到"为什么循环依赖在Spring Boot低版本能跑,升个版本就启动失败"这种问题时,光背概念完全不够用。

我最初对三级缓存的理解也停留在表面。直到有一次排查一个启动报错BeanCurrentlyInCreationException的问题,翻源码翻到DefaultSingletonBeanRegistry,才彻底意识到:三级缓存的本质,是Spring为了解决Bean实例化过程中的引用提前暴露问题,而设计的一套带有"延迟决策"机制的存储结构。

先看一下核心代码,这是getSingleton方法里最核心的查找逻辑:

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.singletonObjects.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; }

注意几个关键点:

  • 一级缓存singletonObjects存放的是完整创建好的单例Bean。
  • 二级缓存earlySingletonObjects存放的是提前暴露的早期对象,这个对象可能还没完成属性填充。
  • 三级缓存singletonFactories存放的是**ObjectFactory对象**,不是Bean本身。

为什么不能只用一个Map?因为Spring在创建Bean的过程中,Bean的状态一直在变化:从刚new出来的原始对象,到填充完属性的半成品,再到初始化完成后经过AOP代理的最终成品。用一个Map存所有状态,取出的时候你根本不知道拿到的对象处于哪个阶段,逻辑会彻底失控。

三个缓存本质上是三个不同生命周期的对象存储区,Bean在创建过程中按"未完成 -> 已完成"的顺序在不同缓存之间转移。

三级缓存真正特殊的地方在于第三级:它存的不是Bean实例,而是ObjectFactory。这个ObjectFactory的作用是在运行时按需生成对象的"早期引用",再决定是否生成代理对象。也就是说,Spring把"到底要不要提前把对象暴露出去"这个决策,延迟到了真正发生循环依赖的瞬间。

这个设计里藏着一个非常精妙的思想:无循环依赖时,三级缓存形同虚设;只有真的有人需要早期引用时,三级缓存才会被触发。这种"惰性+按需"的设计,正是三级缓存区别于普通缓存的核心,也是面试时值得展开的关键细节。

2. 三级缓存各司其职:这三层Map的真实职责拆解

想彻底搞懂三级缓存,先要把每一层的职责边界划清楚。很多资料把三个Map描述成"查询顺序"或者"存取关系",但真正重要的是每一层Map存的到底是谁、什么时候写入、什么时候移除。

2.1 一级缓存:玩家最终看到的Bean都在这里

singletonObjects字段,源码定义是Map<String, Object>,存放的是BeanName到完整单例Bean的映射。这个Map是ConcurrentHashMap(早期版本是HashMap,后来为了并发安全改掉的),支持并发读取。

一级缓存的写入时机有两个:

  • 正常创建流程结束:createBean方法走完实例化、属性填充、初始化之后,通过addSingleton方法把成品Bean放进来。
  • 从二级缓存升级:提前暴露的早期对象,经过完整生命周期后,从earlySingletonObjects中取出并放入一级缓存。

这里有个常见误区:一级缓存里的Bean永远是成品——所有属性已经填充完毕、初始化方法已经执行、AOP代理已经生成完毕。你在代码里用@Autowired或@Resource注进去的,是最终可用的完整对象。

2.2 二级缓存:半成品的临时收容所

earlySingletonObjects字段,存放的是提前暴露的早期引用。这个"早期"体现在:Bean刚完成new,还没有填充属性、还没有执行afterPropertiesSet等初始化逻辑。

二级缓存的写入来源只有一个:三级缓存中的ObjectFactory.getObject()被调用后的返回值。写入的同时,会把对应的三级缓存条目删除。

二级缓存存在的意义,我理解是为了性能和状态隔离:从ObjectFactory.getObject()把早期引用拿出来之后,后续再有人依赖这个Bean,就直接从二级缓存取,不用反复执行ObjectFactory的获取逻辑。同时,二级缓存的存在也保证了:如果Bean最终创建失败,早期对象不会污染一级缓存。

2.3 三级缓存:为什么要存ObjectFactory而不是Bean

三级缓存里存的是ObjectFactory,源码定义是Map<String, ObjectFactory<?>>。这一层的核心作用:延迟决策是否需要AOP代理。

为什么不能直接把Bean放进去?看AbstractAutowireCapableBeanFactory里的doCreateBean方法:

if (earlySingletonExposure) { addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); }

getEarlyBeanReference的逻辑大致是:

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject = bean; // 遍历所有SmartInstantiationAwareBeanPostProcessor // 如果Bean需要被代理(比如有@Async、@Transactional等),在这里返回代理对象 // 如果不需要代理,返回原始对象 return exposedObject; }

关键逻辑在哪?Spring在创建Bean的早期阶段,并不知道这个Bean最终是否会被AOP代理。AOP代理的生成时机在initializeBean()之后的applyBeanPostProcessorsAfterInitialization()阶段。如果循环依赖提前暴露引用后,另一边的Bean已经把这个引用注入进去了,那后续再生成代理对象时,别人拿到的是原始对象,代理根本不会生效。

所以三级缓存用ObjectFactory包裹了一层"延迟决策":只有当循环依赖真正发生时,三级缓存才会被解锁,此时调用getEarlyBeanReference,根据当时的Bean状态决定要不要提前代理。这就是三级缓存存在的意义。

回到"为什么不是二级缓存"这个问题:如果只用一级和二级,Spring必须在new完Bean之后就立刻判断:这个Bean有没有可能需要代理?如果有,提前代理,然后放入二级缓存。但这样做的后果是,AOP代理比预期更早生成了,而且基于早期对象做代理本身就不可靠。所以方案被设计成"三级缓存 +ObjectFactory延迟调用",一切等真正需要的那一刻再决定。

提示:三级缓存在"无循环依赖"场景下完全没有触发机会。它只是Spring为循环依赖这个极端场景准备的"应急方案",平时根本不会走,这也是为什么很多人看日志时基本见不到三级缓存活跃的原因。

3. A与B互相依赖:一次完整的三级缓存调用链路追踪

光说"三层Map"的概念有点虚,我们把场景拉起来:假设有两个类BeanA和BeanB,BeanA里通过@Autowired注入了BeanB,BeanB里也通过@Autowired注入了BeanA。这就是最经典的Setter注入循环依赖。

3.1 从getSingleton到doCreateBean的调用过程

整个流程可以从refresh()容器启动开始追踪。创建BeanA时,调用链大概是:

  1. getSingleton(beanName):先查一级缓存,没有;查二级缓存,没有;查三级缓存,也没有。进入创建流程。
  2. createBean->doCreateBean:执行createBeanInstance方法,通过构造器或反射new出BeanA的原始对象。
  3. earlySingletonExposure判断:默认情况下,如果allowCircularReferences为true且是单例Bean,就会执行addSingletonFactory,把BeanA的ObjectFactory注册到三级缓存中。注意此时BeanA只完成了实例化,属性全是null。
  4. 执行populateBean:开始填充BeanA的属性,发现需要注入BeanB。于是调用getSingleton("beanB")。

此时关键事件到来。创建BeanB去解析依赖时,也要经过getSingleton("beanA")。因为BeanA还在创建中(isSingletonCurrentlyInCreation("beanA")为true),一级缓存没有,二级缓存也没有,于是到三级缓存里找到了ObjectFactory。

调用singletonFactory.getObject(),执行getEarlyBeanReference(beanA)。如果BeanA不需要代理,返回原始对象;如果需要代理,此刻就会提前创建代理。然后把这个早期引用放入二级缓存,同时删除三级缓存中BeanA的条目。这样BeanB拿到了BeanA的早期引用,顺利完成了BeanB的创建。

BeanB创建完成后,Spring回到BeanA的populateBean流程,把BeanB注入给BeanA。接着完成BeanA的初始化、AOP代理生成等逻辑,最终把成品BeanA放入一级缓存。

3.2 关键细节:BeanB拿到的是BeanA的"半成品"

注意,BeanB拿到BeanA时,BeanA是一个还没有填充任何属性的半成品。如果此时调用BeanA的方法,而方法里依赖了某个属性,会直接NPE。所以实际业务中,循环依赖本身是有风险的,不是"解决掉就万事大吉"。

3.3 三级缓存触发后各层数据变化

缓存层级BeanA创建初期BeanA被循环依赖暴露时BeanA最终创建完成
一级缓存 singletonObjects无无BeanA完整实例
二级缓存 earlySingletonObjects无BeanA早期引用(代理或原始)部分情况下仍有残留(取决于是否被其他Bean持有引用)
三级缓存 singletonFactoriesBeanA的ObjectFactory已删除已删除

这个变化过程就是三级缓存"由三变二、二变一"的完整链路。想验证的话,可以在getEarlyBeanReference方法里打断点,观察BeanA的创建日志顺序,非常直观。

4. 解不开的节:构造器循环依赖和prototype Bean为什么不走三级缓存

聊完正常流程,自然要聊边界。三级缓存不是万能的,有两类循环依赖它解决不了,这两类也是面试高频追问。

4.1 构造器注入循环依赖:三步就卡死

构造器循环依赖,指的是BeanA的构造器需要BeanB,BeanB的构造器需要BeanA。这段流程走起来完全不一样:

  1. doCreateBean里createBeanInstance,Spring需要先确定构造器参数,于是解析BeanB。
  2. 解析BeanB时,又需要创建BeanA的构造器参数。
  3. 发现BeanA还在创建中,而且此时BeanA还没执行addSingletonFactory——因为实例化都没完成,根本没法提前暴露。

核心原因在于:三级缓存的提前暴露发生在实例化完成之后,而构造器注入发生在实例化过程中。BeanA还没走完createBeanInstance,还没来得及把ObjectFactory放入三级缓存,就已经需要BeanB去依赖它了,自然无从暴露。

所以报错信息会直接给出BeanCurrentlyInCreationException,而且Spring在日志里明确提示:不建议使用循环依赖,请考虑解决依赖设计。

4.2 prototype Bean:三级缓存压根不参与

prototype作用域的Bean每次请求都创建新实例,不存在"正在创建中"的共享状态。三级缓存里的isSingletonCurrentlyInCreation判断只在单例Bean上生效。prototype的Bean创建时直接走new-> 填充 -> 初始化,全程不经过三级缓存。所以prototypeBean的循环依赖,无论构造器注入还是Setter注入,都会直接抛异常。

4.3 为什么Spring不彻底解决构造器循环依赖

这里涉及一个设计取舍:构造器循环依赖如果硬要支持,需要改变"先实例化再注入"的核心创建顺序,代价太大。而且构造器注入鼓励的是显式的、完整的依赖声明,一个类构造器参数写全,本身就说明依赖关系清晰,不适合靠"半成品暴露"来处理。Spring团队选择直接拒绝,反而逼迫开发者提前发现设计问题。

5. 三级缓存与AOP代理的暗战:为什么有时注入的不是代理对象

这是三级缓存里最微妙、也最容易踩坑的点。如果循环依赖过程中暴露的是原始对象,但Bean本来需要AOP代理,最后注入到依赖方的可能是原始对象,而不是代理对象。这会导致@Transactional失效、@Async失效之类的问题。

5.1 有多少种"代理"需要走三级缓存

常见的@Transactional、@Async、自定义AOP切面都是通过BeanPostProcessor创建代理对象的。正常创建流程中,Spring在doCreateBean最后阶段调initializeBean,然后通过applyBeanPostProcessorsAfterInitialization遍历后置处理器生成代理。

但循环依赖场景中,依赖方在BeanA的属性填充阶段就去三级缓存调了getEarlyBeanReference。此时BeanA的后置处理器还没执行,怎么办?答案藏在getEarlyBeanReference里:它在早期就遍历了SmartInstantiationAwareBeanPostProcessor,比如AbstractAutoProxyCreator,判断要不要在此时就做代理。

具体逻辑大致是:

// AbstractAutoProxyCreator#getEarlyBeanReference protected Object getEarlyBeanReference(Object bean, String beanName) { if (!this.targetSourcedBeans.contains(beanName)) { // 判断是否需要代理,如果需要,创建代理对象并缓存 // 如果不需要,返回原始对象 } return bean; }

这也就意味着:在循环依赖发生的那一刻,Spring会为这个Bean提前做代理决策。如果@Transactional这类注解的切面匹配到了,就会在三级缓存返回时直接生成代理对象。

5.2 @Async循环依赖为什么容易出坑

@Async是循环依赖场景的重灾区。为什么?因为@Async代理的创建时机比较特殊,它依赖的后置处理器在某些版本里并不会参与getEarlyBeanReference。这就导致:如果循环依赖提前暴露的是原始对象,后面再用@Transactional等切面处理时,生成代理对象的时机已经错过了。

我在实际项目里踩过的场景是:一个类既被另一个Bean循环依赖,同时方法上有@Async,最后注入进去的Bean上的异步方法完全不生效,代码执行了但是没走代理逻辑,日志没有异步线程输出。排查后确认是三级缓存提前暴露了原始Bean,后续代理增强没有覆盖到。

解决方案通常有几个方向:

  • 通过@Lazy阻断循环依赖,让依赖方拿到代理对象而不是直接触发提前暴露。
  • 调整依赖结构,拆掉循环关系。
  • 手动暴露代理:实现ApplicationContextAware,通过getBean获取代理对象。

5.3 代理与实际引用的最终归位

Spring在整个创建流程结束后,会做一次校验:从getSingleton取出最终实例,看看它和早期暴露的对象是不是同一个。如果不同,且allowRawInjectionDespiteWrapping为false,会抛出BeanCurrentlyInCreationException,提示"早期对象被替换,循环依赖被破坏"。

这个校验逻辑在doCreateBean末尾:

if (earlySingletonExposure) { Object earlySingletonReference = getSingleton(beanName, false); if (earlySingletonReference != null) { if (exposedObject == bean) { exposedObject = earlySingletonReference; } else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) { throw new BeanCurrentlyInCreationException(beanName); } } }

理解了这个逻辑,就能明白:三级缓存不只是存储方案,更是一套保证Bean状态一致性的状态机。代理对象有没有被提前暴露、最终成品是不是同一个引用,Spring都在这个流程里做了严格管控。

6. 从源码回到实践:三级缓存影响下的项目稳定性和几个保命建议

理论聊完,说点实际经验。三级缓存虽然解决了Setter循环依赖,但绝不代表循环依赖是好设计。很多项目能启动,是因为Spring兜底了,但兜底不代表没有隐患。

6.1 循环依赖隐藏的启动顺序问题

遇到循环依赖时,Bean的初始化顺序不再完全可控。BeanA创建时拉了BeanB一把,BeanB又拉着BeanA的半成品,Spring内部虽然靠三级缓存处理了,但依赖方真正拿到引用时,对象可能还没准备好。如果业务逻辑依赖了循环依赖Bean的完整初始化状态,偶尔会出现"诡异NPE"。

我之前遇到过一次:一个配置类和一个工具类互相依赖,工具类里有个静态字段依赖配置类的初始化结果。启动偶尔成功偶尔失败,后来发现是循环依赖导致工具类拿到的引用时机不对。最终彻底拆掉循环,问题消失。

6.2 启动日志里如何判断三级缓存是否被触发

想知道自己的项目有没有走循环依赖分支,有个简单办法:启动时打开debug日志,搜Creating shared instance of singleton bean和BeanCurrentlyInCreationException。如果出现类似"一How moving"或者Circular dependency相关的警告,就说明三级缓存正在为你兜底。

Spring从某个版本开始,在检测到循环依赖时会输出日志:

WARN: Bean ... is involved in a circular reference

这个警告就是信号:循环依赖存在,设计上有隐患。

6.3 我的处理优先级

遇到循环依赖,我的处理顺序一般是:

  1. 重构:拆服务、抽公共层、消除循环。这是终极方案。
  2. @Lazy注解:在其中一个注入点加@Lazy,让Spring延迟注入代理对象,阻断循环触发。
  3. @DependsOn调整创建顺序:如果只是初始化顺序问题,用它控制一下。
  4. 最后才是保留循环依赖:如果短期实在无法重构,保留循环依赖但必须在核心路径做防护,尽量避免在postConstruct里调用对方的完整方法。

6.4 理解三级缓存对日常开发的价值

很多人觉得三级缓存只是面试题,和日常开发没多大关系。但我在排查启动问题、看日志、做性能优化时,经常需要回到这段逻辑。举两个场景:

  • 线上出现BeanCurrentlyInCreationException,你能快速根据报错判断是构造器循环依赖还是代理对象替换问题,而不是盲目加@Lazy。
  • 升级Spring Boot版本后,启动方式变了(比如某些版本对循环依赖警告从WARN升级为ERROR,甚至直接阻止启动),如果你理解三级缓存机制,就知道这是循环依赖导致的升级不兼容,而不是Spring框架本身出了问题。

从源码设计的角度看,三级缓存这个方案的历史背景是当时Spring为了支持Setter注入的循环依赖,在两难中做了一个务实取舍。它有效、可靠、性能足够,代价是设计上引入了一定的复杂度。理解了这种取舍,再回头看框架设计,思路会清晰很多。

最后再分享一个排查工具类的经验:如果项目里出现"循环依赖但启动正常"的情况,别急着开心,用前面提到的方式确认一下依赖方拿到的引用到底是不是完整的代理对象。如果涉及@Transactional或@Async,建议手动验证一下代理是否生效,而不是依赖直觉。三级缓存是好东西,但它解决的问题终究应该被从根上规避掉。

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

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

立即咨询