面试 Java 岗,Spring 循环依赖几乎是一道必问题。我见过不少候选人能把“一级缓存存成品、二级缓存存半成品、三级缓存存 ObjectFactory”背得很顺,但只要我追问一句:那第三级能不能去掉?场面就会安静好几秒。这种安静很正常,因为三张缓存表只是答案的壳,真正的内核是“为什么非要这么设计”。这篇文章就从这个问题出发,把三级缓存、三个 Map、循环依赖的解决链路拆开讲清楚,既适合准备 Spring 面试的人,也适合在项目里被循环依赖启动报错折磨过的开发。
1. 这个连环追问,能筛掉多少候选人
1.1 先说结论:三级缓存就是三张登记表
Spring 的单例 Bean 默认都放在DefaultSingletonBeanRegistry这个“大本营”里。在里面你能找到三个 Map,几乎所有循环依赖的源码分析都绕不开它们:
| 缓存层级 | 变量名 | 类型 | 存放内容 |
|---|---|---|---|
| 一级缓存 | singletonObjects | Map<String, Object> | 完全初始化好的单例 Bean,也就是“成品” |
| 二级缓存 | earlySingletonObjects | Map<String, Object> | 提前暴露的 Bean 早期引用,可能是“半成品”,也可能已经是 AOP 代理对象 |
| 三级缓存 | singletonFactories | Map<String, ObjectFactory<?>> | BeanName 到ObjectFactory的映射,工厂负责在必要时生成早期引用 |
很多人会把这个结论背下来,但有几个细节容易被忽略:第三个 Map 的 value 不是 Bean,而是一个ObjectFactory;二级缓存里的对象,也不是直接从三级缓存里“拿出来”的,而是调用了三级缓存中那个工厂的getObject()方法后才产生的结果。换句话说,三级缓存更像一个“延迟触发器”,不是单纯的容器。
从命名上看,一级和二级都是直接存对象,只有三级存的是工厂。这个差异不是随手写的,它决定了 Spring 能在多大程度上推迟代理对象的创建,后面第 4 节我会详细展开。
1.2 三级缓存解决的是“哪一种”循环依赖
先明确一个很容易搞混的点:不是所有循环依赖 Spring 都能解决。三级缓存这套机制,只对“单例 Bean + 字段注入 / setter 注入”的循环依赖有效。
什么是循环依赖?A 依赖 B,B 又依赖 A,两者互相引用。如果 A 在创建时需要注入 B,而 B 在创建时需要注入 A,两边都等对方先构造完,谁都没法结束。Spring 的解决办法不是把两个对象同时造出来,而是先把 A 的“早期引用”放出来,让 B 先拿到一个可用的 A,等 B 创建完,A 再继续处理剩下的事情。
但如果是构造器注入,A 在构造时就必须传入 B 的实例。也就是说,A 还没 new 出来,Spring 根本拿不到它的早期引用,更不可能提前暴露。这个场景下三级缓存无能为力,只能直接报BeanCurrentlyInCreationException。
还有一个前提是单例。如果 Bean 作用域是 prototype,Spring 根本不会缓存它,每次都是新建,自然没有“提前暴露”这回事。
1.3 三个 Map 各自的职责边界
再往细看,一级缓存是最终结果的存放处:所有 Bean 初始化完成后,都会通过addSingleton方法放进singletonObjects,同时把二级、三级缓存里的对应内容清掉。日常通过getBean("xxx")拿到的基本都是这个 Map 里的对象。
二级缓存是“半成品收留所”。当某个 Bean 还在创建过程中,另一个 Bean 正好需要引用它时,Spring 会把一个早期对象放进earlySingletonObjects,让后续调用能直接命中,而不会再次触发创建。这个 Map 的存在是为了保证同一个 Bean 在循环依赖中只被“提前暴露”一次,不会每次都重新生成。
三级缓存则是提前引用的“生产车间”。singletonFactories里的工厂方法,核心逻辑会走到getEarlyBeanReference,这一步允许SmartInstantiationAwareBeanPostProcessor对早期对象进行加工,最常见的就是 AOP 代理。正因为有了这层工厂,Spring 才能做到“需要代理的时候才生成代理”,而不是在 Bean 刚 new 出来时就盲目代理。
所以三个 Map 的职责可以概括成一句话:一级管成品,二级管“已经暴露过的半成品”,三级管“如何生产半成品”。
2. Bean 还没“装修完”,为什么就敢拿出来见人
2.1 从 new 出来的半成品说起
一个 Bean 的完整生命周期大致可以分成四步:实例化、属性填充、初始化、注册到单例池。
- 实例化:通过构造器 new 出一个对象,此刻对象里的属性全是默认值,比如引用类型是 null,基本类型是 0 或 false。
- 属性填充:Spring 扫描
@Autowired、@Resource、@Value等注解,把依赖属性塞进去。 - 初始化:执行
InitializingBean、@PostConstruct、BeanPostProcessor等初始化逻辑。 - 注册:把完全准备好的对象放入一级缓存,之后
getBean才能拿到。
如果 A 依赖 B,B 又依赖 A,正常顺序必然是:A 实例化 -> A 需要 B -> 于是去创建 B -> B 实例化 -> B 需要 A -> 于是去创建 A。此时 A 已经在创建中了,再创建 A 就会重复。如果没有特殊处理,这里会死循环。
但换个角度看,A 虽然只完成了“实例化”,还没有完成属性填充和初始化,可它已经是一个真实存在的 Java 对象了,内存地址是确定的,B 完全可以在此时先持有它的引用。后面 A 的属性填充和初始化,都是在这个对象上继续做,最终 B 手里的引用不会变。
这就是 Spring 敢于提前暴露的底气:它暴露的不是“没造完的东西”,而是一个“还没装修完但地址已经确定的房子”。
2.2 Spring 的“先登记、后装修”策略
Spring 在 A 实例化完成后、属性填充之前,就会执行addSingletonFactory,把 A 的ObjectFactory放进三级缓存。这一步特别像开发商卖期房:房子主体刚封顶,水电还没通,但房产已经可以登记了,买家可以先拿到钥匙。后期装修不会改变房子的位置,只是让房子变得更完整。
对应到代码层面,AbstractAutowireCapableBeanFactory在doCreateBean中,完成实例化后就会判断:如果这个 Bean 是单例,并且允许提前暴露,那么就会调用addSingletonFactory。这个判断非常重要,因为只有单例 Bean 才需要缓存,只有允许提前暴露,循环依赖才有被解决的可能。
addSingletonFactory本身做的事情很简单:把一个ObjectFactory放入三级缓存。但真正值得关注的是这个ObjectFactory内部做了什么。在DefaultSingletonBeanRegistry的getSingleton方法里,如果从二级缓存没拿到对象,就会触发三级缓存中的工厂调用。工厂的getObject()会执行getEarlyBeanReference,而getEarlyBeanReference会遍历所有SmartInstantiationAwareBeanPostProcessor,让它们有机会提前包装这个对象。
在实际项目中,最常见的SmartInstantiationAwareBeanPostProcessor就是 AOP 的AbstractAutoProxyCreator。所以这一步发生的典型动作是:为 A 生成一个动态代理对象,然后把这个代理对象放进二级缓存。
2.3 三级缓存出现之前,代码里发生了什么
很多初学者会有疑问:为什么不直接在一级缓存里放一个“半成品标记”,等创建完成后再替换?因为替换会让其他 Bean 拿到不同的对象引用,破坏单例的一致性。
举个例子:B 在创建过程中拿到了 A 的原始对象,并把它注入到自己的字段里。之后 A 完成 AOP 代理,一级缓存里最终放的是 A 的代理对象。如果 B 持有的还是原始对象,那么 B 调用 A 的方法时,AOP 增强逻辑就不会生效,这显然不能接受。
Spring 的解决思路是:一旦发现 A 已经被其他 Bean 提前引用,就立刻在提前引用阶段生成 AOP 代理,并且把代理对象放进二级缓存。这样后续 B 拿到的、A 最终注册进一级缓存的,都是同一个代理对象,单例的一致性才能被保证。
三级缓存不是“缓存了半成品”这么简单,它实际上解决的是一个更棘手的问题:Spring 不知道 A 是否会被循环依赖,也不知道是否应该提前生成代理,所以它把“是否现在生成”的决定权,延迟到了“有人真正需要 A 的早期引用”的那一刻。
3. 完整走一遍:A 和 B 互相依赖时三个 Map 的状态变化
3.1 getSingleton 的查找顺序为什么是先查两处再查工厂
要理解状态变化,先看DefaultSingletonBeanRegistry里最核心的getSingleton方法。源码逻辑可以简化成下面这段伪代码:
protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 第 1 步:查一级缓存,拿到就直接返回 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { // 第 2 步:查二级缓存,拿到就直接返回 singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { // 第 3 步:查三级缓存,取出 ObjectFactory,执行 getObject() ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); // 移入二级缓存,并从三级缓存移除 this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }第一步查一级缓存,是因为如果 Bean 已经创建完成,就没必要走后面那些复杂逻辑。第二步查二级缓存,是因为如果已经有人触发过三级缓存工厂,早期引用已经生成,直接复用即可。第三步才轮到singletonFactories,此时说明当前 Bean 还在创建中,也还没有被提前暴露过,需要调用工厂生成一个早期引用。
注意代码里的条件:只有在isSingletonCurrentlyInCreation(beanName)为 true 时,才会继续查二级和三级。这是为了避免普通getBean调用误拿到未创建完的 Bean。
3.2 A 创建前半段:三级缓存里出现 A 的 ObjectFactory
假设整条链路从getBean(A)开始。
getSingleton(A)从一级缓存没拿到,于是进入创建流程。createBean(A)内部实例化出一个 A 对象。- 实例化完成后,调用
addSingletonFactory(A, () -> getEarlyBeanReference(...)),把 A 的工厂放入三级缓存。 - 开始
populateBean(A),扫描到 A 依赖 B,于是再次调用getSingleton(B)。
此刻三个缓存的状态是:
singletonObjects:空。earlySingletonObjects:空。singletonFactories:{ A -> ObjectFactory }。
A 还没有被填充任何属性,但它的工厂已经“登记在册”。
3.3 B 反查 A:二级缓存救场
getSingleton(B)发现一级缓存没有 B,于是也进入创建流程。
createBean(B)实例化出 B 对象。- 实例化完成后,同样给 B 注册一个
ObjectFactory到三级缓存。 - 开始
populateBean(B),发现 B 依赖 A,于是调用getSingleton(A)。 - 一级缓存没有 A,二级缓存也没有 A,但三级缓存里有 A 的工厂。
- 触发 A 的工厂,执行
getEarlyBeanReference,可能生成 A 的早期代理对象。 - 把这个早期对象放入
earlySingletonObjects,并从singletonFactories中移除 A。
此时三个缓存的状态是:
singletonObjects:空。earlySingletonObjects:{ A -> A的早期引用 }。singletonFactories:{ B -> ObjectFactory }。
B 顺利拿到 A 的早期引用,完成自己的属性填充。接着 B 执行初始化逻辑,最终通过addSingleton(B)把自己放进了singletonObjects。
这里最关键的一步是:A 的早期引用被放进了二级缓存,同时 A 的工厂被移除。这意味着后续再有其他 Bean 依赖 A,会直接命中二级缓存,而不会再次执行getEarlyBeanReference,也就不会生成新的代理对象。
3.4 初始化收尾:三级到二级到一级的迁移
B 创建完成后,A 的populateBean继续执行,把 B 注入到 A 的字段中。然后 A 执行初始化逻辑,完成所有BeanPostProcessor的处理。最后addSingleton(A)把 A 放入singletonObjects,同时清除earlySingletonObjects和singletonFactories中的 A。
最终三个缓存的状态是:
singletonObjects:{ A -> 初始化完成的A, B -> 初始化完成的B }。earlySingletonObjects:空。singletonFactories:空。
这个过程中可以看到一个明显规律:Bean 的最终归宿是一级缓存,二级和三级缓存只是创建过程中的“临时中转站”。有人会把三级缓存理解成“Bean 先进三级,再进二级,最后进一级”,这种说法只适用于发生循环依赖的情况。如果某个 Bean 从头到尾没有被别人提前引用,它的ObjectFactory可能一直躺在三级缓存里,直到初始化完成时被addSingleton统一清理掉。
4. 为什么三级不是两级?AOP 代理才是那道分水岭
4.1 如果只有一级缓存,会怎样
假设 Spring 只用一个 Map 存 Bean,会发生两件事:
第一,无法区分“创建中的 Bean”和“创建完成的 Bean”。如果 A 还在属性填充阶段,B 需要 A,从同一个 Map 里拿到 A,但 A 还带着一堆 null 属性,B 用完可能直接空指针。第二,Spring 很多逻辑判断都是基于“这个 Bean 是否在创建中”,没有独立缓存,状态管理会非常混乱。
所以一级缓存肯定不够,至少需要一个地方来存“还没创建完但可以提前拿到的对象”。这就是二级缓存存在的意义。从纯循环依赖角度看,一级加二级似乎已经能跑通了:A 实例化后直接放进二级缓存,B 来拿 A 时从二级缓存取到半成品,等 A 最终创建完成再放进一级缓存。
但问题在于:如果 A 需要 AOP 代理,这个方案就会崩。
4.2 如果只有两级缓存,问题出在哪
Spring AOP 的默认实现是,在 Bean 初始化完成后,通过AbstractAutoProxyCreator.postProcessAfterInitialization为 Bean 创建代理对象。如果没有提前暴露机制,循环依赖中的 B 拿到的是 A 的原始对象,等到 A 初始化完成后,Spring 又生成了一个代理对象并放进一级缓存,B 和 A 的最终代理就失去了同一个引用。
有人会说,那就让 A 在实例化后立刻生成代理,然后把代理对象放二级缓存。这么做确实能解决引用一致性问题,但它会带来两个新问题:
- 所有 Bean 只要被创建,都要提前走一遍
getEarlyBeanReference,即使根本没有发生循环依赖。 - 很多增强逻辑依赖 Bean 的属性填充和初始化结果,过早生成代理可能拿到一个“半吊子”代理,后续属性更新也没法可靠同步。
所以二级缓存里到底该放原始对象还是代理对象,怎么选都别扭。放原始对象,会破坏 AOP;放代理对象,又会让所有 Bean 都付出 AOP 提前代理的代价。
4.3 三级缓存的关键:ObjectFactory 延迟决策
三级缓存的巧妙之处在于:它并不直接放“最终要暴露的对象”,而是放了一个工厂。工厂什么时候被调用?只有当有人真的需要这个 Bean 的早期引用时,才会触发getObject(),此时才去执行getEarlyBeanReference,决定是否要生成 AOP 代理。
这就是“延迟决策”:
- 如果 A 没有被任何 Bean 提前引用,A 的工厂就永远不会被调用,A 会按正常流程初始化,最后在
postProcessAfterInitialization阶段正常生成 AOP 代理。 - 如果 A 被 B 提前引用了,工厂才被触发,A 的早期代理会在这个时间点生成,然后放进二级缓存,保证后续所有依赖方拿到的都是同一个代理对象。
这样一来,Spring 既不需要让所有 Bean 都提前代理,也能保证循环依赖场景下引用一致。第三级缓存本质上是把“要不要提前代理”的选择,从 Bean 创建时延后到了“有人真正向我要早期引用”的时刻。
4.4 终极奥义:把“是否代理”的选择权留到最后一刻
回到题目里“终极奥义”这四个字。我觉得真正的奥义不是“三个 Map”这个表象,而是那个ObjectFactory带来的延迟能力。
如果只看两个 Map,你只能在“提前暴露原始对象”和“提前暴露代理对象”之间二选一。有了第三个 Map,Spring 才做到“按需暴露”:需要代理时才生成代理,不需要代理时就只是登记一个工厂,后续无需清理太多中间状态。
另外一个容易忽略的细节是:AbstractAutoProxyCreator里有一个earlyProxyReferences集合,专门记录哪些 Bean 已经被提前代理。当getEarlyBeanReference创建过代理后,postProcessAfterInitialization发现earlyProxyReferences里已经有这个 Bean,就不会再创建一遍代理。这个机制保证了“提前代理”和“最终代理”不会重复,也让早期引用和最终单例对象最终保持一致。
所以三级缓存不是简单的三层 Map 嵌套,它是一套“延迟生成 + 单例一致性保护 + AOP 代理协调”的综合方案。
5. 这些循环依赖,三级缓存也救不了
5.1 构造器注入:对象还没生出来,没法提前曝光
A 和 B 如果都是通过构造器注入产生循环依赖:
@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 会直接报错。原因很简单:构造器注入需要在 new 对象之前就拿到参数,但对象本身还没有被实例化,自然不可能放进二级或三级缓存。三级缓存能处理的是“对象已经 new 出来,但属性还没填充”的半成品,对于“对象还没出生”的情况,任何缓存都帮不上忙。
所以很多团队强制要求使用构造器注入,这不是保守,而是通过把一个潜在隐患在启动阶段直接暴露出来。如果你正在用字段注入,代码里已经出现循环依赖但没有报错,不要觉得岁月静好,那只是三级缓存帮你兜底了。一旦切到构造器注入,问题就会立刻涌现。
5.2 原型 Bean 与复杂代理场景
非单例 Bean 不在缓存管理范围内。每次getBean都是新对象,A 依赖 B,B 依赖 A 时,两边都等着对方创建,Spring 不会缓存任何中间状态,只能靠@Lazy之类的方案打破循环。
@Lazy的做法是:在注入点不直接注入目标 Bean,而是注入一个 JDK 动态代理,等到真正调用目标对象方法时,才去容器里获取被依赖的 Bean。这样 A 创建时不需要真正拿到 B,B 创建时也不需要拿到 A,循环关系被“代理”切断了。
还有一类场景要警惕:即使 Spring 没有报错,循环依赖也可能导致某些增强“诡异失效”。比如 A 依赖 B,B 依赖 A,同时 B 还需要 A 的事务增强或异步增强。由于 B 拿到的 A 已经是早期引用,如果早期引用和最终增强对象不一致,业务方法上的切面可能不会生效。这类问题通常比启动报错更难排查,日志不会告诉你“循环依赖导致 AOP 异常”,只会表现为某些方法调用没走代理。
5.3 如果启动日志里已经出现循环依赖,该怎么做
Spring Boot 2.6 之后默认禁止循环依赖,一旦检测到,启动会直接失败。这时先不要急着把allow-circular-references打开,因为那只是掩盖问题,不是解决问题。
好的做法是:
- 先定位循环依赖的完整链路,看是 A 依赖 B、B 依赖 A 的直接循环,还是 A -> B -> C -> A 的间接循环。
- 看看有没有重复依赖。比如 A 明明只需要 B 的一个方法,B 又反过来依赖 A,很可能两个类的职责划分有问题。
- 尝试把单向依赖关系抽出来。A 依赖 B,B 不再依赖 A,而是依赖一个抽象接口或一个专门的事件发布器。
- 如果只是临时让项目跑起来,可以在配置文件中设置
spring.main.allow-circular-references=true,但要在代码里记录 TODO,尽快治理。
6. 从源码到实战:我如何处理项目里的循环依赖
6.1 Spring Boot 2.6 以后默认禁止,这是好事
很多人抱怨 Spring Boot 2.6 默认禁止循环依赖太激进,老项目一升级就起不来。但站在长期维护的角度,这个默认策略反而是保护。循环依赖虽然能被三级缓存解决,但它会让 Bean 的创建顺序变得隐晦,代码越往后越难梳理。
我之前接手过一个老项目,里面有一组 Service 互相注入,启动时依赖三级缓存才能跑起来。后来某个版本升级,Spring 开始默认检查循环依赖,启动直接崩了。排查下来发现,这些循环引用绝大多数是因为 Service 层职责堆得太厚,A 在业务上根本不需要依赖 B 的完整能力,只是需要 B 里的一个查询方法。后来把公共查询抽到独立的 Repository 或工具类,依赖关系一下就清爽了。
所以如果项目启动报循环依赖错误,不要第一反应是改配置,而是先看看这个循环依赖是不是设计问题。
6.2 治理循环依赖的四个方向
我在实际项目里常用的治理手段可以分成四类:
第一,重构依赖方向。最常见的方法是提取中间层。A 依赖 B,B 又依赖 A 时,可以把 B 依赖 A 的那部分逻辑抽到一个独立的 Service 或组件中,让两个类都只依赖这个新组件,而不是互相依赖。
第二,使用@Lazy。如果两个类确实需要互相引用,而且短期没有重构成本,可以在构造器或字段注入上标注@Lazy,让 Spring 注入代理对象,延迟真正获取目标 Bean 的时机。这是成本最低的临时方案,但会让实际调用路径多一层代理,需要权衡。
第三,用事件机制解耦。A 完成某个操作后,不直接调用 B 的方法,而是发布一个ApplicationEvent,由 B 监听并处理。这样 A 和 B 在编译期不产生直接依赖,运行期的耦合也被事件解耦了。适合业务上“A 做完某件事需要通知 B”的场景。
第四,使用ObjectProvider<T>延迟获取。在注入点声明ObjectProvider<B>,不直接注入 B,而是在代码中按需调用getIfAvailable()。这适合 B 不是必须存在、或者调用时机不固定的场景。
6.3 面试时怎么把这道题讲出差异化
这道题现在面试问得太频繁,面试官想听的往往不只是“三个 Map 分别是什么”,而是你有没有把设计逻辑想清楚。
我建议按这个顺序讲:
- 先讲循环依赖的本质:对象创建都是“先 new 出来再填充属性”,只要允许半成品先暴露,单例循环依赖就有解。
- 再讲三个 Map 的分工:一级存成品,二级存已经提前暴露的早期引用,三级存的是
ObjectFactory。 - 接着讲触发流程:A 创建时注册工厂,B 需要 A 时触发工厂,得到早期引用,放入二级缓存并移除三级缓存。
- 然后讲为什么需要三级:两级缓存无法解决“是否提前代理”的矛盾,三级缓存的
ObjectFactory实现了延迟决策。 - 最后提一句源码细节:
getEarlyBeanReference会调用SmartInstantiationAwareBeanPostProcessor,AbstractAutoProxyCreator用earlyProxyReferences避免重复代理。
如果你能把这些讲通,面试官基本能判断你是真的看过源码,而不是背了一篇八股文。
我自己每次看这一段源码时,都会感慨 Spring 在一个看似很小的点上的设计能力。三个 Map 并不复杂,但每个 Map 的职责边界、触发时机和淘汰策略都很清晰。这种把“状态”和“行为”分开的思路,放在任何系统中都值得借鉴。下次再遇到项目里的循环依赖,别急着改配置,先想想是不是可以重新设计依赖方向——这比多背几个知识点有用得多。