☰
Spring构造器循环依赖为什么会失败?源码剖析三级缓存机制
2026/10/14 13:19:49 网站建设 项目流程

前阵子帮同事排查一个启动异常,Spring Boot 项目刚启动就抛了BeanCurrentlyInCreationException,报错信息里有一句特别容易误导人的话:Requested bean is currently in creation: is there an unresolvable circular reference?。直译过来就是“请求的 Bean 正在创建中,是不是有无法解决的循环引用?”很多人第一反应是:Spring 检测到了循环依赖,然后主动拒绝。但真实情况并不是这样。

这个问题的本质,藏在 Spring 创建 Bean 的时间线里。同样是循环依赖,字段注入和 setter 注入能安然启动,换成构造方法注入就必然翻车,不是 Spring 随机抽风,而是构造方法注入在时间上恰好踩中了框架最不方便处理的阶段。这篇文章我会直接把源码链路拉出来,一步一步复盘从getBean到最终抛异常的全部过程,解释清楚“为什么不支持构造方法循环依赖”这个问题的准确答案,顺带聊聊实际项目里遇到构造器循环依赖时的处理顺序和排障经验。如果你正准备啃 Spring 源码,或者面试时被问到这个问题,这篇应该能帮上忙。

1. 同是循环依赖,为什么字段注入能活、构造器注入会挂

1.1 一个反直觉的对比实验

先做一个最小复现,Spring Framework 5.x 下,不用任何额外配置,直接在容器里注册两个互相依赖的单例 Bean。

@Component public class ServiceA { private final ServiceB serviceB; public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; } } @Component public class ServiceB { private final ServiceA serviceA; public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; } }

启动容器,立刻抛错:

BeanCurrentlyInCreationException: Error creating bean with name 'serviceA': Requested bean is currently in creation: Is there an unresolvable circular reference?

然后把两个注入方式改成字段注入:

@Component public class ServiceA { @Resource private ServiceB serviceB; } @Component public class ServiceB { @Resource private ServiceA serviceA; }

同一个容器,启动成功,业务方法调用也正常。这就是最让人困惑的地方:Spring 官方明明说支持循环依赖,怎么换了个注入方式就死给你看?

答案的关键在于,循环依赖和循环依赖不一样。字段注入、setter 注入的循环依赖发生在“对象已经实例化、正在填充属性”的阶段;而构造方法注入的循环依赖发生在“对象还没有实例化”的阶段。这两个阶段在 Spring 的源码处理流程里,根本不是同一个时间点,能用的缓存机制也完全不同。

1.2 报错信息其实没在说“检测到循环引用”

很多人被Requested bean is currently in creation这句英文带偏了,以为 Spring 在创建前先做了环检测,发现 A 依赖 B、B 依赖 A,就拒绝创建。实际上 Spring 默认并不做预先的“环路拓扑检测”,它是在创建过程中递归请求 Bean 时,发现缓存里拿不到任何可用对象,才触发了异常。

这句话的真实含义是:当前线程正在创建 A,但 A 又需要被别人依赖,而且此刻容器里连 A 的半成品都找不到。换句话说,问题不是“Spring 发现了环”,而是“Spring 在这个环上拿不出任何东西给下一跳”。明白这一点后,后续源码分析才有方向:我们要研究的是,Spring 在什么时机把“半成品”放进了缓存,以及构造器注入为什么赶不上这个时机。

1.3 复现时先记住两个边界条件

这个实验我建议你自己跑一遍,因为光看结论很容易忽略两个边界:

第一,Spring 默认allowCircularReferences = true,也就是说它“允许”循环引用存在,但这个允许只对字段注入和 setter 注入有效。设置了这个开关,不代表构造器注入也能被容忍。

第二,上面说的“能活”只针对 singleton 作用域。如果 A、B 改成 prototype,就算用字段注入也一样抛异常,后面我会专门讲 prototype 的原因。

也就是说,我们平时说的“Spring 支持循环依赖”,准确说法应该是“Spring 支持 singleton 作用域下、通过字段或 setter 注入触发的循环依赖”。这句话的每个限定词都不是废话。

2. 三级缓存的时间窗口:半成品到底何时被允许见人

2.1 三个缓存各管一件事、一层分一段事

Spring 解决循环依赖的核心机制是三级缓存,代码在DefaultSingletonBeanRegistry里,为了照顾不熟悉源码的读者,我先摆一张职责划分的表。

缓存变量名装的是什么备注
一级缓存singletonObjects完整初始化结束的成品 Bean真正的单例池
二级缓存earlySingletonObjects已经实例化、但属性还没填充的早期引用半成品的“快捷方式”
三级缓存singletonFactories保存ObjectFactory工厂对象必要时调getObject()拿早期引用

为什么不直接用一个二级缓存存半成品,还要中间加一层ObjectFactory?因为 Spring 需要考虑 AOP 代理。一个 Bean 如果被切面增强,提前暴露出来的应该是代理对象,而不是原始对象;三级缓存里的ObjectFactory可以在真正需要时,通过getEarlyBeanReference把原始对象加工成代理对象。这层“延迟加工”的能力,用普通缓存装对象是做不到的。

2.2 半成品暴露的前提:实例化必须已经完成

关键问题来了:半成品是什么时候放进三级缓存的?看AbstractAutowireCapableBeanFactory的doCreateBean,有一段逻辑是这样的:

boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); }

这段代码的调用位置,在createBeanInstance(beanName, mbd, args)执行完后、populateBean(beanName, mbd, instanceWrapper)执行前。

createBeanInstance这个方法名直译就是“创建 Bean 实例”,也就是执行构造方法、产生对象引用的地方;populateBean是“填充属性”,也就是处理字段注入、setter 注入的地方。三级缓存暴露半成品的时机,恰好卡在这两个方法之间。

换句话说,Spring 必须先拿到一个“对象壳”,哪怕这个壳里什么都没有,只要内存地址存在,它就可以把这个壳提前暴露给别的 Bean。这正是字段注入循环依赖能活下来的前提:A 在填充属性前已经把自己暴露进了三级缓存,B 创建时才能通过三级缓存拿到 A 的早期引用。

2.3 三级缓存为什么只管 singleton

你可能注意到了,上面判断earlySingletonExposure的条件里第一行就是mbd.isSingleton()。为什么 prototype 不参与这套机制?

从时间线看,三级缓存保住的对象,将来是要放进单例池、被整个容器复用的。prototype 每次请求都新建对象,本就无“池”可放,提前暴露一个“用完即弃”的半成品没有任何意义。更重要的是,prototype 循环依赖如果也走缓存,就会变成无限递归创建,直接把栈打爆。Spring 对 prototype 循环依赖的处理策略是“直接拒绝”,这个细节我们在第五章还会展开。

所以三级缓存本质上是为 singleton 的“创建中 → 创建完成”这段时间窗口准备的“临时通道”。这个通道从实例化完成开始,到初始化结束关闭。

3. 源码链路复盘:构造器循环依赖在哪个环节彻底断掉

3.1 一个 Bean 的完整创建顺序

先把 Spring 创建 singleton Bean 的主干链路梳理清楚,源码入口是AbstractBeanFactory.doGetBean,然后一路走到AbstractAutowireCapableBeanFactory.createBean、doCreateBean。主干方法执行顺序如下:

  1. createBeanInstance:实例化对象,调用构造方法
  2. addSingletonFactory:把当前对象包装成ObjectFactory放入三级缓存
  3. populateBean:属性填充,处理字段注入和 setter 注入
  4. initializeBean:执行@PostConstruct、InitializingBean、AOP 代理包装等初始化逻辑
  5. 将最终对象放入一级缓存singletonObjects

字段注入和 setter 注入发生在第 3 步,构造器注入发生在第 1 步的内部。这个位置差异,是理解整篇文章的核心。

3.2 构造器参数解析为什么会触发递归创建

当 Spring 创建 ServiceA 时,走到第 1 步createBeanInstance,接下来要做“构造器推断”。Spring 会找构造方法,解析构造器参数列表,看到参数类型是ServiceB,于是进入ConstructorResolver.resolveAutowiredArgument,最终调用到beanFactory.resolveDependency,这个方法内部会执行一次getBean("serviceB")。

这段链路可以简写成:

createBeanInstance("serviceA") -> ConstructorResolver.autowireConstructor(...) -> resolveAutowiredArgument(...) -> beanFactory.resolveDependency(...) -> doGetBean("serviceB")

注意,这整条链都发生在 ServiceA 的addSingletonFactory之前。也就是说,Spring 为了给 ServiceA 找个构造参数,提前拉起了 ServiceB 的创建,而 ServiceA 自己还没有向三级缓存暴露任何东西。

3.3 同一个 getSingleton,三次查询的结果完全不同

ServiceB 的创建流程同样会走到构造器推断:它需要ServiceA,于是再次触发getBean("serviceA")。此时 Spring 会通过DefaultSingletonBeanRegistry.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) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }

在构造器循环依赖的场景下,ServiceA 的查询结果如下:

缓存是否命中的原因结果
singletonObjectsA 还没创建完成,不可能有成品未命中
earlySingletonObjectsA 还没执行到addSingletonFactory,二级缓存自然为空未命中
singletonFactoriesA 的ObjectFactory还没注册,三级缓存也为空未命中

三次查询全部落空,getSingleton返回 null。紧接着doGetBean发现没有拿到缓存对象,准备进入“真正创建 A”的分支,但beforeSingletonCreation检查发现:当前线程已经在创建serviceA的集合中标记过了。同一个线程内二次创建同名单例,连重入的资格都没有,于是直接抛出BeanCurrentlyInCreationException。

而字段注入场景下,ServiceA 早就完成了实例化,addSingletonFactory已经执行,三级缓存里有 A 的ObjectFactory。getSingleton能从上表第三行命中,调getObject()拿到早期引用放到二级缓存,再把这个引用交给 ServiceB,ServiceB 完成构造后,ServiceA 再继续填充自己的属性。整条环就解开了。

3.4 为什么“尝试解决”的机会都没有

有人会问:Spring 能不能在检测到构造器循环依赖时,先返回一个空对象,等构造完成后再补参数?答案是:不能,也没有意义。

在字段注入场景,Spring 拿到的半成品是一个已经“存在”的对象,属性填充可以在拿到依赖后回头补。但在构造器场景,对象本身还没构造完成,JVM 层面都不存在一个可供持有的引用,更不用说把引用交给别人。Spring 连“半成品”都没有,怎么可能拿去解环?它不是“尝试解决后失败”,而是从机制上就不存在尝试解决的机会。这也是为什么异常发生在构造器参数解析这一条链路里,而不是发生在populateBean阶段。

4. 如果强行支持构造器循环依赖,框架要付出什么代价

4.1 先有壳还是先有内容,这是根本冲突

三级缓存能解决字段循环依赖,前提是“先有壳,再填内容”。字段注入和 setter 注入都符合这个模式:对象实例已经存在,依赖关系只是在填坑。构造器注入则是“内容齐了,才允许对象存在”,它要求所有依赖在对象诞生前全部到位。

让 Spring 支持构造器循环依赖,等于要求框架允许“一个尚未构造完成的对象被别人持有”。这在 Java 的内存发布模型里是危险的:对象在构造方法返回之前处于不可安全发布状态,别的线程(哪怕当前同一个线程)提前持有这个对象引用去调用方法,很可能看到的是不完整状态,轻则 NPE,重则脏数据。为了一个特殊场景破坏对象生命周期语义,成本实在太高。

4.2 与构造器注入的推荐初衷正面冲突

Spring 官方一向推荐“对必要依赖使用构造器注入”,理由很简单:构造器注入的参数通常被final修饰,能保证对象的依赖不可变、不可为空,对象一旦创建就是完整状态。这也是不可变对象设计的基本要求。

如果允许构造器循环依赖,就会出现一个逻辑颠倒的场景:ServiceB 持有 ServiceA 的引用,但 ServiceA 的构造参数还没填全,ServiceB 拿到的 ServiceA 是残缺的。这个“残缺对象”一旦被使用,就破坏了构造器注入本来要保证的“对象完整性”。Spring 用三级缓存去宽容字段注入的循环依赖,某种程度上已经是在迁就历史代码;再为构造器开这个口子,等于把自己最推荐的编码风格从根上掘掉。

4.3 代理和增强场景会让问题指数级放大

再考虑 AOP。一个 Bean 如果被事务、异步等方法增强,在提前暴露时,Spring 会通过getEarlyBeanReference把原始对象包装成代理。这个步骤依然要求原始对象已经存在,代理只是给原始对象“包了一层”。

假设构造器循环依赖被强行支持,Spring 就不得不面对“一个根本还没实例化的类,先造出一个代理对象给被人用”的局面。代理调用最终要落脚到真实对象的方法上,真实对象还卡在构造过程中,代理行为会产生不可预知的自调用问题。光是理顺这个流程,就足够把AbstractAutowireCapableBeanFactory的复杂度再抬升一个台阶,而收益只是解决一个本应通过重构消除的坏味道。

4.4 框架作者的选择:不是实现不了,而是不该实现

从源码设计者的视角看,循环依赖在绝大多数业务代码里都属于设计信号:两个 Bean 平级互持,往往意味着职责边界没有划清。Spring 对字段注入循环依赖的容忍,更多是历史兼容和“既然能救就救一下”的策略;构造器注入则被官方文档明确点名为“与循环依赖不兼容”的方式,原因就是它把依赖前置到了对象诞生之前,本质上拒绝了“先暴露半成品”这套方案。

换个角度说,Spring 不支

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询