循环依赖与三级缓存:Spring 如何解开死结
先说人话。循环依赖就是两个类互相需要对方:A 的工作要靠 B,B 的工作又要靠 A。用生活场景说,就像两位新员工互相要求「他到岗了我才算入职」——按正常顺序排,谁都没法先报到。Spring 容器造对象时真会撞上这个死结,但它有一整套解环工艺;而这套工艺能不能救你,取决于你把依赖写成构造器注入还是字段注入。
三级缓存像仓库的「取货单」。货(一个还没装配完的对象)明明还没到齐,但你已经可以先开一张单子贴墙上;下游来提货时,凭这张单子就能先把货登记走,等厂家把剩下的零件装完,再把单子换成实物。关键有三条:能先拿到单据(对象已实例化)、单据只兑一次(兑完就升级归档,不再重复产单)、单据上写明是正品还是样机(到底交原对象还是交 AOP 代理)。
@Lazy 像借电钻。隔壁老王有台电钻,但你俩约好「你先凿墙我才开门」——门打不开,电钻永远借不到。正确做法是先给自己装一扇穿墙小门(代理占位):电钻本体还在老王家没动,你随时能通过小门用它,等到真要钻孔那天才去取实物。@Lazy 改的是「什么时候拿」,不是「到底依不依赖」。

学完这节,你要能回答三个问题:
- 同样一对 A↔B,为什么字段注入能启动、构造器注入当场报错?
- 一级、二级、三级缓存里各存的是什么?为什么要多存一个「工厂」而不是直接存对象?
- 线上碰到
BeanCurrentlyInCreationException,第一步该动代码、配置,还是设计?
先问"为什么":为什么 Spring 要费这么大力气设计三级缓存?因为循环依赖是真实工程中极其常见的一种结构——两个类互相需要对方,而面向接口的分层又让这种互相需要显得"很自然"。可自然归自然,启动时它却可能直接炸给你看。

先复现事故。写两个互相依赖的类,都用构造器注入:
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; }}启动应用,日志毫不留情:
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 moreBeanCurrentlyInCreationException:直译就是"你想要的 Bean 正在创建中"——容器发现要拿 A 的时候 A 还没造完- 关键不在异常名字,而在于构造器注入下,'"创建 A"这个动作一旦开始就无法暂停:B 必须作为参数传进来,而 B 又必须先把 A 传进来,两者都无法"半成品"交出
换成字段注入,同样的 A↔B 却安然无恙:
@Componentpublic class A { @Autowired private B b; // 先 new 出空壳 A,稍后再把 b 塞进去——这就是"半成品可交付"}@Componentpublic class B { @Autowired private A a;}差别只有一句话:字段注入允许"先把对象创建出来、再注入属性",于是容器可以在对象尚未完成时就把它"借"出去。三级缓存,就是为这次"提前出借"准备的中转仓。

把两种写法的命运并排放,一眼就能看出「能不能被救」的分界线在哪里。
构造器注入像点外卖——骑手必须把整份餐(依赖)递到你手上,你才肯签收(对象出生);字段注入像楼下小吃摊——先拿个号(空壳对象已经站在那儿),餐慢慢再做。循环依赖时,"拿号"这条路能走通,"签收"这条路直接卡死。
DefaultSingletonBeanRegistry 里有三个关键字段,名字直译就能理解大半:
| 缓存级别 | 字段名 | 存的是什么 | 何时写入 / 移除 |
|---|---|---|---|
| 一级 | singletonObjects | 完整可用的单例(Map<String, Object>) | 创建完成后 addSingleton 写入,永不移除直到销毁 |
| 二级 | earlySingletonObjects | 早期引用(已实例化、但未走完初始化) | 三级工厂被调用后升级写入;最终完成后移除 |
| 三级 | singletonFactories | ObjectFactory(产出早期引用的工厂,不是对象本身) | 实例化后立即写入;被调用后移除 |
先把两个术语按「完全没写过 Java」的口径钉死。实例化(instantiation)就是普通 Java 里的 new A()——内存分配好、构造器跑完,对象已经存在,只是字段全是 null;初始化(initialization)才是把依赖塞进去、回调跑完、对象真正可用的那一段。三级缓存的全部魔法,就发生在「已实例化、未初始化」这条缝里。
一级 singletonObjects 是商场成品货架(装好了随便挑);二级 earlySingletonObjects 是已取号待装配的暂存台(拆箱了还没组装);三级 singletonFactories 是墙上那一排取货单(连货都没露面,只写着「怎么把这件货弄出来」)。顺序永远是先翻货架 → 再翻暂存台 → 最后才撕一张取货单,撕下来就归档到暂存台,单子同时作废。
一二级存的都是"对象",唯独三级存的是"工厂"。这个看似别扭的设计,正是第五节要解释的核心——它把"是否要生成代理"这件事推迟到了真正需要时才决定。

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

按时间顺序,字段注入的 A↔B 是这样解开的(用序号对应动图):
- 创建 A:反射实例化得到一个空壳 A,属性尚未填充
- 暴露 A 的早期引用:把"如何获取 A 早期引用"的工厂放进三级缓存
- A 注入 B:容器发现 A 需要 B,于是去
getBean("b") - 创建 B:同样先实例化空壳 B,并把 B 的工厂放入三级缓存
- B 注入 A:容器发现 B 需要 A,回到
getBean("a") - 三级缓存命中:A 已完成实例化但没走完初始化,于是从三级缓存拿到工厂、产出早期引用
- 升级与收尾:A 的早期引用升级到二级;B 拿到它完成自身创建并入一级;A 随后也完成并入一级
看 doCreateBean 的关键片段,注意"暴露工厂"发生在属性填充之前:
// 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);再看 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) { 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 二次进入创建流程,就会在这里被拦下。
动图给的是全景,但「三个 Map 此刻分别有什么」这件事,只有一步一停才看得清。下面这台单步调试台左边是字段注入 A↔B 的真实调用序列,右边每一格都刷新三张表的当前内容——连点「下一步」,盯住第 ⑦ 格那张「取货单」是怎么在 0.1 毫秒里变成二级缓存里的实物的:
getBean("a"); // 第 11 步轮到 AcreateBeanInstance("a"); // ① 反射 new,字段全空addSingletonFactory("a", () -> getEarlyBeanReference(a)); // ② 挂取货单(存工厂)populateBean("a"); // ③ 装配:发现要 BgetBean("b"); // ④ 递归去造 BaddSingletonFactory("b", () -> getEarlyBeanReference(b)); // ⑤ B 同样先挂单populateBean("b"); // ⑥ 装配:发现要 AgetSingleton("a", true); // ⑦ 一级→二级→三级,撕单兑货addSingleton("b", b); addSingleton("a", a); // ⑧ 两个相继入一级| singletonObjects | {} |
| earlySingletonObjects | {} |
| singletonFactories | {} |
| singletonsCurrentlyInCreation | {} |
preInstantiateSingletonsdoGetBean这是循环依赖里最经典、也最容易被问倒的一题。
先说结论:二级缓存其实足以完成"解环"这件事(存早期对象即可);三级缓存存在的唯一理由是兼容 AOP 代理。
关键在 getEarlyBeanReference:
// 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 代理,就在这里提前把代理生成出来- 换句话说,三级缓存存的不是"早期对象",而是"一个能按需决定产出原始对象还是代理对象的工厂"
为什么不能只用两级?假设二级缓存直接存"早期对象本身",会掉进两个陷阱:
| 若二级直接存早期对象 | 后果 |
|---|---|
| 循环依赖发生时 | 早期引用被取走,但此时还不知道它最终要不要被代理;等到初始化后置阶段才发现要代理,已经晚了一步,注入方拿到的是原对象,代理没生效 |
| 强行在暴露时就生成代理 | 无论是否发生循环依赖,每个 Bean 都被迫提前生成代理,破坏"AOP 代理在初始化后置统一创建"的模型,可能生成两个代理实例 |
- 三级缓存把"是否生成代理"这个决定推迟到真正被循环依赖需要的那一刻:没人循环引用它,就一路走正常流程,代理在"初始化后置"生成;有人循环引用,才提前经工厂产出(且只会产出一次)
- 这样无论是否发生循环依赖,代理都有且只有一个——这正是三级缓存不可省的原因
所以标准答案是"二级能解环,三级是为了在解环的同时保证代理的唯一性"。只答"三级缓存是为了解决循环依赖"是会被扣分的。
这张「推迟决定」的机器是怎么转的?点一遍就清楚了——注意第 ④ 格才是三级存在的全部理由:
顺带把第 ④ 格那位「决定要不要包代理」的judge单独看一眼。它的正式名字是 AutoProxyCreator(自动代理创建器——一种专门负责判断「这个 Bean 该不该被包成 AOP 代理」的 BeanPostProcessor),下面这个实验切到「候选判定」,你会看到它是怎么按类型匹配通知的:
构造器注入为什么无解? 回到第一节:构造器注入要求依赖对象在实例化之前就存在,而循环依赖的本质是"A 需要 B、B 需要 A",两者都无法先于对方完成实例化。容器此时连早期引用都没来得及暴露——addSingletonFactory 是在 createBeanInstance 之后才调用的,构造器却发生在这之前。没有对象,就没有可借出的引用,死结无解。
那为什么同样是"依赖",@Lazy 就能破环?
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 只是"绕过"了循环依赖,并没有"解决"它。它把问题从启动期挪到了运行期,设计上的双向耦合依然存在。
「借电钻」那个类比的关键在于小门:电钻本体没动,你随时能用,但真要钻穿墙那天才去取。动画把这条时间轴放一遍,注意第 ③ 格(环在这里被剪断)与第 ⑥ 格(报错被推迟到这里):

从 Spring Boot 2.6 起,allow-circular-references 默认为 false,也就是说——上面那套三级缓存工艺依然在 Spring Framework 里,但 Boot 默认不让它生效:
# application.yml:显式放行循环依赖(不推荐长期使用)spring: main: allow-circular-references: true- 关闭时,即使是字段注入的 A↔B 也会启动失败,并给出明确提示"考虑重构或使用
@Lazy" - 社区取舍:循环依赖几乎总意味着职责划分有问题,Boot 选择用"默认拒绝"逼迫开发者正面处理,而不是靠缓存悄悄兜底
- 如果确实有历史包袱,再用上面这行配置临时放行——但请把它当成技术债记下来
与其依赖缓存兜底,不如从设计上消灭环。三个方案各自到底改变了什么,才是选型的依据——这局配完,比背下表更管用:
优先顺序永远是"抽公共依赖 > 事件驱动 > @Lazy"。前两者改变的是结构,@Lazy 改变的只是时机。面试时能说出这个优先顺序,通常比背出三级缓存更能加分。
三级缓存只对"单例 + 字段/setter 注入"生效。prototype 作用域的 Bean 会立即失败,因为它根本不进 singletonObjects,也就没有"提前借出"的仓库;构造器注入同样失败(见第五节)。只有单例、且依赖是在实例化之后注入的,才能被三级缓存救回来。
这条边界值得亲手验证一次——下面这个实验把四种作用域的实例计数摆出来,切到「数一数有几个」你会看到 prototype 每取一次就加一,而它从不进池;没有池,就没有取货单:
别把 @Async 的代理拿到循环依赖里做文章。@Async 等方法级代理同样依赖"初始化后置"生成代理;若在循环依赖中经三级缓存提前产出,可能得到一个尚未完全增强的代理,表现为"有时异步不生效"。
不要用三级缓存"治"循环依赖,要用它"理解"循环依赖。把 allow-circular-references 永久打开、把 @Lazy 到处撒,等于把设计缺陷藏进缓存里。真正健康的关系应该是单向依赖。
下面这个演示把三级缓存的每一步内容都摆出来。先切到「零缓存」,看循环依赖如何当场失败;再切回「开启三级缓存」,逐步对比每一步缓存里究竟存了什么:
光看图不够,下面四个内核实验请按顺序跑完,每个只要 30 秒。第一个直接推翻「Spring 能解所有环」这个错觉。
同一个 A↔B,换成四种注入写法,容器走的时序完全不同。先跑 constructor,再跑 field,最后跑 cycle,你会亲眼看到「半成品能不能交出去」这条分界线:
前面反复强调「早期引用是在实例化之后、属性填充之前暴露的」。把它放到完整的八阶段时间线上验证一次——注意工厂入池的位置正好卡在「实例化」和「属性填充」之间:
最后一层往下挖:容器凭什么知道「A 这个类要造出来」?答案是扫描注解后先生成一份配方清单(BeanDefinition——描述一个 Bean 该怎么造的元数据对象,而不是 Bean 本身)。看清这一层,你就明白循环依赖是「装配阶段」的问题,跟扫描、注册无关:
实验按完一轮,就该自己动手问容器了。这台控制台连着浏览器里的真容器,回显全部由内核当场算——下面这一串的顺序,就是一次线上排障的顺序:
lab circular cache 与 lab circular nocache 要连着敲。同一个 A↔B,前者的失败点在第七格之前就被取货单救走,后者连单子都没得撕——这一对比比任何一段源码讲解都直观,也正好是上面那台单步调试台的两端。
循环依赖的结局由三个开关共同决定。拖动下面三个参数,右侧会立刻给出启动结果——先把结论玩熟,再去背源码:
startup: OK# 一级 singletonObjects : {a=A, b=B}# 二级 earlySingletonObjects: 空(完成后清空)# 三级 singletonFactories : 空(工厂已被兑掉)结论:单例 + 字段注入 = 三级缓存的主场
读这张沙盘的正确姿势:先看 scope,再看注入方式,最后才看开关。prototype 四行全部判死;singleton 里只有 constructor 判死。真正能被三级缓存救回的,永远只有「单例 + 字段/setter 注入」那几格——其余配置怎么拨都只是在改变报错出现的时机。
新手最怕的是「日志太长看不懂」。下面这些片段都能原样复制去搜索,请先学会读它们。
Boot 给出的循环依赖报告长这样,缩进的树就是环的形状:
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.读法只有三条:① ┌─────┐ … ↑ ↓ … └─────┘ 框住的几行才真在环上,框外只是入口;② 链条从哪个 Bean 开始打印,不代表它是罪魁,它只是最先被创建的那个;③ Action: 段是官方给的自救清单,@Lazy 被明写成 "last resort"。
| 报错原文(片段) | 真实原因 | 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 cycle | Boot 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 篇 |
搜报错时只搜冒号后第一段原文(例如 has been injected into other beans),命中率远高于搜整句——不同 Spring 版本会在后半句加话。
上面那张表的第一行就是本篇的主角异常。下面这段是它的完整现场——先别看结论,点出你认为的凶手帧:
一个类加了第 3 个构造器依赖,本地第一次启动就崩。日志里三行了不得的话反复出现:currently in creation、form a cycle、Action。
建两个互相依赖的类,都用字段注入,跑通后再逐步改成构造器注入,把两段日志抄进笔记。完整可跑代码:
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; }}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)); } }}预期输出(省略启动横幅,只看关键两行):
[result] mail->bob | via SmsService[result] sms->bob | back-ref=true再加一行配置,把 Boot 2.6+ 的默认拒绝显式打开,对比前后差异:
# src/main/resources/application.ymlspring: main: allow-circular-references: true然后把 MailService 改成构造器注入:
@Componentpublic class MailService { private final SmsService sms; public MailService(SmsService sms) { this.sms = sms; } // 只改这一处}再次启动,控制台必然出现这段(这就是本节要你抄进笔记的原文):
***************************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。环被解开的标志不是「日志变少」,而是两个方向都能调用通。
目标:不删掉环,只加一个注解让它起来。
提示:在 MailService 的构造参数前写 @Lazy(public MailService(@Lazy SmsService sms)),其余代码一字不改。
你会观察到三件事:① 启动成功,form a cycle 不再出现;② 打印 sms.getClass().getName(),得到的是 ...$$SpringCGLIB$$0 之类的代理类名而不是 SmsService;③ 故意往 SmsService 的构造器里塞一行 throw new IllegalStateException("boom"),你会发现启动不再报错,异常推迟到第一次真正调用 sms.send(...) 时才炸——这就是 @Lazy 把问题从启动期挪到运行期的代价。
做一个「双写通知器」小项目:UserService 与 AuditService 互相依赖(用户变更要写审计,审计又要回查用户),要求最终既没有环也不使用 @Lazy。
验收清单:
- [ ] 画得出改造前后的依赖箭头图,改造后所有箭头同向
- [ ] 三种方案至少各试一次并写下取舍:抽公共依赖
ChangeLog、ApplicationEventPublisher事件驱动、ObjectProvider<AuditService>延迟取 - [ ]
application.yml里没有allow-circular-references=true - [ ] 启动日志中
BeanCurrentlyInCreationException与form a cycle各 0 次 - [ ] 说清一件事:事件驱动为什么能把双向调用变成单向通知(发布者不认识监听者)
不看资料,说出三级缓存各自的字段名与存储内容——一级成品、二级早期引用、三级工厂。缺一即回第二节。
能说清「二级就能解环,为什么还要三级」吗?关键词必须是「代理的唯一性」与 getEarlyBeanReference。
构造器注入的环,除了重构还有哪些手段?它们的区别是「改变时机」还是「改变结构」?
prototype 作用域为什么一定失败?答案应落在「根本不进 singletonObjects,没有仓库可提前借出」。
Boot 报 form a cycle 时,你能从缩进树里认出环上的 Bean 名吗?
先出生、再装配,取货单兑一次;构造器环不认缓存,@Lazy 只搬时间。
循环依赖不是洪水猛兽,而是一道可被推理的题。记住三条——能解的前提是"单例 + 字段/setter 注入"(对象能先出生再注入),构造器注入与 prototype 无解;三级的分工是一级存成品、二级存早期引用、三级存产出引用的工厂;三级的意义在于把"是否生成代理"推迟到真正被需要时,从而保证代理有且只有一个。而工程上最好的答案,始终是让依赖保持单向——缓存能兜底,但兜不住糟糕的设计。