年前接手一个老项目,启动时直接给我甩了一屏红:BeanCurrentlyInCreationException: Error creating bean with name 'orderQueryService'。当时第一反应是"又踩循环依赖了",毕竟Spring Boot 2.6之后默认就禁止循环引用,多半是allow-circular-references没开。但仔细看堆栈却不对劲——异常指向的不是字段注入,而是配置类里的一个@Bean工厂方法参数,另一个 Service 类型传不进来。这就不是"打开一个开关"能糊弄过去的了。
这篇文章专门聊"工厂方法创建Bean时的循环依赖"这个点:为什么@Bean方法、静态工厂方法这些"工厂形态"在遇到循环依赖时,Spring 的三级缓存基本使不上劲;以及 Spring Boot 2.x/3.x 下这类问题应该怎么排查、怎么解。
1. 先看现场:工厂方法循环依赖的报错长什么样
1.1 一段最典型的"工厂方法循环依赖"代码
先还原一下我当时遇到的场景。为了演示,把业务类简化成两个 Service,彼此需要引用对方:
@Configuration public class BizConfig { @Bean public OrderQueryService orderQueryService(OrderExecuteService executeService) { return new OrderQueryService(executeService); } @Bean public OrderExecuteService orderExecuteService(OrderQueryService queryService) { return new OrderExecuteService(queryService); } }OrderQueryService构造器接收OrderExecuteService,OrderExecuteService构造器接收OrderQueryService。两个@Bean方法的参数都是对方,容器一启动就形成闭环。这种写法本质上就是"工厂方法参数级循环依赖"。
启动后核心异常是这样的:
UnsatisfiedDependencyException: Error creating bean with name 'orderQueryService': Unsatisfied dependency expressed through method 'orderQueryService' parameter 0; nested exception is BeanCurrentlyInCreationException: Error creating bean with name 'orderQueryService': Requested bean is currently in creation: Is there an unresolvable circular reference?注意这里的措辞——"expressed through method 'orderQueryService' parameter 0"。这一句就是关键线索:依赖是通过工厂方法的参数传递的,而不是通过字段或 setter。这说明 Spring 在处理@Bean方法的参数时,遇到了创建期的循环依赖。
1.2 报错信息里的关键线索
同样是循环依赖,不同注入方式的报错信息是不同的,读法也不一样。
如果是普通字段注入循环,Spring Boot 2.6 之前通常能看到这样的提示:
The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | orderQueryService (field orderExecuteService) ↑ ↓ | orderExecuteService (field orderQueryService) └─────┘如果是构造器注入或工厂方法参数注入,报错往往不是BeanCreationNotAllowedException,而是UnsatisfiedDependencyException包裹的BeanCurrentlyInCreationException。为什么会有一个CurrentlyInCreation?因为 Spring 在创建orderQueryService的过程中发现它"正在创建中"——同一线程带着同一个 Bean 的名字又回到了创建入口,这种递归式的回头在创建期是不允许的。
遇到BeanCurrentlyInCreationException的时候,第一件事不是开allow-circular-references,而是先分辨:这个循环是发生在"实例化/创建"阶段,还是发生在"属性填充"阶段。后者才有概率被三级缓存救回来,前者基本没救。
1.3 为什么这不是调大 allow-circular-references 就能解决的
很多人在 Boot 2.6 之后遇到循环依赖,第一反应就是配置:
spring.main.allow-circular-references=true这个开关对普通 setter/字段注入循环是有效的,因为它允许容器在"属性填充"阶段提前暴露早期引用。但工厂方法参数循环不一样:@Bean方法的参数解析发生在实例化阶段,这个阶段在 Spring 的 Bean 创建流程里排得很靠前,早于早期引用的暴露。开关打开后,三级缓存虽然允许被注册,但工厂方法解析参数的时机根本轮不到走三级缓存。
所以结论很明确:工厂方法创建 Bean 时的循环依赖,不是配置开关能解决的,必须换路子。后面几节我会把底层原因彻底拆开。
2. 三级缓存为什么"救不了"工厂方法:执行顺序才是本质
要搞清楚这个问题,不能只在日志层面打转。Spring 解决循环依赖的机制是"三级缓存 + 早期引用暴露",而工厂方法的问题恰恰卡在了这个机制生效之前。
2.1 三级缓存各管什么
所有单例 Bean 都会进DefaultSingletonBeanRegistry的缓存体系,三级缓存分别是:
| 缓存 | 名称 | 存的是什么 |
|---|---|---|
| 一级 | singletonObjects | 创建完成的成品 Bean,大家平时 getBean 拿到的就是它 |
| 二级 | earlySingletonObjects | 提前暴露的半成品 Bean(原始对象或代理),循环依赖中"被对方提前引用"的对象 |
| 三级 | singletonFactories | ObjectFactory工厂,真正需要提前暴露时才调用它生成早期引用 |
关键在第三级。它不是直接存对象,而是存一个"懒工厂":addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean))。只有当另一个 Bean 在创建过程中反过来需要当前 Bean 时,才会触发这个工厂,生成一个早期引用放到二级缓存里,再注入给对面的 Bean。
2.2 doCreateBean 里的三步执行顺序
AbstractAutowireCapableBeanFactory#doCreateBean是单例 Bean 创建的核心方法,整个流程可以简化成三步:
// 第一步:实例化 Bean(构造器、工厂方法都在这里) Object bean = createBeanInstance(beanName, mbd, args); // 第二步:允许循环引用时,注册三级缓存,提前暴露早期引用 boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); } // 第三步:填充属性(字段注入、setter 注入都在这里) populateBean(beanName, mbd, instanceWrapper);注意顺序:实例化 → 注册三级缓存 → 填充属性。三级缓存的注册,发生在实例化完成之后、属性填充之前。也就是说,凡是需要在属性填充阶段回环过来的依赖,Spring 都能通过三级缓存拿到当前 Bean 的早期引用;凡是需要在实例化阶段就拿到依赖的,当前 Bean 还没走到"注册三级缓存"这一步,自然拿不到早期引用。
这就是 Spring 能解决 setter/字段注入循环依赖、却解决不了构造器注入循环依赖的底层原因。
2.3 工厂方法依赖解析发生在哪一环
那工厂方法创建 Bean 属于哪一步?答案很直接:属于第一步createBeanInstance。
@Bean方法在 Spring 内部其实是被封装成一个RootBeanDefinition,beanClass是配置类,factoryMethodName是方法名。创建这个 Bean 时,容器会走到ConstructorResolver#instantiateUsingFactoryMethod,它要做的事情和构造器注入非常像——解析工厂方法的参数:
// ConstructorResolver#instantiateUsingFactoryMethod 中会解析构造参数 ConstructorArgumentValues resolvedValues = new ConstructorArgumentValues(); resolvedValues.addGenericArgumentValue(...); // 解析参数时可能触发其他 Bean 的加载 Object[] args = resolveConstructorArguments(beanName, mbd, bw, constructorToUse, resolvedValues);resolveConstructorArguments最终会调用beanFactory.resolveDependency(...)去解析参数类型对应的 Bean。在解析orderQueryService方法的参数OrderExecuteService时,容器就需要先创建orderExecuteService;创建orderExecuteService时,又要解析它的参数OrderQueryService;此时orderQueryService正在创建中,但它的三级缓存还没注册(因为还没从createBeanInstance走出来),于是getSingleton拿不到,最终抛出那个BeanCurrentlyInCreationException。
所以工厂方法的参数依赖,在创建链路上的位置和构造器参数一模一样:它发生在三级缓存生效之前。容器没有机会提前暴露早期引用,死路一条。
2.4 getEarlyBeanReference 与早期代理
顺手补一个知识点:为什么三级缓存不干脆存原始 Bean 而是要包一层 ObjectFactory?
因为getEarlyBeanReference不只是返回原始对象,它还会让SmartInstantiationAwareBeanPostProcessor处理早期引用,最常见的就是生成 AOP 代理。比如orderQueryService本身需要被 AOP 切面代理,在循环依赖里被orderExecuteService提前引用到的那个对象,必须是最终那个代理对象,而不是代理之前的裸对象。三级缓存的设计保证了"需要提前暴露时才去生成代理",生成一次放到二级缓存,保证全局引用一致。
如果直接用二级缓存存裸对象,会导致两个问题:一是代理没生效,B 拿到的是 A 的原始对象,后面 A 被代理了,两边引用不一致;二是无法保证只暴露一次,可能生成多个代理对象。理解这个机制,再看工厂方法循环依赖就更容易明白——工厂方法阶段连"暴露"这个动作都还没发生,后面这些精妙设计对它是无效的。
3. 三种"工厂"形态,循环依赖结果各不相同
"工厂方法"在 Spring 里其实有好几种落地形态,它们在循环依赖面前的表现不完全一样,平时很多人混为一谈。我把常见的三种拆开来讲,每种给结论。
3.1 @Bean 方法参数注入:最常见的形态
@Bean方法通过参数声明依赖,是 Spring Boot 项目里最常见的装配方式。前面已经说过,它的参数由ConstructorResolver解析,发生在实例化阶段,遇到循环依赖必死。
这里有个容易忽略的细节:@Bean方法的 bean definition 里,autowireMode会被设为AbstractBeanDefinition.AUTOWIRE_CONSTRUCTOR。换句话说,Spring 根本就是把@Bean方法当作一个"构造函数"来看待的。所以它在循环依赖问题上的行为和构造器注入完全一致——这也是为什么很多人把@Bean参数循环跟构造器循环归为一类,本质上就是同一个问题。
3.2 @Bean 方法体内调用另一个 @Bean 方法:同样踩雷
有人会想:那我不用参数注入,改成方法体内手动调用另一个@Bean方法行不行?
@Configuration public class BizConfig { @Bean public OrderQueryService orderQueryService() { return new OrderQueryService(orderExecuteService()); } @Bean public OrderExecuteService orderExecuteService() { return new OrderExecuteService(orderQueryService()); } }答案是要分情况。
如果BizConfig是被 CGLIB 代理的完整@Configuration(默认proxyBeanMethods = true),那么配置类里的@Bean方法会被代理拦截,方法体内调用orderExecuteService()时,并不会直接执行原始方法体,而是通过容器getBean("orderExecuteService")获取。这本质上还是走 Spring 容器,两个方法互相调用,创建期依赖依然发生在createBeanInstance阶段,循环依赖照样报错。
如果你把配置类改成proxyBeanMethods = false:
@Configuration(proxyBeanMethods = false) public class BizConfig { ... }那就完全是另一个故事了——方法体内的@Bean方法调用变成普通 Java 方法调用,不再经过容器。此时两个方法互相调用,会直接在 Java 栈上无限递归,最终抛StackOverflowError,而不是 Spring 的循环依赖异常。业务上还会出现单例失效:每次调用@Bean方法都执行原始方法体,返回全新实例。
所以这种"方法体内互相调用"的做法,在完整配置类下解决不了循环依赖,在非代理配置类下则会变成一个更原始的问题。出路还是别让两个@Bean方法互相引用。
3.3 静态工厂方法(BeanDefinition 注册):本质一致
还有一种"工厂方法",不是写在@Configuration里,而是通过BeanDefinition指定静态工厂方法。比如:
BeanDefinitionBuilder.genericBeanDefinition(OrderFactory.class) .setFactoryMethodName("createQueryService") .addConstructorArgValue(orderExecuteService) // 依赖另一个 Bean .getBeanDefinition();或者 XML 时代常见的写法:
<bean id="orderQueryService" class="com.example.OrderFactory" factory-method="createQueryService"> <constructor-arg ref="orderExecuteService"/> </bean>只要工厂方法需要接收参数、参数又依赖回当前的 Bean,循环依赖就走不通。因为静态工厂方法的参数同样由ConstructorResolver解析,同样发生在createBeanInstance阶段。不要以为换成"静态工厂方法"就能躲过三级缓存的边界问题,它和@Bean方法参数在创建链路上的位置一模一样。
不过有一点可以区分:如果静态工厂方法不需要任何参数,只是单纯用工厂方法替代构造器来拿到一个 Bean,那它根本不会产生参数级循环依赖。循环依赖必须要有"依赖"存在,没有依赖引用就没有环。
3.4 附带提一下 FactoryBean:它也不是避风港
FactoryBean是另一种"工厂"形态,很多人以为它够特殊,可以绕开循环依赖。实际上它也不是绝对安全。
FactoryBean本身是一个单例 Bean,创建它的时候走的还是doCreateBean流程。如果FactoryBean的成员变量里注入了另一个 Bean A,而 A 又依赖FactoryBean生成的目标类型,那么在创建FactoryBean的过程中,A 的创建会反向触达FactoryBean生成的目标 Bean。此时FactoryBean可能还在singletonsCurrentlyInCreation名单里,getBean(targetName)依然可能触发创建期死锁。
即使FactoryBean自身没有属性循环,只是在getObject()内部去容器拿依赖,你也要非常小心:getObject()的调用时机、目标 Bean 的缓存位置都和普通@Bean方法不同。它可以作为解决问题的一种手段,但不是"万能钥匙"。
3.5 行为对比表
把几种形态放在一起看,结论更清晰:
| 创建方式 | 依赖解析时机 | 参数/依赖回环时 | 三级缓存能否救 |
|---|---|---|---|
| @Bean 方法参数注入 | 实例化阶段(工厂方法调用前) | 未注册三级缓存 | 不能救 |
| @Bean 方法体内调用另一 @Bean(proxyBeanMethods=true) | 实例化阶段(工厂方法执行中) | 未注册三级缓存 | 不能救 |
| @Bean 方法体内调用另一 @Bean(proxyBeanMethods=false) | 无容器介入 | Java 栈递归 | 超出 Spring 范围 |
| 静态工厂方法 + 构造参数 | 实例化阶段(工厂方法调用前) | 未注册三级缓存 | 不能救 |
| FactoryBean.getObject() 内获取依赖 | FactoryBean 实例化后,目标 Bean 获取时 | 视是否反向依赖而定 | 有限场景可用 |
| setter / 字段注入 | 属性填充阶段(已注册三级缓存) | 已注册三级缓存 | 默认可救(Boot 2.6+ 需开开关) |
| 构造器注入 | 实例化阶段(构造器调用前) | 未注册三级缓存 | 不能救 |
最核心的分界线就是一句大白话:依赖发生在"实例化前/中"还是"属性填充时"。前者是死结,后者才有的谈。
4. 从应急到根治:工厂方法循环依赖的解决路径
前面把原理和形态讲透了,剩下的就是动手解决。我的建议是先做应急(让项目跑起来),再做根治(让依赖关系健康)。
4.1 @Lazy:把创建期依赖推迟到使用期
最快速的应急方案,是在导致循环的@Bean方法参数上加@Lazy:
@Configuration public class BizConfig { @Bean public OrderQueryService orderQueryService(@Lazy OrderExecuteService executeService) { return new OrderQueryService(executeService); } @Bean public OrderExecuteService orderExecuteService(OrderQueryService queryService) { return new OrderExecuteService(queryService); } }@Lazy在参数上生效时,Spring 不会在解析工厂方法参数时真正去创建OrderExecuteService,而是注入一个懒加载代理对象。代理里面暂时没有真实引用,等到OrderQueryService真正调用executeService的某个方法时,才会触发getBean("orderExecuteService")。这时候orderQueryService已经创建完成,循环链条被打断,容器可以正常启动。
用这个方案有几个坑要提醒:
- 懒代理和真实对象不是同一个引用,做
==比较、getClass()判断、强转具体类时都可能出问题。 - 被代理的类尽量不要有
final方法,否则代理无法正常拦截。 - 调用链如果很早就触发代理方法,那么"创建期循环"只是被推到了运行时,仍然可能变成运行时依赖报错。
所以@Lazy对我来说是"应急"方案,不是"根治"方案。它适合快速解障,但依赖关系本身依然是环,只是藏到了运行时。
4.2 ObjectProvider:更可控的延迟句柄
比@Lazy稍微"清醒"一点的方案是ObjectProvider,它是 Spring 5.1 开始大力推荐的依赖选择器:
@Configuration public class BizConfig { @Bean public OrderQueryService orderQueryService(ObjectProvider<OrderExecuteService> executeServiceProvider) { return new OrderQueryService(executeServiceProvider); } @Bean public OrderExecuteService orderExecuteService(ObjectProvider<OrderQueryService> queryServiceProvider) { return new OrderExecuteService(queryServiceProvider); } }ObjectProvider本身是一个"句柄"对象,注入它不需要立刻解析具体依赖;你在代码里通过getObject()、getIfAvailable()、getIfUnique()在合适的时机获取实际 Bean 即可。相比@Lazy代理,它有几个优势:
- 可以通过
getIfAvailable()做存在性判断,应对"可能没有 Bean"的场景。 - 可以做多候选筛选,比如
ifUnique。 - 解析时机完全由业务代码控制,不会自动触发代理方法,语义更直观。
代价是业务类里多了一个ObjectProvider包装,调用处要显式getObject(),代码可读性稍微打折。
4.3 打破环路的架构调整
真正想根治循环依赖,调整结构才是最健康的方式。利落地剪断一条依赖,永远比在环上做手脚更省心。
常见做法是单向化:让其中一个 Bean 不再直接依赖另一个。比如OrderQueryService需要OrderExecuteService的能力,可以反过来由OrderExecuteService持有OrderQueryService,而OrderQueryService通过事件、回调、或者把依赖下沉到一个公共子模块来解耦。
另一种思路是"中间层":把两个 Service 共同依赖的逻辑抽到一个新的 Service 或组件里,两边都只依赖中间层,不再互依。代价是多一个类,代码结构会清爽很多。
如果只是局部需要对方的某个能力,也不妨考虑"方法参数传递":把一个 Bean 的方法编写成接收外部传入的协作对象,而不是在字段/构造器里持有对方。依赖从"长期持有"变成"用时即取",环路自然消失。
4.4 总开关 allow-circular-references 到底管什么
回到 Spring Boot 2.6 之后的默认行为。配置里最常见的开关:
spring.main.allow-circular-references=true它的作用是设置AbstractAutowireCapableBeanFactory.allowCircularReferences,决定doCreateBean里earlySingletonExposure是否成立。如果开关是 false,那么即使属性填充阶段的循环依赖,也不会注册三级缓存,直接报循环依赖异常。
但回到我们文章的标题场景——工厂方法创建 Bean 时的参数循环依赖——这个开关根本来不及起作用。因为工厂方法解析参数发生在createBeanInstance,而allowCircularReferences影响的是createBeanInstance之后的二级设定。开关打开,只是让"属性填充阶段的循环依赖"有了被三级缓存处理的机会,它救不了"实例化阶段就回环"的死结。
所以别再一遇到循环依赖就开这个开关了。先判断你的循环发生在哪个阶段,再决定要不要用开关、怎么用@Lazy或ObjectProvider。
4.5 一条实用的排查步骤
总结一套我自己常用的排查链路,遇到 Spring Boot 循环依赖异常时可以按顺序走:
- 看异常类型:
UnsatisfiedDependencyException+BeanCurrentlyInCreationException大概率是构造器/工厂方法参数循环;BeanCreationNotAllowedException通常是容器关闭或显式循环引用限制。 - 读堆栈里的方法签名:
expressed through method 'xxx' parameter 0直接告诉你哪个@Bean方法的参数在回环。 - 画出依赖图:把涉及循环的 Bean 和依赖方向记录下来,找到哪个依赖是"创建期依赖"(构造器参数、工厂方法参数、静态工厂方法参数),哪个是"属性期依赖"(字段/setter)。
- 优先剪断创建期依赖:给工厂方法参数加
@Lazy或换成ObjectProvider,先让项目启动起来。 - 评估是否根治:启动成功后,回头审视两个 Bean 的职责边界。如果长期互相调用,考虑抽取中间层、回调或事件。
- 最后才碰全局开关:除非你确认循环只存在于属性填充阶段而且项目就是想让循环存在,否则别让
spring.main.allow-circular-references=true成为默认配置。
我自己实际操作的经验是,90% 的工厂方法循环依赖在画完依赖图之后,都能找到一个"看似便利、实则多余"的反向依赖。把这个反向依赖剪掉,比任何技术技巧都省心。@Lazy和ObjectProvider是我用来救火的工具,但每次用完我都要追问一句:这个环是不是本来就不该存在?如果答案是"该",那我就把它拆了,而不是留着隐患。