
面试 Java 岗Spring 循环依赖几乎是一道必问题。我见过不少候选人能把“一级缓存存成品、二级缓存存半成品、三级缓存存 ObjectFactory”背得很顺但只要我追问一句那第三级能不能去掉场面就会安静好几秒。这种安静很正常因为三张缓存表只是答案的壳真正的内核是“为什么非要这么设计”。这篇文章就从这个问题出发把三级缓存、三个 Map、循环依赖的解决链路拆开讲清楚既适合准备 Spring 面试的人也适合在项目里被循环依赖启动报错折磨过的开发。1. 这个连环追问能筛掉多少候选人1.1 先说结论三级缓存就是三张登记表Spring 的单例 Bean 默认都放在DefaultSingletonBeanRegistry这个“大本营”里。在里面你能找到三个 Map几乎所有循环依赖的源码分析都绕不开它们缓存层级变量名类型存放内容一级缓存singletonObjectsMapString, Object完全初始化好的单例 Bean也就是“成品”二级缓存earlySingletonObjectsMapString, Object提前暴露的 Bean 早期引用可能是“半成品”也可能已经是 AOP 代理对象三级缓存singletonFactoriesMapString, ObjectFactory?BeanName 到ObjectFactory的映射工厂负责在必要时生成早期引用很多人会把这个结论背下来但有几个细节容易被忽略第三个 Map 的 value 不是 Bean而是一个ObjectFactory二级缓存里的对象也不是直接从三级缓存里“拿出来”的而是调用了三级缓存中那个工厂的getObject()方法后才产生的结果。换句话说三级缓存更像一个“延迟触发器”不是单纯的容器。从命名上看一级和二级都是直接存对象只有三级存的是工厂。这个差异不是随手写的它决定了 Spring 能在多大程度上推迟代理对象的创建后面第 4 节我会详细展开。1.2 三级缓存解决的是“哪一种”循环依赖先明确一个很容易搞混的点不是所有循环依赖 Spring 都能解决。三级缓存这套机制只对“单例 Bean 字段注入 / setter 注入”的循环依赖有效。什么是循环依赖A 依赖 BB 又依赖 A两者互相引用。如果 A 在创建时需要注入 B而 B 在创建时需要注入 A两边都等对方先构造完谁都没法结束。Spring 的解决办法不是把两个对象同时造出来而是先把 A 的“早期引用”放出来让 B 先拿到一个可用的 A等 B 创建完A 再继续处理剩下的事情。但如果是构造器注入A 在构造时就必须传入 B 的实例。也就是说A 还没 new 出来Spring 根本拿不到它的早期引用更不可能提前暴露。这个场景下三级缓存无能为力只能直接报BeanCurrentlyInCreationException。还有一个前提是单例。如果 Bean 作用域是 prototypeSpring 根本不会缓存它每次都是新建自然没有“提前暴露”这回事。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 依赖 BB 又依赖 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你只能在“提前暴露原始对象”和“提前暴露代理对象”之间二选一。有了第三个 MapSpring 才做到“按需暴露”需要代理时才生成代理不需要代理时就只是登记一个工厂后续无需清理太多中间状态。另外一个容易忽略的细节是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 依赖 BB 依赖 A 时两边都等着对方创建Spring 不会缓存任何中间状态只能靠Lazy之类的方案打破循环。Lazy的做法是在注入点不直接注入目标 Bean而是注入一个 JDK 动态代理等到真正调用目标对象方法时才去容器里获取被依赖的 Bean。这样 A 创建时不需要真正拿到 BB 创建时也不需要拿到 A循环关系被“代理”切断了。还有一类场景要警惕即使 Spring 没有报错循环依赖也可能导致某些增强“诡异失效”。比如 A 依赖 BB 依赖 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 依赖 BB 不再依赖 A而是依赖一个抽象接口或一个专门的事件发布器。如果只是临时让项目跑起来可以在配置文件中设置spring.main.allow-circular-referencestrue但要在代码里记录 TODO尽快治理。6. 从源码到实战我如何处理项目里的循环依赖6.1 Spring Boot 2.6 以后默认禁止这是好事很多人抱怨 Spring Boot 2.6 默认禁止循环依赖太激进老项目一升级就起不来。但站在长期维护的角度这个默认策略反而是保护。循环依赖虽然能被三级缓存解决但它会让 Bean 的创建顺序变得隐晦代码越往后越难梳理。我之前接手过一个老项目里面有一组 Service 互相注入启动时依赖三级缓存才能跑起来。后来某个版本升级Spring 开始默认检查循环依赖启动直接崩了。排查下来发现这些循环引用绝大多数是因为 Service 层职责堆得太厚A 在业务上根本不需要依赖 B 的完整能力只是需要 B 里的一个查询方法。后来把公共查询抽到独立的 Repository 或工具类依赖关系一下就清爽了。所以如果项目启动报循环依赖错误不要第一反应是改配置而是先看看这个循环依赖是不是设计问题。6.2 治理循环依赖的四个方向我在实际项目里常用的治理手段可以分成四类第一重构依赖方向。最常见的方法是提取中间层。A 依赖 BB 又依赖 A 时可以把 B 依赖 A 的那部分逻辑抽到一个独立的 Service 或组件中让两个类都只依赖这个新组件而不是互相依赖。第二使用Lazy。如果两个类确实需要互相引用而且短期没有重构成本可以在构造器或字段注入上标注Lazy让 Spring 注入代理对象延迟真正获取目标 Bean 的时机。这是成本最低的临时方案但会让实际调用路径多一层代理需要权衡。第三用事件机制解耦。A 完成某个操作后不直接调用 B 的方法而是发布一个ApplicationEvent由 B 监听并处理。这样 A 和 B 在编译期不产生直接依赖运行期的耦合也被事件解耦了。适合业务上“A 做完某件事需要通知 B”的场景。第四使用ObjectProviderT延迟获取。在注入点声明ObjectProviderB不直接注入 B而是在代码中按需调用getIfAvailable()。这适合 B 不是必须存在、或者调用时机不固定的场景。6.3 面试时怎么把这道题讲出差异化这道题现在面试问得太频繁面试官想听的往往不只是“三个 Map 分别是什么”而是你有没有把设计逻辑想清楚。我建议按这个顺序讲先讲循环依赖的本质对象创建都是“先 new 出来再填充属性”只要允许半成品先暴露单例循环依赖就有解。再讲三个 Map 的分工一级存成品二级存已经提前暴露的早期引用三级存的是ObjectFactory。接着讲触发流程A 创建时注册工厂B 需要 A 时触发工厂得到早期引用放入二级缓存并移除三级缓存。然后讲为什么需要三级两级缓存无法解决“是否提前代理”的矛盾三级缓存的ObjectFactory实现了延迟决策。最后提一句源码细节getEarlyBeanReference会调用SmartInstantiationAwareBeanPostProcessorAbstractAutoProxyCreator用earlyProxyReferences避免重复代理。如果你能把这些讲通面试官基本能判断你是真的看过源码而不是背了一篇八股文。我自己每次看这一段源码时都会感慨 Spring 在一个看似很小的点上的设计能力。三个 Map 并不复杂但每个 Map 的职责边界、触发时机和淘汰策略都很清晰。这种把“状态”和“行为”分开的思路放在任何系统中都值得借鉴。下次再遇到项目里的循环依赖别急着改配置先想想是不是可以重新设计依赖方向——这比多背几个知识点有用得多。