循环依赖只答三级缓存?Boot 2.6 默认关闭,启动即崩

场景:面试标准答案"三级缓存"在 Spring Boot 2.6 之后默认失效,一次版本升级让启动直接崩在 BeanCurrentlyInCreationException 上 路径:质疑(事故)→ 考题(标准答案)→ 路径(加分回答)→ 真相(对比)→ 升级

面试官问:"Spring 循环依赖怎么解决?"标准答案很顺口:三级缓存——一级放完整单例,二级放提前暴露的早期引用,三级放创建工厂。

这个答案没错,但它只覆盖了 30%。

直到线上一次升级——Spring Boot 从 2.5.14 升到 2.6.15,启动直接崩在一个我再熟悉不过的报错上:BeanCurrentlyInCreationException(Bean 正在创建中异常,Spring 无法解析循环引用时的典型报错)。那一刻我才理解,三级缓存不是失效了,而是被 Spring 自己默认关了。

上篇讲了 @Transactional 抛异常就回滚的标准答案漏掉代理边界,这篇我们把目光从"事务注解"挪到"Bean 生命周期"——循环依赖的面试答案,和生产里的失效方式。

【质疑】生产事故:升级 Boot 2.6,启动即崩

事故现场:2.5.14→2.6.15 升级,启动报 BeanCurrentlyInCreationException

本文事故基于某结算服务的生产现场还原(JDK 11 + Spring Boot 2.5.14 → 2.6.15,4C8G 容器,已脱敏)。

该服务的 SettlementServiceRiskService 互相 setter 注入,两个 Bean 循环依赖。这套代码跑了两年没出过事——因为 2.5.x 时代 Spring 默认允许循环引用,三级缓存把这个问题静默消化了。升级 2.6.15 后启动,容器在装配 RiskService 时直接抛错:

升级 2.6.15 后启动崩溃:BeanCurrentlyInCreationException

报错首行清晰写着:Requested bean is currently in creation: Is there an unresolvable circular reference?——请求中的 Bean 正在创建中:是否有一个无法解析的循环引用?

复盘钩子:你知道它叫什么,但说不清它什么时候失效

事故当晚我翻了半天代码,第一反应是"循环依赖新增的?"——没有,这两行互相注入的代码在 git log 里已经两年没动过。真正变的是 Spring Boot 的默认行为:2.6.0 起 spring.main.allow-circular-references 默认改为 false,等于把三级缓存这座桥从默认桥拆了,只有你显式说"需要"它才重新搭回来。

2.6 默认配置变化:allow-circular-references 默认 false

这里藏着全文最反直觉的一个点:你背得出三级缓存的源码顺序,却说不清它什么时候失效。 面试问"循环依赖怎么解决",你答三级缓存——在 2.5 及以前这是标准答案;在 2.6 及以后的默认配置下,这个答案就不再成立。

【考题】面试题:循环依赖怎么解决?标准答案:三级缓存

面试第一问:标准答案长这样

先看面试时背诵的标准版本——"Spring 用三级缓存解决循环依赖":

面试标准答案:三级缓存

三级缓存指的是 DefaultSingletonBeanRegistry 里三个 Map(Bean 单例注册器,Spring 容器内部管理单例 Bean 的仓库):

  • 一级缓存 singletonObjects:存创建完成的完整单例 Bean(完全初始化,可被外部直接使用)
  • 二级缓存 earlySingletonObjects:存提前暴露的早期引用(Bean 还在创建中,但已经可以被别人引用)
  • 三级缓存 singletonFactories:存 ObjectFactory(对象工厂,一个"到需要时才动手创建"的懒工厂)

三级缓存解决的循环依赖,指的是 A 依赖 B、B 又依赖 A 这种场景。A 创建到一半发现自己需要 B,B 创建时又回头找 A——如果 A 不提前把自己"半成品"暴露出去,B 就永远拿不到 A,死锁。

这套答案面试及格没问题。但面试官不会停在这里——真正的追问,是从这个答案没讲清楚的地方挖出来的:

  • "三级"——为什么是三级不是两级?二级缓存理论上也够吗?
  • "缓存"——缓存里放的是实例还是工厂?二级和三级到底差在哪?
  • "解决"——所有循环依赖都能解决吗?构造器注入呢?@Async 呢?

标准答案的前提:三级缓存救的是"创建中"的引用,且只救单例 + 非构造器

"三级缓存解决循环依赖"这句话默认了两个前提:Bean 是单例(singleton scope),且循环发生在属性填充阶段(setter/字段注入)

为什么?因为三级缓存的机制是"创建到一半先登记个钩子,让别人能引用半成品"。而这个登记动作,发生在 Bean 实例化(构造方法执行完毕)之后、属性填充之前:

createBeanInstance(调用构造方法)
   → addSingletonFactory(把懒工厂登记进三级缓存,此刻 Bean 可被引用)
   → populateBean(属性填充,A 在这发现需要 B)
   → 若 B 依赖 A → 从三级缓存拿到 A 的懒工厂 → 触发创建 → A 的半成品引用

构造方法这个环节,Bean 还没进入任何缓存,三级缓存机制压根没启动——所以构造器注入的循环依赖,三级缓存救不了。面试答案只讲了"能解决",没讲"能解决哪一类"。

【路径】面试中这么答,才能满分

加分答案:三级缓存为什么是三级不是两级

面试官问"为什么是三级不是两级",标准答案之外的加分回答是这一段:

三级缓存结构:第三级存的是 ObjectFactory 懒工厂

先说结论:两级缓存对"无代理"的循环依赖已经足够。A 提前把半成品放进二级缓存,B 拿到就能用,两个 Map 就能闭环。

但 Spring 有 AOP——@Transactional、@Async 这些注解靠代理对象工作。如果 A 最终要被代理,那 B 注入的就不能是 A 的原始对象,必须是代理对象。问题来了:"A 要不要代理"这个决定,要等 A 创建完成后由 BeanPostProcessor(Bean 后置处理器,在 Bean 初始化后做增强)才能判定——可 A 在属性填充阶段就得被 B 引用,那时候代理还没创建。

所以第三级缓存的作用是:用 ObjectFactory 把"到底给你原始对象还是代理对象"这个决定,推迟到"真正被消费的那一刻"再执行。B 注入 A 时,才触发 getEarlyBeanReference(获取早期 Bean 引用的钩子方法),此时才知道 A 要不要代理、要什么代理——按需生产,而不是提前给所有 Bean 造一遍代理。

这一段答完,面试官能立刻判断:这个人不是背了三个 Map 的名字,是真理解"懒工厂"这三个字的含义。

面试官追问:AOP 代理和提前暴露,谁先谁后?

追问来了:"那提前暴露的到底是原始对象还是代理对象?"

分两种场景。有循环依赖时:A 在属性填充阶段被 B 引用,触发三级缓存的 ObjectFactory,此时经 getEarlyBeanReference 提前创建代理——所以 B 拿到的是 A 的代理对象,不是原始对象。没有循环依赖时:A 正常走完创建流程,代理在最后一步 postProcessAfterInitialization(初始化后的后置处理)才创建,不会走提前暴露这条路径。

也就是说:"暴露"是一个登记钩子,发生在构造之后、属性填充之前;"代理"是懒创建,发生在别人真正消费 A 的那一刻。 谁先谁后取决于有没有循环依赖——循环场景下早暴露的就是代理,非循环场景下代理最后才造。

这个"懒创建 + 时机"的细节,正是大多数背答案的人说不出来的部分。三级缓存存的不是实例而是工厂,就是为了把代理决策延迟到最后一刻。

AOP 代理与提前暴露:谁先谁后的时序

【真相】对比分析:面试答案 vs 生产真相

归因缝合:事故只暴露 2.6 关闭这一条,顺着它排查出另外两条

那次事故的直接原因只有一条:2.6 默认关闭循环引用。复盘时的排查路径是这样的:先看报错第一行"Requested bean is currently in creation",确认是循环引用;再查 application.yml 里有没有 allow-circular-references——没有,说明走的是默认值;然后 git log 看两个互相注入的 Bean 有没有人改过——两年没动;最后才意识到,变的是 Spring Boot 的默认行为,不是我们的代码。顺着这条线往下想,我意识到标准答案的失效面比想象中宽——除了"2.6 默认关闭",还有两类场景三级缓存一样兜不住:构造器注入的循环、以及 @Async 这类独立后置处理器的代理 Bean 循环。事故只是把第一类摆到了台面上,另外两类就埋在同样的代码里。

标准答案对在哪:限定范围内的循环,三级缓存确实闭环

先讲标准答案对的部分。把条件限定清楚——单例 + setter/字段注入 + 非构造器 + 无代理:三级缓存确实能解决循环依赖。A 构造完登记懒工厂,B 需要 A 时从三级缓存拿到引用,B 完成后再回来把 A 填充完整,最终 A 和 B 都是完整单例,闭环成立。

而且 Spring 还做了防呆:标准 AOP 代理(如 @Transactional)参与循环时,提前暴露的早期引用和最终单例是同一个实例——早期引用经 getEarlyBeanReference 产出后,会被记进 earlyProxyReferences 这个集合,之后 Bean 初始化完再走到后置代理那一步时,看到"这 Bean 已经被提前代理过"就直接跳过,不再生成第二个代理。所以不会出现"B 拿到的 A 和最终 A 是不同对象"的错乱,这也是为什么 @Transactional 参与循环能正常闭环、而 @Async 会炸(后者不走这套提前代理机制)。

标准答案漏了哪:三类它兜不住的循环

面试答案 vs 生产真相 逐项对比

漏点 1:构造器注入的循环依赖。 构造方法执行时 Bean 还没登记进任何缓存,三级缓存机制没启动,参数解析时拿不到对方实例,必然抛 BeanCurrentlyInCreationException。面试说"三级缓存解决循环依赖"——但构造器循环它从根上就救不了。

漏点 2:@Async 代理 Bean 的循环。 @Transactional 这类走标准 AOP 的代理参与循环时反而没事——它的代理创建器(AspectJ AOP 自动代理创建器)实现了 getEarlyBeanReference,循环时提前暴露的就是带增强的代理,B 拿到它和最终单例是同一实例,正常闭环。真正会出事的是 @Async:它走独立的异步注解后置处理器(AsyncAnnotationBeanPostProcessor),不参与 getEarlyBeanReference 的提前暴露。循环开启时,@Async 的代理要等初始化之后才生成,而早暴露的引用早就被别的 Bean 拿走了——两个对象对不上,启动直接抛另一种文案的 BeanCurrentlyInCreationException。不是"异步悄悄失效",是它根本不给你启动。

漏点 3:Spring Boot 2.6 起默认关闭。 2.6.0 把 spring.main.allow-circular-references 默认值改为 false,启动即抛 BeanCurrentlyInCreationException。你可以在 application.yml 里显式改回 true,但这只是规避不是修复——循环依赖本身就是设计债,默认关闭是 Spring 在推动你尽早还债。

为什么面试不会触发、生产会:对比表

对比维度 面试环境 生产环境
Spring Boot 版本 教程模板常是 2.5 或更低 2.6+ 默认关闭循环引用
注入方式 随手 setter 注入,一对一 字段注入 + 构造器注入混用
Bean 类型 全是普通单例 混入 @Async/@Transactional 代理 Bean
依赖规模 三两个 Bean 上百 Bean,循环依赖隐蔽藏身
失败表现 教程演示"能解决" 启动即崩(setter 循环、@Async 循环皆如此)

面试现场 vs 生产现场 对照

面试里那个"三级缓存"救的循环,在生产里有三面墙挡着:版本默认值、注入方式、代理机制。三面墙撞上任意一面,标准答案就失效。

【升级】面试这么答 + 生产中这么用

面试总结话术

面试官再问循环依赖,你就能完整接住:

面试官:Spring 怎么解决循环依赖? 你:三级缓存——但先限定范围,它救的是单例 + setter/字段注入 + 非构造器。为什么三级不是两级?因为有 AOP,代理决定要等 Bean 创建完才知道,所以第三级用 ObjectFactory 把"给原始对象还是代理"延迟到被消费那一刻。它的边界是:构造器循环救不了(暴露机制在构造之后才启动)、@Async 这类不参与提前暴露的代理 Bean 参与循环时启动即崩、而且 Spring Boot 2.6 起默认关掉了整个机制——所以生产里最好的答案不是"怎么解决循环依赖",而是"怎么不制造循环依赖"。

这一问答完,面试官能立刻判断:这个人不只是会背三个 Map,是真的在生产里被默认值坑过。

三级缓存没错,错在 Spring Boot 2.6 把它默认关了,而你还在背老版本的标准答案。

生产中这么用:构造器注入优先 + @Lazy 兜底

生产最佳实践:构造器注入优先 + @Lazy 兜底

  • 首选:构造器注入。 构造器注入天然暴露循环依赖——一个 Bean 构造参数里出现了自己依赖链上的 Bean,编译和启动阶段就能发现,比 setter 注入"悄悄运行两年再崩"强太多。强制要求构造器注入,循环依赖会在设计阶段就被逼出来。
  • 次选:@Lazy 兜底。 历史代码已经 setter 互注、改构造器代价大时,在依赖的一方加 @Lazy,注入一个代理占位,真正用到时才去容器取——把循环依赖从"启动即崩"降级为"用到才解析"。启动后到控制台确认一下:RiskService 已成功注入,且 @LazySettlementService 只有在首次被调用时才真正触发容器获取。

升级 Spring Boot 前的 60 秒检查清单

每次升级 Spring Boot 大版本,花 60 秒过一遍这张表:

60 秒升级检查清单

检查点 2.5 及以前 2.6+ 处理
有没有 setter/字段互相注入 静默消化 启动即崩 改构造器注入 或 加 @Lazy
有没有 spring.main.allow-circular-references 配置 无感 需显式声明 加配置=true(短期)或重构(根治)
有没有 @Async Bean 参与循环 启动报错(早期引用与代理不一致) 同左 拆循环,别依赖代理兜底
有没有 @Transactional 参与循环 可闭环(AOP 代理走提前暴露) 可闭环 无需处理
有没有构造器循环依赖 无解 无解(永远如此) 重构,用 @Lazy 拆开

对照完,你就能在 60 秒里判断:这次升级的启动,到底会不会崩在循环依赖上。


循环依赖不是 Spring 的缺陷,是它替你把"设计债"记在账上——三级缓存是缓缴方案,2.6 默认关闭是催收通知。标准答案没有错,只是它在面试的版本下成立,在生产的版本下不一定成立。想亲手复现的,demo/ 目录里 bash run-demo.sh 用一个最小的 Spring 上下文把 2.5 静默消化 vs 2.6 启动即崩完整跑一遍——跑完你就再也不会说"循环依赖有三级缓存就够"。

下篇我们聊 Spring Boot 自动配置原理面试题:生产 Bean 覆盖排查——默认值即事故源,自动配置就是那个默认值。

🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操