循环依赖与三级缓存:Spring 如何解开死结

bee2026-10-0853 分钟0 次阅读
A 依赖 B、B 又依赖 A,Spring 为什么不慌张?三级缓存的查找顺序、提前引用的升级过程、以及「为什么必须是三级而不是两级」的经典追问,一次讲透。
1 / 133
小节
〇、30 秒看懂
2 / 133

先说人话。循环依赖就是两个类互相需要对方:A 的工作要靠 B,B 的工作又要靠 A。用生活场景说,就像两位新员工互相要求「他到岗了我才算入职」——按正常顺序排,谁都没法先报到。Spring 容器造对象时真会撞上这个死结,但它有一整套解环工艺;而这套工艺能不能救你,取决于你把依赖写成构造器注入还是字段注入。

3 / 133
类比

三级缓存像仓库的「取货单」。货(一个还没装配完的对象)明明还没到齐,但你已经可以先开一张单子贴墙上;下游来提货时,凭这张单子就能先把货登记走,等厂家把剩下的零件装完,再把单子换成实物。关键有三条:能先拿到单据(对象已实例化)、单据只兑一次(兑完就升级归档,不再重复产单)、单据上写明是正品还是样机(到底交原对象还是交 AOP 代理)。

4 / 133
类比

@Lazy 像借电钻。隔壁老王有台电钻,但你俩约好「你先凿墙我才开门」——门打不开,电钻永远借不到。正确做法是先给自己装一扇穿墙小门(代理占位):电钻本体还在老王家没动,你随时能通过小门用它,等到真要钻孔那天才去取实物。@Lazy 改的是「什么时候拿」,不是「到底依不依赖」。

5 / 133
架构图
图 · 本篇知识地图
图 · 本篇知识地图
6 / 133

学完这节,你要能回答三个问题:

7 / 133
  1. 同样一对 A↔B,为什么字段注入能启动、构造器注入当场报错?
  2. 一级、二级、三级缓存里各存的是什么?为什么要多存一个「工厂」而不是直接存对象?
  3. 线上碰到 BeanCurrentlyInCreationException,第一步该动代码、配置,还是设计?
8 / 133
小节
一、先看事故现场:构造器注入的 A↔B 启动即挂
9 / 133

先问"为什么":为什么 Spring 要费这么大力气设计三级缓存?因为循环依赖是真实工程中极其常见的一种结构——两个类互相需要对方,而面向接口的分层又让这种互相需要显得"很自然"。可自然归自然,启动时它却可能直接炸给你看。

10 / 133
架构图
图 1 · A 与 B 的死结
图 1 · A 与 B 的死结
11 / 133

先复现事故。写两个互相依赖的类,都用构造器注入:

12 / 133
java
package com.example.cycle;import org.springframework.stereotype.Component;@Componentpublic class A {    private final B b;    public A(B b) {        // 构造器注入:创建 A 就必须先有 B        this.b = b;    }}@Componentpublic class B {    private final A a;    public B(A a) {        // 构造器注入:创建 B 又必须先有 A        this.a = a;    }}
13 / 133

启动应用,日志毫不留情:

14 / 133
代码对照
代码text
Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException:    Error creating bean with name 'a': Requested bean is currently in creation:    Is there an unresolvable circular reference?    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:355)    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:225)    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:325)    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:519)    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)    ... 30 more
解读
  • BeanCurrentlyInCreationException:直译就是"你想要的 Bean 正在创建中"——容器发现要拿 A 的时候 A 还没造完
  • 关键不在异常名字,而在于构造器注入下,'"创建 A"这个动作一旦开始就无法暂停:B 必须作为参数传进来,而 B 又必须先把 A 传进来,两者都无法"半成品"交出
15 / 133

换成字段注入,同样的 A↔B 却安然无恙:

16 / 133
java
@Componentpublic class A {    @Autowired    private B b;   // 先 new 出空壳 A,稍后再把 b 塞进去——这就是"半成品可交付"}@Componentpublic class B {    @Autowired    private A a;}
17 / 133

差别只有一句话:字段注入允许"先把对象创建出来、再注入属性",于是容器可以在对象尚未完成时就把它"借"出去。三级缓存,就是为这次"提前出借"准备的中转仓。

18 / 133
架构图
图 · 三级缓存救得了字段注入,救不了构造器注入
图 · 三级缓存救得了字段注入,救不了构造器注入
19 / 133

把两种写法的命运并排放,一眼就能看出「能不能被救」的分界线在哪里。

20 / 133
类比

构造器注入像点外卖——骑手必须把整份餐(依赖)递到你手上,你才肯签收(对象出生);字段注入像楼下小吃摊——先拿个号(空壳对象已经站在那儿),餐慢慢再做。循环依赖时,"拿号"这条路能走通,"签收"这条路直接卡死。

21 / 133
小节
二、三级缓存到底是什么
22 / 133

DefaultSingletonBeanRegistry 里有三个关键字段,名字直译就能理解大半:

23 / 133
对照表
缓存级别字段名存的是什么何时写入 / 移除
一级singletonObjects完整可用的单例(Map<String, Object>)创建完成后 addSingleton 写入,永不移除直到销毁
二级earlySingletonObjects早期引用(已实例化、但未走完初始化)三级工厂被调用后升级写入;最终完成后移除
三级singletonFactoriesObjectFactory(产出早期引用的工厂,不是对象本身)实例化后立即写入;被调用后移除
24 / 133

先把两个术语按「完全没写过 Java」的口径钉死。实例化(instantiation)就是普通 Java 里的 new A()——内存分配好、构造器跑完,对象已经存在,只是字段全是 null;初始化(initialization)才是把依赖塞进去、回调跑完、对象真正可用的那一段。三级缓存的全部魔法,就发生在「已实例化、未初始化」这条缝里。

25 / 133
类比

一级 singletonObjects 是商场成品货架(装好了随便挑);二级 earlySingletonObjects 是已取号待装配的暂存台(拆箱了还没组装);三级 singletonFactories 是墙上那一排取货单(连货都没露面,只写着「怎么把这件货弄出来」)。顺序永远是先翻货架 → 再翻暂存台 → 最后才撕一张取货单,撕下来就归档到暂存台,单子同时作废。

26 / 133
要点

一二级存的都是"对象",唯独三级存的是"工厂"。这个看似别扭的设计,正是第五节要解释的核心——它把"是否要生成代理"这件事推迟到了真正需要时才决定。

27 / 133
小节
三、源码级时序:字段注入下 A↔B 如何走完
28 / 133
原理动画
动图 · 三级缓存查找与升级
动图 · 三级缓存查找与升级
29 / 133

同一个死结换个视角:六步动画,全程只盯三个 Map 此刻各存了什么。

30 / 133
原理动画
动图 · A→B→A 的死结怎么被解开
动图 · A→B→A 的死结怎么被解开
31 / 133

按时间顺序,字段注入的 A↔B 是这样解开的(用序号对应动图):

32 / 133
  1. 创建 A:反射实例化得到一个空壳 A,属性尚未填充
  2. 暴露 A 的早期引用:把"如何获取 A 早期引用"的工厂放进三级缓存
  3. A 注入 B:容器发现 A 需要 B,于是去 getBean("b")
  4. 创建 B:同样先实例化空壳 B,并把 B 的工厂放入三级缓存
  5. B 注入 A:容器发现 B 需要 A,回到 getBean("a")
  6. 三级缓存命中:A 已完成实例化但没走完初始化,于是从三级缓存拿到工厂、产出早期引用
  7. 升级与收尾:A 的早期引用升级到二级;B 拿到它完成自身创建并入一级;A 随后也完成并入一级
33 / 133

看 doCreateBean 的关键片段,注意"暴露工厂"发生在属性填充之前:

34 / 133
java
// AbstractAutowireCapableBeanFactory#doCreateBean(节选)// ① 实例化:拿到一个属性全空的原始对象BeanWrapper instanceWrapper = createBeanInstance(beanName, mbd, args);Object bean = instanceWrapper.getWrappedInstance();// ② 暴露早期引用:放入的是"工厂",不是对象本身if (earlySingletonExposure) {    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));}// ③ 属性填充:此时若依赖又绕回自己,就会命中上面这个工厂populateBean(beanName, mbd, instanceWrapper);// ④ 初始化:Aware、前后置处理、init 方法Object exposedObject = initializeBean(beanName, bean);
35 / 133

再看 getSingleton 的"三级查找"逻辑,这是解环的真正入口:

36 / 133
代码对照
代码java
// 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) {            ObjectFactory<?> factory = this.singletonFactories.get(beanName); // ③ 查三级            if (factory != null) {                singletonObject = factory.getObject();                   // 产出早期引用                this.earlySingletonObjects.put(beanName, singletonObject); // 升级到二级                this.singletonFactories.remove(beanName);                // 移除三级            }        }    }    return singletonObject;}
解读
  • 查找顺序永远是一级 → 二级 → 三级,且只有"当前正在创建中"才会继续往下查
  • 三级命中后会立刻升级到二级并移除三级,所以同一对象的工厂只会被调用一次
  • 二级缓存存在的意义,是为了让"同一个早期引用"被多次请求时无需反复调用工厂——这引出了下一节的追问

提示:isSingletonCurrentlyInCreation 判断的是 singletonsCurrentlyInCreation 这个 Set。它就是第四步那个异常 BeanCurrentlyInCreationException 的检查依据——同一个 Bean 二次进入创建流程,就会在这里被拦下。

37 / 133

动图给的是全景,但「三个 Map 此刻分别有什么」这件事,只有一步一停才看得清。下面这台单步调试台左边是字段注入 A↔B 的真实调用序列,右边每一格都刷新三张表的当前内容——连点「下一步」,盯住第 ⑦ 格那张「取货单」是怎么在 0.1 毫秒里变成二级缓存里的实物的:

38 / 133
单步调试台
单步台字段注入的 A↔B:三级缓存逐格回放1 / 9
点满九步,第 ⑦ 格是全场唯一一次「撕单兑货」
被调试的代码
1getBean("a"); // 第 11 步轮到 A
2createBeanInstance("a"); // ① 反射 new,字段全空
3addSingletonFactory("a", () -> getEarlyBeanReference(a)); // ② 挂取货单(存工厂)
4populateBean("a"); // ③ 装配:发现要 B
5getBean("b"); // ④ 递归去造 B
6addSingletonFactory("b", () -> getEarlyBeanReference(b)); // ⑤ B 同样先挂单
7populateBean("b"); // ⑥ 装配:发现要 A
8getSingleton("a", true); // ⑦ 一级→二级→三级,撕单兑货
9addSingleton("b", b); addSingleton("a", a); // ⑧ 两个相继入一级
此刻的变量
singletonObjects{}
earlySingletonObjects{}
singletonFactories{}
singletonsCurrentlyInCreation{}
调用栈
1preInstantiateSingletons
2doGetBean
1起点:三张表全空。容器按 beanDefinitionNames 遍历,第一个轮到 a。注意「谁先开始」不影响结果——先进入的那个一定会撞上另一个。
39 / 133
小节
四、为什么必须是三级而不是两级(重头戏)
40 / 133

这是循环依赖里最经典、也最容易被问倒的一题。

41 / 133

先说结论:二级缓存其实足以完成"解环"这件事(存早期对象即可);三级缓存存在的唯一理由是兼容 AOP 代理。

42 / 133

关键在 getEarlyBeanReference:

43 / 133
代码对照
代码java
// AbstractAutowireCapableBeanFactory#getEarlyBeanReference(节选)protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {    Object exposedObject = bean;    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {        for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {            exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);        }    }    return exposedObject;}
解读
  • getEarlyBeanReference 里会调用 AbstractAutoProxyCreator 的对应实现:如果这个 Bean 需要被 AOP 代理,就在这里提前把代理生成出来
  • 换句话说,三级缓存存的不是"早期对象",而是"一个能按需决定产出原始对象还是代理对象的工厂"
44 / 133

为什么不能只用两级?假设二级缓存直接存"早期对象本身",会掉进两个陷阱:

45 / 133
对照表
若二级直接存早期对象后果
循环依赖发生时早期引用被取走,但此时还不知道它最终要不要被代理;等到初始化后置阶段才发现要代理,已经晚了一步,注入方拿到的是原对象,代理没生效
强行在暴露时就生成代理无论是否发生循环依赖,每个 Bean 都被迫提前生成代理,破坏"AOP 代理在初始化后置统一创建"的模型,可能生成两个代理实例
46 / 133
  • 三级缓存把"是否生成代理"这个决定推迟到真正被循环依赖需要的那一刻:没人循环引用它,就一路走正常流程,代理在"初始化后置"生成;有人循环引用,才提前经工厂产出(且只会产出一次)
  • 这样无论是否发生循环依赖,代理都有且只有一个——这正是三级缓存不可省的原因
47 / 133
说明

所以标准答案是"二级能解环,三级是为了在解环的同时保证代理的唯一性"。只答"三级缓存是为了解决循环依赖"是会被扣分的。

48 / 133

这张「推迟决定」的机器是怎么转的?点一遍就清楚了——注意第 ④ 格才是三级存在的全部理由:

49 / 133
交互图解
流程一张取货单的一生:代理决定是怎么推迟的1 / 5
从 ① 点到 ⑤;没人来兑单的话,第 ④ 格根本不会发生
→
→
→
→
① 实例化完成就挂单
addSingletonFactory 把「怎么把早期引用弄出来」写成工厂挂上墙。此刻对象字段还全空,但已经有个可用的地址了——这是能不能被救的第一道门槛。
全部看懂了两级足以解环;第三级买的是「代理只生成一次」这条保证。
50 / 133

顺带把第 ④ 格那位「决定要不要包代理」的judge单独看一眼。它的正式名字是 AutoProxyCreator(自动代理创建器——一种专门负责判断「这个 Bean 该不该被包成 AOP 代理」的 BeanPostProcessor),下面这个实验切到「候选判定」,你会看到它是怎么按类型匹配通知的:

51 / 133
内核实验
TeaVM提前产出的到底是原对象还是代理未启动
再看「代理工厂配置」理解代理是怎么临时攒出来的;这一格决定了第 ⑤ 格要不要报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
52 / 133
小节
五、为什么构造器注入无解,以及 @Lazy 如何破环
53 / 133

构造器注入为什么无解? 回到第一节:构造器注入要求依赖对象在实例化之前就存在,而循环依赖的本质是"A 需要 B、B 需要 A",两者都无法先于对方完成实例化。容器此时连早期引用都没来得及暴露——addSingletonFactory 是在 createBeanInstance 之后才调用的,构造器却发生在这之前。没有对象,就没有可借出的引用,死结无解。

54 / 133

那为什么同样是"依赖",@Lazy 就能破环?

55 / 133
代码对照
代码java
package com.example.cycle;import org.springframework.context.annotation.Lazy;import org.springframework.stereotype.Component;@Componentpublic class A {    private final B b;    public A(@Lazy B b) {   // 注入的不是 B 本体,而是 B 的代理        this.b = b;    }}
解读
  • @Lazy 让容器注入一个代理对象,而不是真正的 B。创建 A 时拿到的是一个"占位代理",无需立刻创建 B,环被打破
  • 直到 A 第一次真正调用 b.xxx() 时,代理才会去容器取(并创建)真正的 B
  • 代价是:如果 B 真有问题,报错会被推迟到运行期首次调用时,而不是启动时——排查难度上升

注意:@Lazy 只是"绕过"了循环依赖,并没有"解决"它。它把问题从启动期挪到了运行期,设计上的双向耦合依然存在。

56 / 133

「借电钻」那个类比的关键在于小门:电钻本体没动,你随时能用,但真要钻穿墙那天才去取。动画把这条时间轴放一遍,注意第 ③ 格(环在这里被剪断)与第 ⑥ 格(报错被推迟到这里):

57 / 133
原理动画
动图 · @Lazy 破环:借的是小门,不是电钻
动图 · @Lazy 破环:借的是小门,不是电钻
58 / 133
小节
六、Spring Boot 2.6+ 默认禁止循环依赖
59 / 133

从 Spring Boot 2.6 起,allow-circular-references 默认为 false,也就是说——上面那套三级缓存工艺依然在 Spring Framework 里,但 Boot 默认不让它生效:

60 / 133
代码对照
代码yaml
# application.yml:显式放行循环依赖(不推荐长期使用)spring:  main:    allow-circular-references: true
解读
  • 关闭时,即使是字段注入的 A↔B 也会启动失败,并给出明确提示"考虑重构或使用 @Lazy"
  • 社区取舍:循环依赖几乎总意味着职责划分有问题,Boot 选择用"默认拒绝"逼迫开发者正面处理,而不是靠缓存悄悄兜底
  • 如果确实有历史包袱,再用上面这行配置临时放行——但请把它当成技术债记下来
61 / 133
小节
七、设计层面解环三招
62 / 133

与其依赖缓存兜底,不如从设计上消灭环。三个方案各自到底改变了什么,才是选型的依据——这局配完,比背下表更管用:

63 / 133
配对闯关
闯关解环手段配它真正改变的东西已配对 0/6 · 配错 0
左边是你能写的代码,右边是这件事在容器里造成的差异
先点左边一个
64 / 133
要点

优先顺序永远是"抽公共依赖 > 事件驱动 > @Lazy"。前两者改变的是结构,@Lazy 改变的只是时机。面试时能说出这个优先顺序,通常比背出三级缓存更能加分。

65 / 133
小节
八、坑:三级缓存的三个边界
66 / 133
坑

三级缓存只对"单例 + 字段/setter 注入"生效。prototype 作用域的 Bean 会立即失败,因为它根本不进 singletonObjects,也就没有"提前借出"的仓库;构造器注入同样失败(见第五节)。只有单例、且依赖是在实例化之后注入的,才能被三级缓存救回来。

67 / 133

这条边界值得亲手验证一次——下面这个实验把四种作用域的实例计数摆出来,切到「数一数有几个」你会看到 prototype 每取一次就加一,而它从不进池;没有池,就没有取货单:

68 / 133
内核实验
TeaVM为什么 prototype 一定解不了环未启动
先看 singleton 与 prototype 的计数差别,再切「谁来销毁」理解第 9 篇那条「容器拿完就撒手」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
69 / 133
坑

别把 @Async 的代理拿到循环依赖里做文章。@Async 等方法级代理同样依赖"初始化后置"生成代理;若在循环依赖中经三级缓存提前产出,可能得到一个尚未完全增强的代理,表现为"有时异步不生效"。

70 / 133
警告

不要用三级缓存"治"循环依赖,要用它"理解"循环依赖。把 allow-circular-references 永久打开、把 @Lazy 到处撒,等于把设计缺陷藏进缓存里。真正健康的关系应该是单向依赖。

71 / 133
小节
九、上手体验:三级缓存解环全过程
72 / 133

下面这个演示把三级缓存的每一步内容都摆出来。先切到「零缓存」,看循环依赖如何当场失败;再切回「开启三级缓存」,逐步对比每一步缓存里究竟存了什么:

73 / 133
内核实验
TeaVM三级缓存解环全过程未启动
先看「零缓存」如何失败,再切回「开启三级缓存」对比每一步的缓存内容
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
74 / 133
小节
十、遇到循环依赖,第一步该做什么?
75 / 133
决策
决策接手一个老项目,启动时报 `BeanCurrentlyInCreationException`,你确认是 A↔B 循环依赖。为了最快恢复可运行,第一步最合理的动作是什么?
76 / 133
小节
十一、上手实验:把解环过程摊在眼前
77 / 133

光看图不够,下面四个内核实验请按顺序跑完,每个只要 30 秒。第一个直接推翻「Spring 能解所有环」这个错觉。

78 / 133
内核实验
TeaVM循环依赖 · 三级缓存开与关未启动
切到「关闭三级缓存」,看 A↔B 如何在第 5 步当场失败
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
79 / 133

同一个 A↔B,换成四种注入写法,容器走的时序完全不同。先跑 constructor,再跑 field,最后跑 cycle,你会亲眼看到「半成品能不能交出去」这条分界线:

80 / 133
内核实验
TeaVM三种注入的装配时序未启动
依次切换 constructor / setter / field,再看 cycle 如何撞环
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
81 / 133

前面反复强调「早期引用是在实例化之后、属性填充之前暴露的」。把它放到完整的八阶段时间线上验证一次——注意工厂入池的位置正好卡在「实例化」和「属性填充」之间:

82 / 133
内核实验
TeaVMBean 生命周期 · 定位暴露工厂的那一步未启动
沿时间线找 addSingletonFactory 落在哪两个阶段之间;再切 prototype 看它根本不进池
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
83 / 133

最后一层往下挖:容器凭什么知道「A 这个类要造出来」?答案是扫描注解后先生成一份配方清单(BeanDefinition——描述一个 Bean 该怎么造的元数据对象,而不是 Bean 本身)。看清这一层,你就明白循环依赖是「装配阶段」的问题,跟扫描、注册无关:

84 / 133
内核实验
TeaVM从 @Component 到 BeanDefinition未启动
先看 scan 与 registry,再用 attrs 观察 scope / lazy 属性怎么改写创建时机
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
85 / 133
小节
11.1 换成命令行:把环「查」出来
86 / 133

实验按完一轮,就该自己动手问容器了。这台控制台连着浏览器里的真容器,回显全部由内核当场算——下面这一串的顺序,就是一次线上排障的顺序:

87 / 133
内核控制台
88 / 133
提示

lab circular cache 与 lab circular nocache 要连着敲。同一个 A↔B,前者的失败点在第七格之前就被取货单救走,后者连单子都没得撕——这一对比比任何一段源码讲解都直观,也正好是上面那台单步调试台的两端。

89 / 133
小节
十二、沙盘:先判命运,再谈解法
90 / 133

循环依赖的结局由三个开关共同决定。拖动下面三个参数,右侧会立刻给出启动结果——先把结论玩熟,再去背源码:

91 / 133
沙盘
沙盘这个环到底能不能被解开
运行结果
startup: OK
# 一级 singletonObjects : {a=A, b=B}
# 二级 earlySingletonObjects: 空(完成后清空)
# 三级 singletonFactories : 空(工厂已被兑掉)
结论:单例 + 字段注入 = 三级缓存的主场
正常启动:A 的取货单救了 B,两边随后入一级
92 / 133

读这张沙盘的正确姿势:先看 scope,再看注入方式,最后才看开关。prototype 四行全部判死;singleton 里只有 constructor 判死。真正能被三级缓存救回的,永远只有「单例 + 字段/setter 注入」那几格——其余配置怎么拨都只是在改变报错出现的时机。

93 / 133
小节
十三、常见报错速查
94 / 133

新手最怕的是「日志太长看不懂」。下面这些片段都能原样复制去搜索,请先学会读它们。

95 / 133

Boot 给出的循环依赖报告长这样,缩进的树就是环的形状:

96 / 133
text
The dependencies of some of the beans in the application context form a cycle:   userService defined in file [UserServiceImpl.class]┌─────┐|  orderService defined in file [OrderServiceImpl.class]↑     ↓|  userService (above)└─────┘Action:Relying upon circular references is discouraged and they are prohibited by default.Update your application to remove the dependency cycle between beans. As a lastresort, it may be possible to break the cycle via a constructor argument beingmarked @Lazy. Alternatively, consider changing some injections to be setter orfield based, or use ObjectProvider<T> as a dependency.
97 / 133

读法只有三条:① ┌─────┐ … ↑ ↓ … └─────┘ 框住的几行才真在环上,框外只是入口;② 链条从哪个 Bean 开始打印,不代表它是罪魁,它只是最先被创建的那个;③ Action: 段是官方给的自救清单,@Lazy 被明写成 "last resort"。

98 / 133
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?同一个 Bean 二次进入创建流程,被 singletonsCurrentlyInCreation 拦下看栈里是否出现 UnsatisfiedDependencyException: ... constructor argument —— 是则构造器注入,三级缓存帮不上;改字段/setter 或加 @Lazy本篇第一、五节
The dependencies of some of the beans in the application context form a cycleBoot 2.6+ 默认禁止循环依赖,启动前置校验直接拦截读缩进树找出环上的 Bean 名,优先抽公共依赖;临时用 @Lazy,别急着开全局开关本篇第六、七节
Error creating bean with name 'a': Bean with name 'a' has been injected into other beans [b] in its raw version as part of a circular reference, but has eventually been wrapped早期借出的是原对象,初始化后置却又生成了代理,两边不是同一个实例说明这个环上有人需要 AOP 代理;给注入点加 @Lazy 打断提前借出,或检查自定义 SmartInstantiationAwareBeanPostProcessor本篇第四节 + 第 9 篇第四节
Scope 'prototype' is not active for the current thread / 原型互相注入时反复抛 BeanCurrentlyInCreationException原型 Bean 从不写入 singletonObjects,没有可提前借出的仓库改成 singleton;确实要多实例就注入 ObjectProvider<T> 自己 getObject()第 9 篇第五节
UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through constructor parameter 0构造器参数找不到候选 Bean(常与环同时出现)先看是不是同类里的 this.xxx() 自调用,再看是否忘加 @Component/包不在扫描路径第 6、7 篇
启动明明配了 spring.main.allow-circular-references=true,构造器环照样炸该开关只放行「能被三级缓存解开的环」保留构造器注入就别指望开关;用 @Lazy 或重构成单向依赖本篇第六节
BeanDefinitionOverrideException: Invalid bean definition with name 'a'同名 Bean 被注册两次(重复 @ComponentScan 或 XML+注解混用)这不是循环依赖,别乱开缓存开关;统一命名或去掉重复扫描第 7 篇
99 / 133
提示

搜报错时只搜冒号后第一段原文(例如 has been injected into other beans),命中率远高于搜整句——不同 Spring 版本会在后半句加话。

100 / 133

上面那张表的第一行就是本篇的主角异常。下面这段是它的完整现场——先别看结论,点出你认为的凶手帧:

101 / 133
报错急救
报错急救BeanCurrentlyInCreationException

一个类加了第 3 个构造器依赖,本地第一次启动就崩。日志里三行了不得的话反复出现:currently in creation、form a cycle、Action。

APPLICATION FAILED TO START
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'a' defined in file [A.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'b': Requested bean is currently in creation: Is there an unresolvable circular reference?
at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:689)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:355)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:225)
at com.example.cycle.B.<init>(B.java:14)
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| a defined in file [A.class]
↑ ↓
| b defined in file [B.class]
└─────┘
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
102 / 133
小节
十四、随堂自测
103 / 133
随堂自测
随堂自测字段注入的 A↔B 单例环能启动成功,最关键的前提是什么?
先自己选一个,选中立刻告诉你对不对
104 / 133
随堂自测
随堂自测为什么三级缓存非要存 ObjectFactory,而不能像二级那样直接存早期对象?
先自己选一个,选中立刻告诉你对不对
105 / 133
小节
十五、动手练习
106 / 133
小节
第一档 · 照做
107 / 133

建两个互相依赖的类,都用字段注入,跑通后再逐步改成构造器注入,把两段日志抄进笔记。完整可跑代码:

108 / 133
java
package com.example.cycle;import org.springframework.stereotype.Component;@Componentpublic class MailService {    @org.springframework.beans.factory.annotation.Autowired    private SmsService sms;          // 字段注入:可以先出生再装配    public String send(String to) {        return "mail->" + to + " | via " + sms.getClass().getSimpleName();    }}@Componentpublic class SmsService {    @org.springframework.beans.factory.annotation.Autowired    private MailService mail;        // 反向依赖,构成 Mail ↔ Sms 环    public String send(String to) {        return "sms->" + to;    }}
109 / 133
java
package com.example.cycle;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class CycleDemo {    public static void main(String[] args) {        try (ConfigurableApplicationContext ctx = SpringApplication.run(CycleDemo.class, args)) {            MailService mail = ctx.getBean(MailService.class);            SmsService sms = ctx.getBean(SmsService.class);            System.out.println("[result] " + mail.send("bob"));            System.out.println("[result] " + sms.send("bob") + " | back-ref="                    + (mail != null && sms != null));        }    }}
110 / 133

预期输出(省略启动横幅,只看关键两行):

111 / 133
text
[result] mail->bob | via SmsService[result] sms->bob | back-ref=true
112 / 133

再加一行配置,把 Boot 2.6+ 的默认拒绝显式打开,对比前后差异:

113 / 133
yaml
# src/main/resources/application.ymlspring:  main:    allow-circular-references: true
114 / 133

然后把 MailService 改成构造器注入:

115 / 133
java
@Componentpublic class MailService {    private final SmsService sms;    public MailService(SmsService sms) { this.sms = sms; }   // 只改这一处}
116 / 133

再次启动,控制台必然出现这段(这就是本节要你抄进笔记的原文):

117 / 133
代码对照
代码text
***************************APPLICATION FAILED TO START***************************Description:The dependencies of some of the beans in the application context form a cycle:   mailService defined in file [com/example/cycle/MailService.class]┌─────┐|  smsService defined in file [com/example/cycle/SmsService.class]↑     ↓|  mailService (above)└─────┘
解读

说明:重点看两行输出——via SmsService 能打印出来,说明 A 拿到了 B;back-ref=true 说明 B 那边也真握着 A。环被解开的标志不是「日志变少」,而是两个方向都能调用通。

118 / 133
小节
第二档 · 变体
119 / 133

目标:不删掉环,只加一个注解让它起来。

120 / 133

提示:在 MailService 的构造参数前写 @Lazy(public MailService(@Lazy SmsService sms)),其余代码一字不改。

121 / 133

你会观察到三件事:① 启动成功,form a cycle 不再出现;② 打印 sms.getClass().getName(),得到的是 ...$$SpringCGLIB$$0 之类的代理类名而不是 SmsService;③ 故意往 SmsService 的构造器里塞一行 throw new IllegalStateException("boom"),你会发现启动不再报错,异常推迟到第一次真正调用 sms.send(...) 时才炸——这就是 @Lazy 把问题从启动期挪到运行期的代价。

122 / 133
小节
第三档 · 造一个
123 / 133

做一个「双写通知器」小项目:UserService 与 AuditService 互相依赖(用户变更要写审计,审计又要回查用户),要求最终既没有环也不使用 @Lazy。

124 / 133

验收清单:

125 / 133
  • [ ] 画得出改造前后的依赖箭头图,改造后所有箭头同向
  • [ ] 三种方案至少各试一次并写下取舍:抽公共依赖 ChangeLog、ApplicationEventPublisher 事件驱动、ObjectProvider<AuditService> 延迟取
  • [ ] application.yml 里没有 allow-circular-references=true
  • [ ] 启动日志中 BeanCurrentlyInCreationException 与 form a cycle 各 0 次
  • [ ] 说清一件事:事件驱动为什么能把双向调用变成单向通知(发布者不认识监听者)
126 / 133
小节
十六、要点自查
127 / 133
自检

不看资料,说出三级缓存各自的字段名与存储内容——一级成品、二级早期引用、三级工厂。缺一即回第二节。

128 / 133
自检

能说清「二级就能解环,为什么还要三级」吗?关键词必须是「代理的唯一性」与 getEarlyBeanReference。

129 / 133
自检

构造器注入的环,除了重构还有哪些手段?它们的区别是「改变时机」还是「改变结构」?

130 / 133
自检

prototype 作用域为什么一定失败?答案应落在「根本不进 singletonObjects,没有仓库可提前借出」。

131 / 133
自检

Boot 报 form a cycle 时,你能从缩进树里认出环上的 Bean 名吗?

132 / 133
口诀

先出生、再装配,取货单兑一次;构造器环不认缓存,@Lazy 只搬时间。

133 / 133
总结

循环依赖不是洪水猛兽,而是一道可被推理的题。记住三条——能解的前提是"单例 + 字段/setter 注入"(对象能先出生再注入),构造器注入与 prototype 无解;三级的分工是一级存成品、二级存早期引用、三级存产出引用的工厂;三级的意义在于把"是否生成代理"推迟到真正被需要时,从而保证代理有且只有一个。而工程上最好的答案,始终是让依赖保持单向——缓存能兜底,但兜不住糟糕的设计。