Spring Boot工厂方法循环依赖真相:三级缓存为何救不了你
1. 从一个启动报错说起工厂方法把循环依赖藏得更深去年我在做一个多模块项目拆分时把一些公共组件从原来的服务里抽出来为了让组装逻辑集中好维护写了一个Configuration配置类里面用Bean工厂方法依次创建几个核心服务。结果应用一启动控制台直接给出一段循环依赖报错serviceA - serviceB - serviceA。我第一反应是“不对啊Spring 不是有三级缓存能解决循环依赖吗”但真正查下去才发现问题恰好出在“通过Bean工厂方法创建 Bean”这条路径上而三级缓存在这条路径里根本来不及发挥作用。这也是这篇文章想聊透的事Spring Boot 里当一个 Bean 是由工厂方法创建时循环依赖的表现形式和普通字段注入完全不一样。很多老程序员一听到“循环依赖”就条件反射地想到“三级缓存可以解决”但这个结论只在特定注入方式下成立。工厂方法参数组成的环既不像构造器注入那样一看就明白必死也不像字段注入那样能被三级缓存兜住它处在中间一个很容易被误解的位置。这篇文章适合正在排查启动异常的人也适合那些准备把多个Bean配置类组合起来、但对 Spring 内部单例创建时序还不熟悉的开发者。你能看到普通单例 Bean 的完整创建链路看到工厂方法创建 Bean 的真实执行时序也会看到几个可以直接抄的解决方案。看完后至少以后遇到类似报错能一眼判断出是哪条路径引起的而不是对着三级缓存的理论疯狂挠头。2. 普通单例 Bean 的生命周期与三级缓存机制在分析工厂方法之前必须先把 Spring 创建普通单例 Bean 的链路弄明白。这一节的内容是后面所有讨论的地基也是很多人对“循环依赖”产生误解的来源。2.1 一次正常的单例 Bean 创建流程以getBean为入口Spring 创建单例 Bean 的核心逻辑在AbstractBeanFactory.doGetBean里。首次获取某个单例 Bean 时容器会先查一级缓存singletonObjects没命中就开始创建流程最终调用AbstractAutowireCapableBeanFactory.doCreateBean。doCreateBean里有个很要命的顺序先执行createBeanInstance实例化 Bean再判断是否需要“提前暴露”如果需要就调用addSingletonFactory把当前 Bean 的早期引用生成器放进三级缓存然后才执行populateBean填充属性、initializeBean执行初始化。用一句话概括就是先让 Bean 出生再让它交朋友。这个“出生”和“交朋友”的先后顺序直接决定了循环依赖能不能被解决。注意这里的“实例化完成”并不等于“初始化完成”更不等于“属性填充完成”。实例化只是把对象内存创建出来Spring 源码里对应createBeanInstance这一步。对象只要被 new 出来哪怕字段全是 null也有了加入三级缓存、提前暴露的资格。2.2 三级缓存分别存什么Spring 解决循环依赖的核心是三组 Map都在DefaultSingletonBeanRegistry里初学者一听到“三级缓存”就觉得很高深其实拆开看很简单。缓存数据类型保存内容写入时机读取场景singletonObjectsMapString, Object完整初始化后的单例 BeanBean 初始化完成常规getBeanearlySingletonObjectsMapString, Object提前暴露的原始 Bean 或代理对象从三级缓存取出后循环依赖中读取早期引用singletonFactoriesMapString, ObjectFactory?生成早期引用的工厂实例化完成之后循环依赖首次读取一级缓存好理解就是最终能用的成品。二级缓存和三级缓存的关系要稍微绕一下三级缓存里存的不是对象而是一个ObjectFactory调用它的getObject()方法时会走到getEarlyBeanReference。为什么不能直接把对象放进二级缓存因为 Spring 需要延迟决定“这个早期引用到底要不要提前生成 AOP 代理”。如果直接放原始对象等到后面发现需要代理早期引用就永远是裸对象了如果直接放代理对象又可能代理时机太早。所以三级缓存存一个工厂等真正有人因为循环依赖来拿早期引用时再现场判断和生成。这也是理解循环依赖的核心心法之一三级缓存不是单纯的“缓存对象”而是缓存了一个“按需加工对象”的工厂。2.3 字段注入为什么天然能解决循环依赖经典场景是 A 字段注入 BB 字段注入 A。这个场景能跑通完全是因为时序安排A 先实例化完成立刻把自己放进三级缓存然后populateBean去填充 A 的字段发现需要 B于是getBean(B)B 开始创建B 实例化完成后也被放进三级缓存填充字段时发现需要 A于是getBean(A)。此时 A 的一级缓存里没有但三级缓存里已经有 A 的ObjectFactory了。Spring 会从三级缓存取出工厂调用getObject()生成早期引用放进二级缓存然后把这个早期引用交给 B 使用。B 完成构造后继续初始化最终完成A 再继续自己的初始化最终也完成。整个过程环环相扣能跑通的核心原因只有一个A 在需要被引用之前已经完成了实例化具备提前暴露的资格。我遇到过很多刚学 Spring 的人以为字段注入“自动解决了循环依赖”其实不是字段注入本身有魔法而是字段注入发生在实例化之后。真正解决问题的是“实例化先发生、依赖注入后发生”这个时序。2.4 构造器注入循环为什么必炸如果 A 和 B 都通过构造器注入对方情况就完全不同了。创建 A 时createBeanInstance要调用 A 的构造器构造器参数需要 B于是去创建 BB 的构造器参数需要 A于是回来创建 A。这时候 A 连构造函数都没执行完对象都没 new 出来自然不可能进入三级缓存。getBean(A)会发现“A 正在创建中”但没有早期引用可用于是抛BeanCurrentlyInCreationException。所以判定循环依赖可不可解其实有一个非常简单的标准当前 Bean 实例化有没有完成有没有资格进入三级缓存。有资格就有救没资格三级缓存再大也救不了你。3. Bean工厂方法创建 Bean 的执行时序现在回到正题。为什么要单独把Bean工厂方法拎出来讲因为它的“实例化”过程和其他 Bean 不一样而这个差异恰恰是循环依赖问题的命门。3.1 配置类先被实例化Bean方法被“包装”使用Bean时我们先写一个配置类比如Configuration修饰的类。Spring 启动时ConfigurationClassPostProcessor会处理配置类解析里面的Bean方法为每个方法生成对应的BeanDefinition。这类BeanDefinition的beanClass是配置类本身factoryMethodName是方法名也就是说真正要创建的 Bean 是“配置类实例上调用某个方法后的返回值”。full 模式的配置类还会被 CGLIB 增强生成代理对象代理Bean方法。这样做的目的之一是保证Bean方法返回的 Bean 是单例你直接调用this.serviceB()时代理会拦截并改成从容器里getBean。很多文章讲Bean时会提到这个代理机制但很少把它和循环依赖挂上钩。配置类本身也是单例 Bean也会在容器启动阶段被创建。配置类通常没有复杂的依赖注入所以能顺利实例化。真正出问题的是后续执行Bean方法创建目标 Bean 时。3.2 工厂方法创建实例的步骤细化当一个 BeanDefinition 标注了工厂方法Spring 在createBeanInstance阶段会走ConstructorResolver.instantiateUsingFactoryMethod。这个过程和普通 Bean 直接new不一样大致分三步第一步解析工厂方法参数。Bean方法的参数会被当成依赖项Spring 会逐个调用getBean获取参数实例。比如serviceA(ServiceB serviceB)在调用serviceA()方法前Spring 必须先拿到一个 ServiceB 实例。第二步调用工厂方法本身。参数齐了才真正调用配置类实例上的那个方法拿到返回的对象这个对象就是当前 Bean 的“实例化结果”。第三步回到doCreateBean流程。只有方法执行完、有了真正的返回对象之后Spring 才会执行addSingletonFactory把早期引用暴露出去然后再进入属性填充、初始化等阶段。关键在于第三步的时机早期暴露发生在工厂方法调用之后。而工厂方法的参数解析却发生在工厂方法调用之前。这中间多出来了一段“必须先交朋友才有资格出生”的流程和构造器注入的处境非常相似。3.3 关键差异参数解析发生在早期暴露之前拿最常见的字段注入场景对比普通 Bean 是“先 new 出来、加入三级缓存、再注入依赖”所以别人需要它时它可以提前露个脸。而工厂方法 Bean 是“先解析参数、再调用方法得到实例、最后加入三级缓存”参数解析阶段如果有人反过头来要它它连加入三级缓存的资格都没有。我有一个比喻普通字段注入的 Bean 是“先出生再交朋友”一群小孩排队交朋友谁先出生谁就能先站在操场上被大家看见。工厂方法创建 Bean 相当于“必须交到指定的朋友才能出生”A 要等 B 来才能出生B 要等 A 来才能出生结果两个人都在产房门口干等谁也生不出来。所以工厂方法路径下的循环依赖本质上和构造器循环依赖是同一类问题依赖关系发生在“实例化完成”之前。三级缓存的提前暴露机制在这里根本还没来得及启动。光记住“Spring 能解决循环依赖”这个结论却不知道这个结论的适用边界迟早会在Bean参数环上栽跟头。4. 一个能稳定复现的工厂方法循环依赖案例理论说再多不如一个能稳定复现的例子直观。下面这个配置类是我用来复现问题的最小样例你复制到 Spring Boot 项目里就能看到同样的报错。4.1 最小复现代码Configuration public class CircularConfig { Bean public ServiceA serviceA(ServiceB serviceB) { return new ServiceA(serviceB); } Bean public ServiceB serviceB(ServiceA serviceA) { return new ServiceB(serviceA); } }ServiceA 和 ServiceB 就是两个普通类构造器接收对方作为参数不再需要多余的业务代码。只要把这个配置类放进 Spring Boot 的扫描路径应用启动就会失败。这个例子里两个 Bean 的生产者都是配置类上的工厂方法依赖关系通过方法参数表达。表面上看起来好像只是“serviceA 依赖 serviceBserviceB 依赖 serviceA”但创建过程会变成创建 serviceA - 解析方法参数需要 serviceB - 创建 serviceB - 解析方法参数需要 serviceA - 但 serviceA 还卡在参数解析阶段没有进入三级缓存 - 报错。4.2 报错现场完整解读如果你用的是 Spring Boot 2.6 及以上版本启动失败时通常会看到类似这样的描述*************************** APPLICATION FAILED TO START *************************** Description: The dependencies of some of the beans in the application context form a cycle: serviceA - serviceB - serviceASpring Boot 会直接帮你找出循环链并提示你检查相关 Bean 的相互依赖。如果你使用的是 Spring Boot 2.3、2.4 这类更早的版本或者手动把循环引用开关打开了那么会在运行时阶段看到另一类异常BeanCurrentlyInCreationException: Error creating bean with name serviceB: Requested bean is currently in creation: Is there an unresolvable circular reference?两种报错的共同点是都指向同一个事实容器发现 serviceB 正在创建中又要创建它而且拿不到任何早期引用无法让环继续转下去。区别只是 2.6 后 Spring Boot 在启动早期就做了依赖图分析提前把问题暴露出来而更早的版本是在真正创建 Bean 时废掉。这里还要特别提醒一点Spring Boot 2.6 开始默认禁止循环依赖但即使你把spring.main.allow-circular-references设置成true工厂方法参数组成的环依然会挂掉。因为这个开关只开放了“允许提前暴露引用”的权限工厂方法场景下连提前暴露的机会都没有。这个细节很容易踩错很多人以为打开开关就万事大吉了结果开了反而一脸懵。4.3 为什么三级缓存这次救不了你源码上继续深挖。普通 Bean 之所以能救是因为它走完了createBeanInstance后立刻执行addSingletonFactory。工厂方法 Bean 的createBeanInstance内部必须先把方法参数解析完、把工厂方法调用完拿到返回值后才会控制权交还给doCreateBean随后才有机会进入addSingletonFactory。也就是说当 serviceA 的工厂方法在等待 serviceB 时serviceA 既不在一级缓存也不在二级缓存更不在三级缓存。它处于一种“Spring 知道它在创建中但没有给外界留下任何获取它早期引用的入口”的状态。此时 serviceB 的创建过程中回头要 serviceASpring 能做的只有抛出“当前正在创建”的异常。用前面那句判断标准来套循环依赖能不能解就看当前 Bean 有没有资格进入三级缓存。工厂方法参数解析阶段Bean 的实例还没产生天然没有资格。4.4 一个容易混淆的兄弟场景方法体内自调用还有一种写法也经常引起困惑就是工厂方法内部直接调用另一个Bean方法Configuration public class CircularConfig { Bean public ServiceA serviceA() { return new ServiceA(serviceB()); } Bean public ServiceB serviceB() { return new ServiceB(serviceA()); } }这个代码同样是循环依赖而且报错逻辑也相同执行serviceA()工厂方法时方法体内调用了serviceB()由于配置类被 CGLIB 代理这个调用会被拦截并转成getBean(serviceB)于是开始创建 serviceBserviceB()方法体内调用serviceA()同样被转成getBean(serviceA)此时 serviceA 还在工厂方法执行过程中没有加入三级缓存直接失败。很多人在代码里大量使用这种“方法内直接调用”的写法其实它和“方法参数注入”在循环依赖这一层面是同一个问题。唯一让我觉得没那么明显的原因是代理机制让方法调用看起来很像普通 Java 方法调用排查时反而不容易一眼看穿。4.5 和其他注入方式的对照依赖产生方式实例化完成时点能否提前暴露循环依赖可解性构造器参数构造器执行时否不可解直接异常字段/setter 注入实例化之后、填充属性时是三级缓存可解需允许循环引用Bean 工厂方法参数工厂方法调用之后否不可解直接异常工厂方法体内自调用方法执行过程中否不可解直接异常这张表背后的逻辑是一致的依赖发生在实例化完成之前就会炸发生在实例化完成之后就有机会被三级缓存救活。工厂方法参数属于前一种。5. 四个能落地的解决思路问题确认后重点自然转向怎么解决。下面四个方案里前三个是“能跑”的临时方案第四个是我个人更推荐的长期方案按场景取舍。5.1 把工厂方法参数改成字段注入最快的救火方案是让参与循环的其中一个 Bean 不要再通过工厂方法参数获取依赖改成普通的Autowired字段注入。比如把 ServiceA 从配置类里的Bean改成用Component或Service注册内部直接Service public class ServiceA { Autowired private ServiceB serviceB; }这时 ServiceA 走的是普通实例化路径先 new 出来加入三级缓存再填充字段注入 ServiceB。ServiceB 如果还是通过工厂方法参数注入 ServiceA创建 ServiceB 时就能从三级缓存拿到 ServiceA 的早期引用整个环可以继续转。要注意两个前提。第一Spring Boot 2.6 及以上版本默认不允许循环依赖只要参与循环的 Bean 有多个就会被启动检测拦下来。你需要在application.properties里显式设置spring.main.allow-circular-referencestrue。第二字段注入本身是一种被很多团队规范否定的写法因为它让依赖关系变得不直观、不利于测试。如果项目里字段注入是允许的这个方案可以接受如果团队强制构造器注入那这个方案可能比循环依赖本身更让人头疼。我不太建议把这种改法当成终极答案因为它本质上是“放弃工厂方法绕过问题”而不是“解决问题”但对一个正在出故障的线上项目来说有时它是最快能恢复的动作。5.2 Lazy延迟注入代理先顶上去保留工厂方法参数但给其中一个参数加上Lazy注解是一个更标准的技术解法Configuration public class CircularConfig { Bean public ServiceA serviceA(Lazy ServiceB serviceB) { return new ServiceA(serviceB); } Bean public ServiceB serviceB(ServiceA serviceA) { return new ServiceB(serviceA); } }原理是Spring 遇到Lazy标注的依赖时不再立即去getBean真实对象而是注入一个代理对象。创建 ServiceA 时ServiceB 只是一个代理方法不需要执行参数解析可以顺利完成ServiceA 成功创建并注册。等到代码真正调用 ServiceB 上的业务方法时代理才会触发真实的getBean此时容器里通常已经有完整的 ServiceB 了。这个方案的好处是不改变 Bean 的定义方式工厂方法结构还在依赖图看起来也很直观。副作用是调试时不太舒服你在断点里看到的参数对象是一个 CGLIB 代理不是 ServiceB 本身对象字段的值全是 null第一次遇到的人很容易以为自己注入失败了。另外Lazy代理对目标类型有限制如果 ServiceB 是 final 类CGLIB 代理可能会遇到麻烦实际工作中我用它时基本都选择接口类型的目标对象。如果项目里两个 Bean 互相依赖但确实历史遗留没法大改Lazy是我通常会优先选的兜底方案改动最小效果最稳。5.3 ObjectProvider延迟获取灵活但容易误用还有一种延迟注入的思路是使用ObjectProviderBean public ServiceA serviceA(ObjectProviderServiceB serviceBProvider) { return new ServiceA(serviceBProvider); }ObjectProvider本身是一个延迟获取的容器注入它并不会触发 ServiceB 的创建。ServiceA 内部需要真正使用 ServiceB 时再调用serviceBProvider.getObject()。这里有一个非常容易踩的坑如果你在工厂方法体内立刻调用getObject()比如Bean public ServiceA serviceA(ObjectProviderServiceB serviceBProvider) { return new ServiceA(serviceBProvider.getObject()); }那和直接注入 ServiceB 没有任何区别因为getObject()方法内部就是getBean它又会在 ServiceA 还没暴露早期引用的时候去创建 ServiceB循环照旧炸掉。ObjectProvider的正确使用姿势是把它保存下来等 ServiceA 完全创建完成或者至少等容器对 ServiceB 的创建不会再回头卡死 ServiceA 时再去获取。这个方案的好处是语义清晰调用方明确知道依赖是“按需拿”的。缺点是要求业务代码里不能太早急于获取依赖对调用时机有隐性要求团队里如果有人不熟悉这个机制很容易当作普通依赖直接调用。5.4 消除环的架构级整改最推荐的方案聊到最后一个方案可能有的人不爱听但这就是事实两个 Singleton Bean 互相依赖大多数情况下是设计上出现了坏味道无论是字段注入、构造器注入还是工厂方法参数去解决它都属于治标。真正的长期做法是打破这个环。最常见的思路是看依赖方向ServiceA 和 ServiceB 谁更接近基础能力谁更接近业务组装。比如 ServiceB 依赖于 ServiceA那大概率 ServiceA 是底层组件ServiceB 是上层业务组件上层依赖底层是合理的反过来就不合理。这时可以把 ServiceA 中依赖于 ServiceB 的逻辑拆分出去或者把两者共同依赖的公共部分提炼成第三个组件让两个 Bean 都只依赖它。另外可以考虑中间层解耦例如引入一个事件机制或者回调接口。ServiceA 不再直接持有 ServiceB而是发布事件、注册监听ServiceB 在需要的时候响应。这种改动看起来更大但换来的是清晰的依赖方向后续维护和测试都会轻松得多。我在实际项目里还用过ArchUnit这种依赖分析工具在单元测试里断言“配置层 Bean 之间不允许存在相互依赖”把这类问题直接挡在 CI 阶段比在运行时报错后再救火高效多了。6. 排查技巧与避坑清单最后这节完全是实战经验是我自己排查这类问题摸索出来的几个技巧以及整理的一些容易踩的坑。6.1 读懂BeanCurrentlyInCreationException的两种形态遇到循环依赖报错消息可能有几种味道。Spring Boot 2.6 之后的启动期检测会直接给出循环链这种最好排查。Spring Boot 2.6 之前或绕过开关后会出现BeanCurrentlyInCreationException消息里通常写着“Requested bean is currently in creation: Is there an unresolvable circular reference?”。更早的 Spring 版本还有一类消息大意是“某个 Bean 已被注入到另一些 Bean 的 raw 版本中但它最终被包装了”这种往往和 AOP 代理叠加代表早期引用与最终 Bean 不一致虽然和参数环不完全一样但排查思路可以通用。看到这些关键字时第一件事不是看代码逻辑而是把 Spring 的 Bean 创建顺序日志打开logging.level.org.springframework.beans.factory.supportdebug日志里会按时间顺序打印每个 Bean 的创建起点和完成情况。对照着看你能非常直观地发现“serviceA 开始创建 - 为了参数开始创建 serviceB - serviceB 又开始要 serviceA - 失败”整个过程一目了然。6.2 用断点定位循环依赖的绝招日志不够细致时我习惯直接在DefaultSingletonBeanRegistry的getSingleton(String beanName, boolean allowEarlyReference)方法打条件断点条件是beanName.equals(serviceA)之类。进入断点后观察三个 Map 的状态singletonObjects里有没有earlySingletonObjects里有没有singletonFactories里有没有。这一步能直接告诉你“当前 Bean 到底有没有资格被提前获取”。如果在工厂方法参数解析阶段打进去会看到 serviceA 连singletonFactories都还没写入这就坐实了“实例化未完成导致无法提前暴露”的判断。这个断点在面试里也常被拿来问自己能亲手走一遍比背源码印象深得多。6.3 Spring Boot 2.6之后的行为变化Spring Boot 2.6 是一个分水岭。之前版本循环依赖默认可运行很多老项目里字段注入互相引用早就跑习惯了大家对循环依赖也没什么警惕。2.6 开始官方默认禁止循环依赖启动时直接失败这让很多人升级版本后突然遇到一堆“之前能跑现在跑不了”的问题。需要特别强调一个容易误会的地方spring.main.allow-circular-referencestrue只是恢复 Spring Framework 里allowCircularReferences的默认行为代表“循环引用时允许提前暴露早期引用”。对于普通字段注入环这个开关是有效的但如果你遇到的是工厂方法参数环开不开都一样报错。判断问题类型时不要只盯着这个开关要回到“实例化完成没有”这个根上。6.4 几个容易误伤的操作最后分享几条实操避坑经验。第一不要指望DependsOn能解决环它只控制创建顺序而闭环里的 Bean 无论怎么排序总有一个会回头要还没创建完成的另一个。第二配置类自身不要写得过于复杂不要在配置类的构造器里调用Bean方法获取实例配置类实例化阶段容器还没完全就绪很容易引起连锁问题。第三Configuration(proxyBeanMethods false)并不能豁免循环依赖它只是取消了 CGLIB 代理方法内自调用反而可能变成每次都 new 新对象行为更难预测。第四如果是静态工厂方法或自定义FactoryBean创建 Bean出现循环依赖的机制和Bean本质一致都是“先参数解析再实例化”排查思路可以通用。这里还要说一个我自己的经验教训当你在同一个Configuration里看到一大堆互相调用的Bean方法时这往往不是循环依赖问题而是配置类在设计上已经超载了。一个配置类只负责一个模块的组装方法参数只依赖模块外部的稳定组件模块内部的组件之间用普通 Spring 组件注入这样的结构基本不会出现工厂方法循环。聊到最后回到我开头那个项目。那次排查花费了大半个下午最后解决方案很简单把两个互相依赖的服务重新划分了职责其中一方不再需要持有另一方环自然消失了。事后回看真正让我印象深刻的不是某个注解的用法而是对“实例化时机”这个概念的彻底理解。很多循环依赖问题拿着三级缓存的理论到处套套不上就觉得框架有问题其实是没分清 Bean 是通过什么方式被创建出来的。希望这篇内容能帮你在排查时少绕一点弯路。