最近有个同事跑来问我,说在智谱清言里搜“三级缓存具体是什么”,AI给讲了一堆Spring源码。他看完还是懵的,就跑来让我用人话再讲一遍。这个题目确实经典,面试问烂了,网上文章也一大把,但能把“为什么非得是三级”讲透的不多。我这几年在项目里踩过循环依赖的坑,也啃过DefaultSingletonBeanRegistry的源码,今天干脆把整套三级缓存机制从头到尾捋一遍,争取让看完的人不仅知道三级缓存是什么,还能拿去面试、拿去排障。
这次讲的核心是Spring框架里的三级缓存,也就是解决单例Bean循环依赖的那三张Map。文章会包含脑图级的整体思路、三张缓存表的职责拆解、A和B互相引用的完整创建流程、为什么二级不够用、核心源码走读,以及实际开发中常见的报错和排查办法。适合刚接触Spring源码的初学者,也适合面试前想系统梳理一遍的Java开发。
1. 三级缓存到底在解什么题
1.1 一次典型的循环依赖报错
在讲缓存之前,先看一个绝大多数Java开发都见过的报错:
BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?这个报错的意思很直白:Spring正在创建A,结果发现A需要B,于是去创建B,结果B又需要A,而A还在创建中。一步到位说,就是两个或者多个Bean互相引用了,形成了一个“先有鸡还是先有蛋”的闭环。
我最早遇到这个报错是在一个老项目里,一个OrderService注入了UserService,UserService又注入了OrderService。当时没想太多,随手就加了@Lazy注解解决。后来看源码才明白,@Lazy是让Spring注入一个代理对象,延迟真正去创建目标Bean的时机,所以绕过了创建闭环。但这不是Spring解决循环依赖的默认姿势,默认姿势就是今天要讲的三级缓存。
要理解三级缓存,首先要理解一个基本前提:Spring创建Bean不是一步到位的,而是分阶段的——先实例化,再填充属性,最后初始化。循环依赖之所以在某些场景下能被解决,靠的就是“实例化”和“填充属性”之间的一个时间差。
1.2 CPU三级缓存和Spring三级缓存不是一回事
搜索“三级缓存”的时候,搜出来的结果五花八门。懂点硬件的会想到CPU里的L1、L2、L3三级缓存,那是为了缓解CPU和内存之间的速度差距;做Android的会想到图片三级缓存,内存、磁盘、网络各一层;而我们Java后端说的三级缓存,特指Spring容器里用来管理单例Bean的三张Map。
很多人一听到“三级缓存”就以为是什么高深的架构设计,其实拆开看就是三个HashMap:
| 缓存级别 | Map名称 | 存储内容 |
|---|---|---|
| 一级缓存 | singletonObjects | 完全创建好的单例Bean |
| 二级缓存 | earlySingletonObjects | 提前暴露的半成品Bean |
| 三级缓存 | singletonFactories | ObjectFactory工厂对象 |
核心思路一点都不玄乎:一个Bean还没完全造好的时候,先把“工厂”放一个地方存着,等别人来找的时候,通过工厂先拿一个“半成品”顶上去用,等整个Bean造好了再换成“成品”。这就像一个餐厅后厨,菜没完全做好的时候,先让服务员告诉客人“菜已经在锅里了”,客人催得急,就先盛半碗端出去,等全部做好了再换一碗。
1.3 三级缓存出现的历史背景
三级缓存在Spring里并不是一开始就有的。Spring 3.0之前处理循环依赖的能力很弱,后来引入了singletonFactories三级缓存,才算是比较优雅地解决了“单例Bean + 属性注入”循环依赖的问题。
需要特别强调的是,三级缓存不是万能的。它只能解决“单例Bean + Setter/字段注入”的循环依赖。构造器注入的循环依赖,不管怎么缓存都救不了,因为构造器注入发生在实例化阶段,实例化时就要拿到依赖的对象,可对象还没开始造,缓存里啥都没有。这一点后文会详细展开。
2. 三张缓存表,各自管什么
2.1 一级缓存 singletonObjects:成品仓库
一级缓存就是DefaultSingletonBeanRegistry类里那个Map<String, Object> singletonObjects。它的语义非常明确:存放已经完全创建好的单例Bean。
什么叫“完全创建好”?Spring对一个Bean的完整处置包括:实例化(分配内存、调用构造器)、填充属性(把依赖的Bean注入进来)、初始化(执行afterPropertiesSet、initMethod,以及各种BeanPostProcessor的处理)。只有走完这一整套流程,Bean才会被放进一级缓存。
从getBean的角度看,当外部调用方要拿一个Bean时,Spring首先查一级缓存,查到就直接返回,查不到才继续后面的创建流程。一级缓存的地位相当于“最终的登记册”,也是一个单例Bean的最终归宿。
2.2 二级缓存 earlySingletonObjects:半成品展示台
二级缓存是Map<String, Object> earlySingletonObjects,存的是提前暴露的半成品Bean,也就是已经实例化完成、但还没有完成属性填充和初始化的对象。
为什么要单独搞一个二级缓存来放半成品?因为半成品Bean放入三级缓存时,只是登记了一个ObjectFactory,并没有真正生成对象。当别人来获取这个Bean的早期引用时,Spring会调用ObjectFactory.getObject()得到对象,然后把这个对象放进二级缓存。这样下一次再有人来获取时,就不用重复调用工厂了,直接从二级缓存拿,保证同一个Bean的早期引用是同一个对象。
我还是用刚才的餐厅类比来理解:一级缓存是已经出餐的成品区;三级缓存是写着“正在做”的订单;二级缓存是“已经盛出来准备端给客人”的餐。客人第一次催,后厨就把菜盛到盘子里,放在出餐台上;第二个服务员再来催,直接端走就行,不用重新做一份。
2.3 三级缓存 singletonFactories:工厂登记处
三级缓存是最特殊的一层,存的是Map<String, ObjectFactory<?>> singletonFactories,value不是Bean对象本身,而是一个ObjectFactory函数式接口实例。
这里有个细节会让很多初学者懵:为什么二级缓存存的是对象,三级缓存存的是“能产生对象的工厂”?直接存一个对象不好吗?
主要原因有两个。第一,一个Bean从实例化完成到完全创建好,中间要经历很多阶段,是否最终需要代理、需要哪个代理,是在BeanPostProcessor阶段才能确定的。如果用二级缓存直接存“半成品对象”,后续想在这个基础上做AOP代理,已经来不及替换了。第二,ObjectFactory把“是否生成对象”和“何时生成对象”做了一个延迟,Spring可以在别人真正需要早期引用的时候,再通过getEarlyBeanReference方法决定返回原始对象还是AOP代理对象——这就是三层设计的精妙所在。
2.4 三张表的关系
三张缓存表的key都是Bean的名字,value分别是“成品Bean”“半成品Bean”“能生产生命周期中某个阶段Bean的工厂对象”。查找顺序是从一级到二级再到三级,反向写入顺序是先写三级、再写二级、最后写一级。
在整个生命周期中,一个正常的单例Bean的缓存状态是这么变化的:
三级缓存(写入工厂) -> 二级缓存(有人提前引用) -> 一级缓存(创建完成)如果自始至终没有任何其他Bean在它创建过程中提前找它要引用,那它就只经历“三级缓存 -> 一级缓存”,二级缓存完全不会被用到。
3. A和B互相依赖,Spring怎么把Bean完整造出来
3.1 完整时序:从A开始到A收尾
理论说了半天,不如直接推演一个例子。假设有两个单例Bean,A依赖B,B依赖A,都用字段注入:
@Component public class A { @Autowired private B b; } @Component public class B { @Autowired private A a; }这个流程我用文字完全展开一次,建议在脑子里跟着过一遍,印象会非常深。
阶段一:创建A。Spring调用getSingleton("a"),一级缓存和二级缓存都没找到,于是走创建逻辑。先实例化A,得到原始对象aInstance,此时aInstance里的b字段还是null。Spring通过addSingletonFactory("a", factory)把这个aInstance包装成一个ObjectFactory放入三级缓存,然后开始给aInstance填充属性。
阶段二:填充A时发现需要B。Spring发现A的b字段需要注入一个B,于是调用getSingleton("b")去获取B。B在缓存中不存在,开始创建B。先实例化B,得到bInstance,同样把B的ObjectFactory放进三级缓存,然后给B填充属性。
阶段三:填充B时发现需要A。这时候精彩的地方来了。Spring在B的字段注入时发现了A,于是又去调用getSingleton("a", true)。这里的第二个参数allowEarlyReference=true表示允许提前引用。查找顺序是:一级缓存没有A,二级缓存没有A,三级缓存有A的ObjectFactory,于是调用这个工厂的getObject(),得到A的早期引用aEarlyRef(此时A还没有填充B属性),把这个早期引用放入二级缓存,同时从三级缓存里移除A的工厂。
阶段四:B拿到A的早期引用。B的a字段被赋值成aEarlyRef,然后B继续走自己的生命周期。B完成属性填充、初始化,最终被放入一级缓存。此时B是完整对象,A还是半成品。
阶段五:A继续完成自己。Spring在B创建完成后,拿到了B的完整对象,把它赋值给A的b字段。A继续完成后面的初始化流程,最终也被放入一级缓存。整个流程结束。
3.2 缓存内容变化速览
这个过程很容易记混,我做了一张表,把关键节点的三张缓存状态列出来:
| 节点 | 一级缓存 | 二级缓存 | 三级缓存 |
|---|---|---|---|
| A实例化完成 | 空 | 空 | a -> A的工厂 |
| B实例化完成 | 空 | 空 | a -> A的工厂, b -> B的工厂 |
| B填充A时 | 空 | a -> aEarlyRef | b -> B的工厂 |
| B创建完成 | b -> B完整对象 | a -> aEarlyRef | 空 |
| A创建完成 | a -> A完整对象, b -> B完整对象 | 空 | 空 |
这张表是整个三级缓存机制的核心,能把这张表的演变过程讲清楚,面试环节基本就稳了。
3.3 提前引用的对象会不会是半成品
有人会问:B拿到的A是半成品,万一在B初始化的时候,有代码通过B去调A里依赖的其他对象,是不是就空指针了?
这个问题确实存在,但Spring的默认做法是在容忍这种风险。Bean的创建顺序是先实例化再填充属性,半成品A只有自己那些已经赋过值的属性是安全的,其他属性全是null。所以实际项目中,如果发现循环依赖的Bean之间在初始化阶段就互相调用方法,很容易出现空指针这类诡异问题。这也是为什么Spring官方一直在建议“尽量不要使用循环依赖”,它只是一种兼容机制,不是一种推荐的设计。
4. 为什么非要三级,二级缓存为什么不行
4.1 二级缓存方案的两种假设
理解了三级缓存的工作流程,一个非常自然的问题就来了:把三级缓存去掉,只用“一级成品缓存 + 二级早期引用缓存”,能行吗?
假设只有二级缓存,那么往二级缓存里能放两种东西:一种是原始的半成品对象,另一种是经过AOP处理后的代理对象。我们逐个分析。
如果二级缓存放的是原始半成品对象,那么当B依赖A时,B拿到的就是A的原始对象。如果A在后续生命周期中需要被AOP增强(比如加了@Transactional或@Async),那么Spring最终放进一级缓存的A是一个代理对象,而B注入的却是原始对象。这会导致同一个Bean在容器中存在两个不同的实例,B调用A的方法没有事务增强,外部容器拿到的A却有增强,逻辑直接错乱。
如果二级缓存放的是AOP代理对象,那就必须在一个Bean刚实例化出来、还没走完BeanPostProcessor完整链路的时候,就确定它是否需要代理、需要生成什么样的代理。这等于把“后置处理”提前到了“实例化刚完成”的阶段,整个架构上的处理顺序就被打乱了。Spring的AOP能力是建立在完整的Bean生命周期之上的,提前做代理会让很多功能失效。
4.2 AOP代理让二级缓存直接崩盘
直接说结论:二级缓存方案之所以不行,核心障碍就是AOP代理。
我见过一篇讲源码的文章用了很精辟的一句话:三级缓存不是为“循环依赖”准备的,而是为“循环依赖+AOP”准备的。如果一个系统里完全没有任何AOP,那二级缓存其实也够用——直接往二级缓存里塞原始半成品对象就可以了,因为最终放进一级缓存的也是同一个对象,引用不会变。
但现实是,Spring项目里@Transactional、@Cacheable、@Async到处都是,这些注解背后都是AOP代理。只要A需要代理,B就不能拿到A的原始对象,否则B里注入的A没有代理效果。
那么为什么二级缓存不能存储“提前生成好的代理对象”呢?关键在于代理生成的触发点是在AbstractAutoProxyCreator的postProcessAfterInitialization方法里,这个方法要在Bean初始化完成后才会执行。如果在一个Bean刚实例化完、属性还没填充时就要决定是否代理,就得提前调用AOP的逻辑,而这会破坏BeanPostProcessor的执行顺序,引发一连串兼容性问题。
4.3 getEarlyBeanReference的懒加载设计
三级缓存的设计把这个难题变成了一个“延迟决策”。三级缓存里存的不是对象,而是ObjectFactory。当有人提前索要这个Bean的引用时,才会调用工厂,工厂内部通过getEarlyBeanReference方法做决策:判断当前是否需要立即AOP,如果需要就返回代理对象;如果不需要就返回原始对象。
对应的源码就在AbstractAutoProxyCreator.getEarlyBeanReference里:
public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey = getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }注意wrapIfNecessary这个名字,它的意思很明白:如果必要就包装,不必要就直接返回原始对象。这个判断可以推迟到真正有人需要早期引用的那一刻才执行,既保证了B能拿到正确的引用,又不会干扰正常的Bean生命周期。
可以这样说:二级缓存解决的是“缓存位置有没有”的问题,三级缓存解决的是“缓存的这个对象此刻应该长什么样”的问题。Spring选择在“对象形态”这个问题上多留一个缓冲层,就是为了兼容AOP这种晚期才能确定形态的场景。
5. 源码走读:DefaultSingletonBeanRegistry 是怎么干活的
5.1 getSingleton 的方法重载与执行顺序
理解了设计思想之后,再去看源码会非常顺。三级缓存相关的核心代码都在DefaultSingletonBeanRegistry这个类中,里面最核心的就是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.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根本没有在创建,那三级缓存就算有东西也不能用,必须老老实实走完整创建流程。
另外注意这里用了synchronized (this.singletonObjects)锁。这个锁不是摆设,因为Spring容器在多线程环境下可能同时有多个线程获取同一个Bean,如果不加锁,两个线程可能同时调用同一个ObjectFactory,产生两个不同的早期引用对象,破坏单例语义。
5.2 addSingletonFactory 与 addSingleton 的成对动作
Bean在实例化完成后,会调用addSingletonFactory把工厂放入三级缓存:
protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) { synchronized (this.singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, singletonFactory); this.earlySingletonObjects.remove(beanName); this.registeredSingletons.add(beanName); } } }这段代码有个细节值得注意:放入三级缓存的同时,会顺手从二级缓存移除同名的对象。为什么要移除?因为新放入的工厂是“最新状态”的代理入口,旧二级缓存里如果残留着之前的早期引用,可能已经过时了。
等到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); this.registeredSingletons.add(beanName); } }这个方法干脆利落:一级缓存放入成品,同时清空二、三级缓存。一个Bean一辈子最终只会在一级缓存里留下一条记录,二、三级缓存都是过程产物。
5.3 getEarlyBeanReference 里的三个关键对象
三级缓存里ObjectFactory的实现在doCreateBean方法中,它实际上是一个lambda表达式:
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));这个lambda返回的对象是getEarlyBeanReference的结果。这个方法在InstantiationAwareBeanPostProcessorAdapter里的默认实现是直接返回参数里的bean:
default Object getEarlyBeanReference(Object bean, String beanName) { return bean; }而AbstractAutoProxyCreator重写了这个方法,在返回对象之前调用wrapIfNecessary,判断要不要创建AOP代理。
所以要理解三级缓存,必须记住三个对象在流程中的不同角色:
- 三级缓存里存的是lambda,它站在“现在”指向“未来”,等有人调用时才执行
getEarlyBeanReference是决策者,决定此刻该返回原始对象还是代理对象- 返回后的结果被放进二级缓存,之后再有查询直接复用
我在源码上踩过的一个坑是:容易把“二级缓存里存的早期引用”和“最终一级缓存的成品”混淆。在无AOP场景下它们是同一个对象;在有AOP且发生提前引用时,二级缓存里可能是一个代理对象,而一级缓存里最终存的是原始对象(早期缓存机制让后期不再重复代理)。理解这一点,对排查诡异问题很有帮助。
6. 实际项目里常见的问题与排查
6.1 构造器循环依赖为什么永远解不了
三级缓存能解决的是“实例化完成 -> 属性填充”阶段的循环依赖,因为它利用了“实例化”和“填充属性”之间的时间差。但如果循环依赖发生在构造器阶段,这个时间差就不存在了。
举个例子:
@Component public class A { private final B b; public A(B b) { this.b = b; } } @Component public class B { private final A a; public B(A a) { this.a = a; } }Spring要创建A,必须先调用A的构造器,而构造器需要B;创建B又需要A。两个Bean都还没走到“实例化完成放三级缓存”这一步,就已经卡死了。所以遇到构造器循环依赖,别指望三级缓存,要么改成字段注入或者Setter注入,要么用@Lazy打破闭环,要么重新设计对象关系。
如果你在项目里看到构造器注入还报BeanCurrentlyInCreationException,先别怀疑Spring的缓存有问题,问题多半出在对象关系设计上。
6.2 @Async、@Transactional 配循环依赖容易踩雷
循环依赖+AOP的组合是最容易出现诡异问题的地方。我实际遇到过的场景是:A通过@Async异步执行某个方法,B依赖A。由于B提前触发了A的早期引用,A的代理对象会提前生成。如果后续A自己完成初始化时,某些BeanPostProcessor的处理逻辑没有拿到同一个代理对象,就会出现“A自己调自己方法时异步不生效”或者“外部拿到的对象和B拿到的对象行为不一致”的情况。
排查这类问题有一个经验法则:先消灭循环依赖,再看AOP是否正常。不要试图在一个循环依赖的复杂关系里同时调试AOP代理行为,那是在给自己挖坑。
有几个快速定位思路:
- 通过
spring.h2.console之类的方式启动时留意启动日志,看有没有循环依赖告警 - 在
getEarlyBeanReference方法处打断点,观察是不是有Bean在创建完成前被提前引用 - 查看Bean的class名,如果出现了
$$EnhancerBySpringCGLIB或$Proxy后缀,说明已经被AOP代理
6.3 SpringBoot 2.6 后默认禁止循环依赖
这里必须提一个重要的版本变化。从SpringBoot 2.6开始,循环依赖在默认情况下被禁止了,启动时如果检测到循环引用,会直接报错:
Description: The dependencies of some of the beans in the application context form a cycle这不是Spring的BUG,而是官方态度转变:循环依赖是设计上的坏味道,应该从源头避免,而不是依赖容器兜底。
如果你还在用老项目,暂时没法快速改造掉循环依赖,可以临时这样开启:
spring.main.allow-circular-references=true但我的建议是,这个配置只用来过渡。长期留着循环依赖,一方面性能上有额外损耗(每次创建Bean都要查三级缓存),另一方面后续升级框架容易踩雷,能拆就拆。
6.4 报错信息速查表
最后整理一个我在工作中常用的排查速查表,遇到问题先按图索骥:
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
BeanCurrentlyInCreationException | 构造器循环依赖 | 改字段注入或用@Lazy打破闭环 |
The dependencies of some of the beans form a cycle | SpringBoot 2.6+ 默认禁用循环依赖 | 先拆依赖,临时可配置allow-circular-references=true |
BeanNotOfRequiredTypeException | 提前引用对象和最终注入类型不一致 | 检查是否有AOP代理,优先消除循环依赖 |
启动日志出现Circular dependency警告 | 存在循环依赖但未报错 | 清理Bean之间的依赖关系 |
这些报错背后的共同源头几乎都是“循环依赖”。搞清楚三级缓存机制,不是为了让你放心大胆地去写循环依赖,而是为了让你在遇到这些报错时,能一眼看到本质,快速定位问题。
我个人在调试这类问题时的体会是,源码看一遍不如亲手跑一遍。用两个@Component互相依赖,在DefaultSingletonBeanRegistry的getSingleton方法里打几个断点,启动项目跟进一次,三张缓存表的变化会看得清清楚楚。源码是死的,跑起来才是活的。