第一次看到BeanCurrentlyInCreationException的时候,我刚从 Go 转 Java 不久,第一反应是 Spring 容器写 bug 了。后来翻了一下午源码才明白,这是我接触 Spring 容器管理机制以来最经典的一道坎——循环依赖。如果你也在 A 依赖 B、B 依赖 A 的配置里栽过跟头,或者准备去面试被问到“Spring 如何解决循环依赖”时只会背三级缓存的名字,这篇文章值得你花十几分钟静下心看完。
我不会照着官方文档给你复述一遍,而是从实际排查、源码断点、异常堆栈和几个真实事故的角度,把循环依赖这件事彻底讲透。包括三级缓存到底怎么协作、为什么三级不是两级、哪些循环依赖 Spring 真的解决不了、@Async和 AOP 代理为什么会让循环依赖死得更惨,最后附一份面试应答参考。写这篇文章的素材,来自我给线上服务处理过的两次循环依赖宕机故障,以及给团队做源码分享时的验证笔记,都是踩过的坑换来的。
1. 先搞清楚循环依赖的三种形态和典型报错
1.1 什么是循环依赖,直觉和实现差距有多大
循环依赖的直观定义很简单:A 在创建过程中需要注入 B,而 B 在创建过程中又需要注入 A,两个 Bean 互相等着对方先完成,形成死锁。用代码表示就是:
@Component public class A { @Autowired private B b; } @Component public class B { @Autowired private A a; }直觉上这是个死结,但 Spring 靠三级缓存把死结解开了,所以很多人对此有误解,以为所有循环依赖 Spring 都能搞定。真相是,Spring 能解决的只是其中一部分,而且解决过程非常讲究时机。为了理解哪些能解、哪些不能解,我习惯把循环依赖拆成三种形态:
- 构造器注入循环依赖:A 的构造方法需要
B,B 的构造方法需要A。两个 Bean 在实例化(new)阶段就需要对方,谁都没法先被创建出来。 - Setter/字段注入循环依赖:A 实例化后,Spring 在填充属性(
populate)时才去找 B,此时 A 虽然没完全初始化,但至少已经是一个“存在”的对象了,可以提前暴露给别人。 - 混合模式循环依赖:A 用构造器注入 B,B 用字段注入 A。这种既涉及构造器、又涉及字段的情况,能不能解决取决于从哪边开始创建,以及 Spring 在哪个阶段发现循环。
后两种有救,第一种基本没救。原因我们后面在源码追踪里详细说,你现在只要记住一句话:Spring 解决循环依赖,依赖的是“提前暴露半成品对象”的能力;构造器阶段对象还不存在,无对象可暴露,所以解决不了。
1.2 典型报错长什么样,怎么从堆栈反推原因
遇到循环依赖,最常见的报错是一大段BeanCurrentlyInCreationException,核心信息长这样:
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?很多刚接触的人看到Requested bean is currently in creation会一头雾水,不知道这跟循环依赖有什么关系。实际上这行字的意思是:Spring 要创建 Bean A,往缓存里查了一圈发现没有 A,正准备创建的时候,发现当前线程已经在创建 A 了。一个大老爷们不可能同时从两个地方领结婚证,一个 Bean 也不可能在同一个线程里被创建两次。所以 Spring 只能直接把这个异常抛出来。
我见过一个很有意思的现场:某团队在@Configuration配置类里用构造器注入两个服务,结果服务销毁时又互相调用,日志里循环打印销毁日志。那个报错不是创建期间的BeanCurrentlyInCreationException,而是BeanCreationNotAllowedException,提示Singleton bean creation not allowed while singletons of this factory are in destruction。这类问题属于生命周期冲突,不是标准循环依赖,但也值得留意:排查循环依赖时,别盯着异常名称里的“Circular”三个字,要优先看它发生在 Bean 的哪个阶段。
为了让你直观感受不同注入方式在循环依赖下的表现,我整理了一张表:
| 注入方式 | 依赖形态 | 是否能被三级缓存解决 | 报错阶段 |
|---|---|---|---|
| 字段注入 | A 字段注入 B,B 字段注入 A | 能 | 一般不会报错 |
| Setter 注入 | A 的 setter 注入 B,B 的 setter 注入 A | 能 | 一般不会报错 |
| 构造器注入 | 两边都通过构造器互相依赖 | 不能 | 实例化阶段直接抛BeanCurrentlyInCreationException |
| 混合模式 | A 构造器依赖 B,B 字段依赖 A | 看创建顺序 | 可能报错,可能侥幸通过 |
@Async代理场景 | A 依赖 B,B 依赖 A,且至少一方有@Async | 表面能建,但代理失效 | 运行时才知道 |
这张表在后面几节会反复用到,你可以先存个印象。
2. 三级缓存设计:为什么一级不行、两级尴尬、三级将将好
2.1 三级缓存各自存什么,分别解决什么问题
Spring 的单例 Bean 缓存不是一个 Map,而是三个 Map,源码里定义在DefaultSingletonBeanRegistry中:
/** Cache of singleton objects: bean name to bean instance. */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** Cache of singleton factories: bean name to ObjectFactory. */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16); /** Cache of early singleton objects: bean name to bean instance. */ private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);三个 Map 各有其位:
- 一级缓存
singletonObjects:存放已经完整走完初始化流程的成品 Bean。这是最终对外提供服务的那一个,也是getBean通常命中的地方。 - 三级缓存
singletonFactories:存放的是ObjectFactory,也就是一个“对象工厂”。它不直接存对象,而是存了一个可以生成对象的工厂函数。Bean 在刚实例化、还未填充属性时就会被封装成这样一个 Factory 放进来,这个操作叫“早期暴露”。 - 二级缓存
earlySingletonObjects:存放提前暴露的半成品对象。什么时候会从三级缓存升级到二级缓存?当别的 Bean 在创建过程中需要引用当前这个半成品时,Spring 会调用三级缓存里的 Factory 拿到早期引用,然后放进二级缓存,同时把三级缓存里的 Factory 移除。
这里有个很容易被忽略的设计细节:三级缓存里存的是ObjectFactory,不是直接的实例。为什么非要多包一层工厂?因为有些 Bean 在早期暴露时还没经过 AOP 代理,而 Spring 希望你这个半成品被“拿出去”的时候,如果它最终需要代理,那你拿到的就应该是代理对象,而不是原始对象。ObjectFactory的存在,就是为了在“被引用”的那一刻才决定返回原始对象还是代理对象。
2.2 为什么不能用两级缓存,AOP 代理卡死了设计
这是面试高频题,也是理解三级缓存设计的核心。很多人默认 Spring 解决循环依赖靠的是二级缓存,一直不理解第三级存在的意义。我给团队讲这块时,习惯用“提前拍婚纱照”来做类比。
想象这样一个场景:你要办婚礼,但另一半的婚纱还在定制,你总不能等婚纱完全做好再办婚礼,所以婚庆公司说,行,先拍一张素颜照放门口迎宾,等婚纱到了你再补一张精修照。这个“素颜照”就是earlySingletonObjects,它让流程能先走下去。
但问题来了:如果这个新郎最终是要做形象包装的(比如上电视要化妆,对应 Spring 里的 AOP 代理),你在门口放一张素颜照,来宾看到的就不是最终上电视的样子。有两种解决办法:
- 方案 A(两级缓存):一开始就知道要化妆,直接在门口放一张“精修照”,也就是在早期暴露时就生成代理对象。听起来可行,但问题是你怎么在刚实例化时就确定这个 Bean 要不要被代理?确定切面逻辑需要拿到完整的 Bean 后经过
AnnotationAwareAspectJAutoProxyCreator判断,而判断依赖的注解、方法、类信息虽然都在,但此时属性还没填充,后置处理器还没跑完,贸然生成代理很可能生成错。 - 方案 B(三级缓存):先放一个“摄影工作室的联系方式”,也就是
ObjectFactory,告诉来宾“你只要报我的名字,工作室现场帮你修图出片”。谁需要这个半成品,谁就去触发 Factory,Factory 内部会调用代理创建逻辑,决定给素颜照还是精修照。
Spring 选择的是方案 B。它的巧妙之处是延迟决策:在未被别人引用之前,早期对象到底长什么样不重要;一旦被引用,就要保证给出去的版本和最终版本一致(要么都是原始对象,要么都是代理对象)。这就是三级缓存不是两级缓存的根本原因——两级缓存只能给固定的对象,而三级缓存的ObjectFactory可以根据需要临时生成正确的版本。
2.3 三级缓存解决不了代理一致性?关键在 getEarlyBeanReference
你可能要问:ObjectFactory返回的真是代理对象吗?这里涉及到一个关键方法:AbstractAutowireCapableBeanFactory.getEarlyBeanReference。它的核心逻辑是通过SmartInstantiationAwareBeanPostProcessor链来暴露早期引用:
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); } } return exposedObject; }这里的关键点是AbstractAutoProxyCreator.getEarlyBeanReference中的wrapIfNecessary逻辑:它会判断当前 Bean 是否有资格被代理,如果有,就提前创建代理并放入缓存。Spring 还用一个earlyProxyReferences集合记录“已经提前做过代理”的 Bean,防止后续在正常初始化流程中再代理一次,造成代理对象包装代理对象的问题。
如果去掉三级缓存、只保留二级缓存,也不是绝对不行,但必须做到“在放入二级缓存时就提前 AOP 代理”。问题是这会把 AOP 代理的决策时机提前,导致很多后置处理器的判断逻辑失效。所以三级缓存不是过度设计,它是“在正确时机做正确事”的产物。
3. 源码级拆解:从 getSingleton 到 populate,循环依赖一步一步怎么转
3.1 入口阶段:getSingleton 的缓存命中与未命中
循环依赖能解,核心流程都在AbstractBeanFactory.doGetBean和DefaultSingletonBeanRegistry里。我们跟着代码走一遍 A、B 互相依赖的场景。
假设先创建 A。A 的创建入口是getSingleton(beanName),第一次进来缓存全空,返回 null,接着调用getSingleton(beanName, () -> createBean(beanName, mbd, args))进入真正创建逻辑。
getSingleton(String beanName, ObjectFactory<?> singletonFactory)这个方法很重要,它做了几件事:加锁、调用singletonFactory.getObject()创建 Bean、创建成功后放入三级缓存的 Collection 里。但这不是我们关注的重点,重点是创建前它会检查当前是否存在同名 Bean 正在创建中:
boolean newSingleton = false; synchronized (this.singletonObjects) { // 检查是否已存在 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { // 检查当前是否处于创建状态(循环依赖检测关键) beforeSingletonCreation(beanName); // ... } }beforeSingletonCreation会维护一个singletonsCurrentlyInCreation集合。如果 A 在创建中又被二次请求,这个集合里已经有 A 了,就会抛异常。这正是我们前面看到BeanCurrentlyInCreationException的源头。
3.2 创建阶段:doCreateBean 里提前把工厂塞进三级缓存
在createBean里调用doCreateBean后,Bean 会被实例化(通过构造器反射得到原始对象),然后执行一个对循环依赖至关重要的操作——addSingletonFactory:
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); }addSingletonFactory做的事情就是把当前 Bean 的ObjectFactory放进三级缓存singletonFactories。注意这段代码的位置:它在属性填充(populateBean)之前,在 Bean 后置处理器初始化之前。也就是说,A 刚 new 出来,还没设置任何成员变量,就已经被“挂出去”了。
很多初学者以为三级缓存是在 A 完全初始化后才放的,其实不对。早期暴露的时机非常早,早到对象身上一切属性都是默认值,这就是“半成品”。
3.3 填充阶段:B 出现,getSingleton 命中三级缓存并升级
A 继续执行populateBean,开始处理@Autowired字段,发现需要注入 B。于是它调用getBean(B),B 开始走同样的流程:
- B 实例化 -> addSingletonFactory(B 的工厂,放入三级缓存)
- B 开始 populate,发现需要 A
- B 调用
getSingleton(A),这次缓存命中逻辑就复杂了:
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) { synchronized (this.singletonObjects) { singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }注意这里有个isSingletonCurrentlyInCreation(beanName)的判断,它保证只有“A 确实正在创建中”才走三级缓存逻辑。B 在singletonFactories里找到了 A 的 Factory,调用getObject()拿到 A 的早期引用(早就不包含 B 的 A),并把 A 从三级缓存升级到二级缓存earlySingletonObjects。
然后这个半成品 A 被注入到 B 的字段里。B 继续完成初始化,最终成为一个完整 Bean,放进一级缓存。
3.4 收尾阶段:A 的早期引用与最终对象的一致性检查
B 创建完成后,Spring 回到 A 的创建流程。此时 A 继续填充属性,把 B 注入进来。A 的初始化流程继续走下去,如果 A 有 AOP 代理或BeanPostProcessor要处理,会在initializeBean阶段完成。
A 完整初始化后,执行getSingleton(beanName, false)做一次检查,关键点在这里:
if (earlySingletonExposure) { Object earlySingletonReference = getSingleton(beanName, false); if (earlySingletonReference != null) { // 如果早期引用和最终实例不一致,说明有人改了引用,通常说明发生了代理 if (exposedObject == bean) { exposedObject = earlySingletonReference; } } }如果 A 在早期被 B 引用过(二级缓存里有 A),而且 A 经过完整的 Bean 生命周期后依然是原始对象(没有被代理),那么最终注入给容器的还是earlySingletonReference。如果 A 被 AOP 代理了,这段逻辑也保证最终暴露的是代理对象。这个设计保证了“早期拿出去的那个对象”和“最终存在容器里的那个对象”是同一个引用,不会出现引用不一致的问题。
这也解释了为什么三级缓存解决循环依赖是有代价的:早期暴露的对象可能没有完成完整的后置处理器链调用,如果这个半成品在初始化过程中状态被其他 Bean 修改,可能和你预期的成品不一致。这也是为什么 Spring 官方其实建议大家尽量不要依赖循环依赖。
3.5 用流程时序直观感受一次完整的循环依赖解决过程
为了让你更能想象整个过程,我画一个文字版的执行顺序(不用图,用编号描述):
getBean(A)-> 缓存 miss -> 创建 A -> A 实例化 -> 放入三级缓存(A Factory)- A populate -> 发现需要 B ->
getBean(B)-> 缓存 miss -> 创建 B -> B 实例化 -> 放入三级缓存(B Factory) - B populate -> 发现需要 A ->
getSingleton(A)-> 一级无、二级无、三级命中 -> 调用 A Factory 得到半成品 A(可能代理) -> 放入二级缓存,移除三级缓存中的 A Factory - B 注入半成品 A -> B 剩余初始化完成 -> B 放入一级缓存
- 回到 A populate -> 注入完整的 B -> A 剩余初始化完成
getSingleton(A, false)-> 二级缓存命中(半成品 A) -> 如果 A 未被代理,则暴露该引用;如果 A 被代理,则用最终代理- A 放入一级缓存,移除二级缓存中的半成品 A
在实际断点调试时,你会在第 3 步看到 B 的a字段确实是一个 A 的早期引用,此时 A 的b字段还是 null。只有到第 5 步,A 的b字段才被赋值。这个“先有对象、后填属性”的中间态,就是循环依赖能解的本质。理解了这个时序,后面再去看 Spring Boot 2.6 默认关闭循环依赖的决策,就很容易明白官方在担心什么了。
4. 构造器注入的死结:为什么三级缓存救不了它
4.1 构造器阶段的“无对象可暴露”
前面提到,三级缓存解决循环依赖的核心是“把半成品提前暴露”。但半成品暴露的时机在new之后、属性填充之前。构造器注入的循环依赖发生在哪?发生在new那一刻。
A 的构造方法需要 B,但 B 还没创建;B 的构造方法需要 A,可 A 的构造还没执行完,对象根本不存在。Spring 想暴露 A 的半成品,也暴露不了——A 连个原始对象都没 new 出来。更直白地说:循环依赖的“依赖”发生在构造函数传入参数时,而三级缓存只解决“属性赋值”阶段的依赖,两者根本不在同一个时间维度上。
我经常用开派对来比喻:A 是个厨师,B 是个服务员。如果 A 上班第一天就要求“必须服务员 B 先到位,我才进场”,B 也要求“必须厨师 A 先到位,我才进场”,那这场派对永远开不了;但如果 A 先到场(哪怕没带食材),B 看到 A 先到了,也愿意进场,派对就能开始。三级缓存解决的是“先进场备菜”的问题,解决不了“谁都不先进场”的僵局。
4.2 构造器注入循环依赖的报错过程
我们模拟一下构造器注入的循环依赖:
@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 找 A 的构造器,参数需要 B,于是先创建 B。B 的构造器需要 A,getSingleton(A)会检查:A 是不是正在创建中?是,因为 A 还没创建完。接着查三级缓存,发现 A 的三级缓存根本不存在——因为 A 连new都还没 new 出来。于是抛BeanCurrentlyInCreationException。
如果只看报错日志,你甚至可能一头雾水:明明日志先打印 “Creating bean with name 'a'”,接着又报 “Requested bean is currently in creation: Is there an unresolvable circular reference?”。原因就是 A 的创建流程被 B 的依赖“卡”住了,B 又回来找 A,把 A 自己给锁死了。
4.3 如何治理构造器循环依赖:能改结构就别动缓存
构造器注入循环依赖既然没法靠容器解决,只能靠代码层面修复。我的治理顺序是这样的:
- 优先重构:把互相依赖的类拆分,或者引入中间层。例如抽出一个
C,让 A 和 B 都只依赖 C,从根上消除环。 - 改注入方式:把其中一个 Bean 的构造器注入改成 Setter 注入或字段注入。虽然团队里总有人喷字段注入不好,但在循环依赖这种场景下,活下来比风格更重要。不过要注意,改了之后可能引入“半成品状态被外部访问”的隐患,需要做好空值保护。
- 用
@Lazy打破依赖:在构造器参数上加@Lazy,Spring 会注入一个代理对象,真正调用时才去解析目标 Bean。
比如:
@Component public class A { private final B b; public A(@Lazy B b) { this.b = b; } }这样 A 构造时拿到的不是完整的 B,而是一个延迟解析的代理,B 构造时再依赖 A 就能正常拿到。代价是访问 B 的方法时多了一层代理调用,性能损耗极小,但语义上要求使用方接受“延迟解析”。
我总是跟团队强调一句话:框架能帮你兜底,但兜底不等于鼓励你制造环。构造器循环依赖就是典型的“代码结构缺陷”,即使靠@Lazy蒙混过关,后续维护的人看到两个类互相构造,还是会头皮发麻。如果让我评审代码,构造器循环依赖是一票否决项。
5. 隐藏得更深的坑:@Async、AOP 代理与循环依赖的组合事故
5.1 @Async 循环依赖为什么表面正常、实际代理失效
如果说构造器循环依赖是“直接报错”,那@Async与循环依赖的组合就是“埋雷不响,点火才爆”,这也是我线上碰到过最隐蔽的问题。
先看代码:
@Component public class A { @Autowired private B b; @Async public void asyncMethod() { System.out.println("A async"); } } @Component public class B { @Autowired private A a; }A 依赖 B,B 依赖 A。按照前面讲的逻辑,三级缓存能解决这个循环依赖,启动不会报错。但问题来了:A 上有@Async,而@Async是通过AsyncAnnotationBeanPostProcessor后在initializeBean阶段生成代理对象的。
当 B 在 populate 阶段需要 A 时,它从三级缓存singletonFactories里触发getEarlyBeanReference。这个阶段AsyncAnnotationBeanPostProcessor有没有生效?要看getEarlyBeanReference的执行链。AbstractAutoProxyCreator.getEarlyBeanReference会对有资格的对象提前创建代理,但@Async的处理器AsyncAnnotationBeanPostProcessor继承自AbstractAdvisingBeanPostProcessor,它没有实现SmartInstantiationAwareBeanPostProcessor,所以不会参与早期代理。
这意味着 B 拿到的 A 是一个原始对象,不是代理对象。后续 A 完成初始化时,Spring 发现 A 被代理了,但代理发生在初始化之后,此时 A 如果发生过早期暴露,exposedObject != bean判定成立,容器最终可能采用的是早期引用(原始对象)。结果就是:A 最终注入到容器和 B 里的都是原始对象,A 的@Async方法全部失效,变成同步调用。
线上表现就是:接口响应时间从 50ms 涨到 3 秒,查日志发现异步任务全都在调用线程里跑完了。当时排查了很久才发现是循环依赖 +@Async的组合问题。
5.2 为什么 @Transactional 循环依赖一般没事
很多人会问:@Async有这问题,@Transactional怎么好像没事?原因是@Transactional的代理创建处理器InfrastructureAdvisorAutoProxyCreator继承了AbstractAutoProxyCreator,支持getEarlyBeanReference的早期代理。所以它在循环依赖中能正确生成代理对象,B 拿到的就是代理,不会出现“代理丢失”的问题。
@Async的处理器不走SmartInstantiationAwareBeanPostProcessor这条路,所以完全没有早期代理能力。这提醒我们:能不能被三级缓存正确暴露,取决于代理处理器是否实现了getEarlyBeanReference。凡是没实现早期代理的注解类 AOP,一旦和循环依赖组合,都要警惕代理失效。
5.3 Spring Boot 2.6 起为什么默认禁止循环依赖
Spring Boot 2.6 发布时,官方做了一个破坏性变更:spring.main.allow-circular-references默认为false。如果你在 2.6 及以上版本中使用循环依赖,容器直接启动失败,并提示:
Description: The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | a defined in file ... ↑ ↓ | b defined in file ... └─────┘这个变更背后的逻辑,官方说是为了让开发者强制避免循环依赖,因为循环依赖是设计缺陷的信号,会让代码变得难以维护。实际上在我看来,还有一个现实原因:循环依赖 + 代理对象的组合不确定性太高,像@Async这种失效问题根本不是容器能控的。宁可启动报错,也不能看着应用起来了但行为全错。
如果你是老项目升级,可以在application.yml里临时开一个口子:
spring: main: allow-circular-references: true但这个配置应该只是过渡方案。正确做法是借机排查,把循环依赖逐个拆掉。我升级过几个老项目,经验是:大部分循环依赖其实是设计问题,改造成本没想象中高,用@Lazy或者抽公共依赖就能解决。
5.4 排查循环依赖的两个实战技巧
排查循环依赖,最烦的是不知道环在哪里。Spring Boot 2.6 以上的报错已经帮我们画出了环的形状,但老版本只有一段抽象异常。我分享两个自己常用的排查手段:
技巧一:启动时打印所有 Bean 的依赖关系。写一个ApplicationRunner,用DefaultListableBeanFactory拿每个 Bean 的PropertyValues和构造器参数,构建有向图做环检测。代码量不大,但能快速定位环的边界。
@Component public class CircularDependencyDetector implements ApplicationRunner { @Override public void run(ApplicationArguments args) { // 获取所有 bean 定义 // 对每个 bean 收集依赖:构造器参数、@Autowired 字段、setter // 用 DFS 找环并打印路径 } }技巧二:断点打在DefaultSingletonBeanRegistry.getSingleton的singletonFactories获取处。只要看到isSingletonCurrentlyInCreation(beanName)为 true 且 singletonFactories 有值,这里就是循环依赖发生的位置。观察调用栈,你能看到是从哪个 Bean 的属性填充发起对当前 Bean 的二次获取。这个方法虽然费时间,但能帮你真正理解问题,而不是靠猜。
6. 几个必须搞清的高频面试问题与一针见血的答法
6.1 三级缓存能解决所有循环依赖吗
不能。构造器注入的循环依赖解决不了,非单例 Bean(@Scope("prototype"))的循环依赖解决不了,部分@Async组合场景虽然启动能过,但代理会失效。面试时不要一上来就说“能解决”,这是个陷阱题。更好的回答是:Spring 的三级缓存解决的是“单例、允许循环引用、通过 setter 或字段注入”的循环依赖,本质上是把 Bean 的实例化和初始化解耦,用提前暴露半成品的方式打破循环。
6.2 为什么是三级缓存不是二级缓存,你怎么设计
这个问题我建议结合代码回答,不要只背结论。核心逻辑是:一级缓存存成品,为避免重复创建需要加锁检查;创建过程中出现循环依赖时,其他 Bean 需要拿到的不是最终对象,而是一个可以触发代理决策的ObjectFactory。如果只有两级,就必须在对象放进二级缓存时立刻决定是否代理,这会把 AOP 代理的时机过早提前,破坏很多后置处理器的执行顺序。三级缓存的价值在于延迟代理决策:只有真正被引用时,才触发getEarlyBeanReference去决定返回原始对象还是代理对象。
如果面试官追问“那你自己实现会怎么做”,你可以回答:同样的场景下,我会把“BeanDefinition 处理完、实例化后、初始化前”这个阶段作为暴露时机,用一个Map<String, Supplier<Object>>存半成品工厂;被依赖时调用Supplier判断是否需要代理,需要则创建代理,否则返回原始对象;初始化完成后用成品覆盖。这样设计后,本质上就会得到一种“三级缓存”。
6.3 Spring 是怎么判断当前 Bean 正在创建中的
DefaultSingletonBeanRegistry维护了一个Set<String> singletonsCurrentlyInCreation,在beforeSingletonCreation时向里面添加 beanName,在afterSingletonCreation时移除。当getSingleton发现一级缓存没有、二级缓存没有、三级缓存没有,但singletonsCurrentlyInCreation里已有当前 beanName,就会判定发生了循环依赖。
这个判断机制也解释了 prototype 作用域为什么解决不了循环依赖:prototype Bean 不会进singletonsCurrentlyInCreation的缓存保护机制,每次getBean都是全新的,Spring 无法定位到“当前正在创建的实例”,自然谈不上提前暴露。
6.4 循环依赖有没有性能损耗,可以完全避免吗
性能损耗是有的,主要是缓存访问和加锁开销,但对绝大多数业务系统来说可以忽略。更严重的不是性能,而是对象语义的复杂性:半成品对象可能被多个 Bean 引用,期间如果状态发生变化,引用方可能看到不一致的数据;加上代理失效的问题,会让系统行为变得不可预期。所以我的建议是:能避则避,不要在业务代码里滥用循环依赖。用构造器注入、拆接口、抽中间层,这些方式能消除绝大多数环。
6.5 面试时怎么组织这段回答更有说服力
我建议按这个顺序组织:
- 先说结论:解决的是单例 + setter/字段注入的循环依赖。
- 抛出三个 Map 的名字和各自职责,说明“早期暴露”的时机。
- 走一遍 A/B 互相依赖的流程,重点说三级缓存如何在 B 注入 A 时触发
getEarlyBeanReference。 - 解释为什么是三级不是两级,用 AOP 代理决策时机做拔高点。
- 补充构造器注入无法解决的原因,展示你对边界的理解。
- 最后提一句 Spring Boot 2.6 默认禁止循环依赖,说明你知道这个特性的演进。
这套回答大概三分钟,既有深度又有广度,面试官一般问到这里就会点头。
7. 从一次线上故障总结:我对循环依赖的态度
处理完那次@Async循环依赖故障后,我在团队里立了一条不成文的规定:新代码禁止出现循环依赖,看到构造器注入的环直接打回。老代码逐步改造,利用 Spring Boot 2.6 的启动报错做检查清单,一个一个清。这不是教条,是因为循环依赖带来的问题太隐蔽了。
举个最简单的例子:A 依赖 B,B 依赖 A。你觉得功能没问题,反正 Spring 能处理。但三个月后,有人给 A 加了一个@Cacheable注解,或者给 B 的方法加了个事务,代理链条一变,整个循环依赖的稳定性就崩了。这类问题你不在初始化阶段暴露出来,就只能在运行时用事故来提醒你。
如果你现在正在为循环依赖头疼,我的建议是:先照着文章第 3 节的时序把流程走一遍,确定自己的场景到底是哪一类;能改的设计马上改,不能改的临时用@Lazy顶一下,然后在代码里加 TODO,尽快从环里跳出来。技术是为业务服务的,但技术债务该还还得还,越早还利息越低。
最后分享一个我自己习惯的检查方式:每次写完一个 Spring Boot 服务,我会把启动日志里Bean 'xxx' is a candidate for getting processed by CallbackBeanPostProcessor这类代理相关的日志扫一遍,再确认下有没有被循环依赖代理陷阱命中。这个习惯帮我避开过好几次线上事故,也推荐给你。