恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring工厂方法循环依赖解析:三级缓存与BeanCurrentlyInCreationException实战排查
首页
资讯中心
/
Spring工厂方法循环依赖解析:三级缓存与BeanCurrentlyInCreationException实战排查
Spring工厂方法循环依赖解析:三级缓存与BeanCurrentlyInCreationException实战排查
发布时间:2026/10/10 9:35:32
那段时间我一直在排查一个奇怪的启动失败问题Spring Boot 2.6 升级之后应用一启动就抛BeanCurrentlyInCreationException日志里明明写着循环引用可代码里翻来覆去都是干净整洁的构造器注入一点多余依赖都没有。后来才意识到问题出在那些用Bean工厂方法创建的对象身上——常规的字段循环依赖和工厂方法创建 Bean 时的循环依赖在 Spring 的执行路径上根本不是一回事。这篇文章就围绕“工厂方法创建 Bean 时的循环依赖”这个场景展开把三级缓存、Bean方法参数注入、FactoryBean、静态工厂方法、Spring Boot 2.6 默认禁止循环依赖这些点串成一条线解释为什么有些循环能自愈有些循环直接启动失败以及遇到之后应该怎么设计才不会再踩。适合对 Spring Bean 生命周期有一定了解但遇到实际循环依赖不会定位的读者。1. 循环依赖的本质与Spring的三级缓存防线1.1 什么是循环依赖什么情况下Spring能自愈循环依赖指的是两个或多个 Bean 在创建过程中互相需要对方。最常见的情况就是 A 依赖 BB 依赖 A。从代码角度看A 的构造方法或者字段里需要 BB 的构造方法或者字段里又需要 A两者在创建时就会形成环。用生活常识类比一下有点像两个人约着一起出门都想着“等对方先出门我拿钥匙锁门”结果两人都待在屋里等对方谁也没法先迈出那一步。Spring 作为一个容器平时一个人干活很利索可一旦遇到这种互相等待的局面就得靠一套策略来打破僵局。Spring 能自愈的循环依赖有一个比较关键的前提循环发生在 Bean 实例化之后、属性填充阶段。也就是 A 已经被 new 出来了只是属性还没全部注入此时 B 创建过程中需要 ASpring 可以先把 A 这个“半成品”交给 B 用。这个手段在三缓存体现得特别明显。反过来如果循环发生在构造器阶段两个 Bean 都还没 new 出来那就没有半成品可言Spring 也只能直接抛出异常。从 Spring 的官方立场来看构造器注入场景下的循环依赖是无解的官方建议从一开始就避免。但现实项目里代码量一大依赖关系图就变得复杂加上工厂方法这种间接创建 Bean 的入口原本能自愈的循环也可能走着走着就从可解变成无解。1.2 三级缓存的运作机制从半成品到成品先看三级缓存是哪三级它们都定义在DefaultSingletonBeanRegistry里。缓存层级名称存放内容一级缓存singletonObjects创建完成的成品单例 Bean二级缓存earlySingletonObjects提前暴露的半成品 Bean三级缓存singletonFactories生成提前暴露 Bean 的 ObjectFactory三级的真正含义可以这么理解Spring 在创建单例 Bean 时会按顺序经历实例化、属性填充、初始化这几个步骤。实例化完成之后如果确定自己是单例且允许循环引用Spring 会把一个 ObjectFactory 放进三级缓存。这个 ObjectFactory 真正的逻辑在getEarlyBeanReference方法中也就是当别的 Bean 需要当前 Bean 的早期引用时通过这个工厂生成一个半成品对象。那为什么要用三级而不是两级这就要提到 AOP 代理了。如果直接用二级缓存存放提前创建的原始对象那么在 AOP 生效的场景下后面 BeanPostProcessor 生成的代理对象和之前已经注入给依赖方的原始对象就会不一致。Spring 的做法是不到万不得已不生成早期引用等有人真的需要时通过三级缓存的 ObjectFactory 触发getEarlyBeanReference让 SmartInstantiationAwareBeanPostProcessor 有机会对早期引用做处理比如提前套上代理。这样既不会给所有 Bean 都做无谓的代理又能保证循环引用时的依赖方拿到的对象是正确的。工厂方法创建的 Bean 同样会走这套流程。Bean方法执行完返回一个对象实例这个实例随后进入doCreateBean流程经历 addSingletonFactory、populateBean、initializeBean 这些步骤。所以单看缓存机制工厂方法创建的 Bean 和普通类定义的 Bean 并没有本质区别区别在于触发创建的时机和参数解析的位置。1.3 场景代入工厂方法在三级缓存中的角色这里有个实践中容易被忽略的关键点Bean工厂方法实例化 Bean 的过程和普通类通过构造器实例化在时间线上并不完全一致。普通类 Bean 进入doCreateBean后第一件事是实例化。这个实例化完成后Spring 才有机会把 ObjectFactory 放入三级缓存。而Bean工厂方法的实例化是通过instantiateUsingFactoryMethod实现的这个阶段会先解析方法参数每个参数都会被当作一个待解析依赖触发resolveDependency进而调用getBean。也就是说工厂方法参数的依赖解析发生在本 Bean 进入三级缓存之前。这一点对判断循环依赖会不会报错极其关键。如果两个 Bean 都通过Bean方法互相注入对方为参数A 在解析参数时触发 B 的创建B 在解析参数时触发 A 的创建而 A 此时还没把自己的工厂对象放进三级缓存B 想找 A 的半成品也找不到最终就是BeanCurrentlyInCreationException。这就解释了为什么有人升级 Spring Boot 2.6 后一启动就报循环依赖错误代码却看起来处处合理。很多Configuration配置类里两个Bean方法互相传参这种就是典型的工厂方法参数循环不是靠三级缓存能救的。2. 工厂方法创建Bean的特殊性为什么这里更容易踩坑2.1 三种工厂方法入口的细微差异先理清“工厂方法”在 Spring 里的几种常见形态。第一种是Configuration类中的Bean方法。这是 Full 模式配置类会被 CGLIB 代理。代理的作用体现在调用Bean方法时Spring 会先检查容器里有没有创建过对应名字的 Bean有就直接返回缓存对象没有才真正执行方法体创建新实例。这样的结果是同一个Bean方法在容器生命周期内多次调用拿到的都是同一个单例。第二种是Component或普通类中的Bean方法。这是 Lite 模式配置类不会被 CGLIB 代理直接调用Bean方法相当于执行一个普通方法每次调用都会创建新实例不经过容器缓存。Spring 容器自己在实例化这个 Bean 时还是会走 getBean 流程没问题但如果代码里手动调用该方法就要小心得到非单例对象。第三种是静态工厂方法。通过Bean声明 static 方法或者 XML 里的factory-method对象的创建逻辑在静态方法中。静态方法不会被 CGLIB 增强拦截调用路径相对直接。这三种入口在循环依赖问题上的表现不同。Full 模式下 Bean 方法有代理拦截单例缓存逻辑清晰Lite 模式和静态工厂方法如果代码绕过容器直接调用很容易产生多个实例间接引发依赖混乱。2.2 工厂方法参数注入其实是隐式依赖查找很多开发者在Bean方法里写参数时下意识以为这只是给方法传一个现成对象。实际上 Spring 在解析Bean方法参数时走的是和Autowired解析一样的逻辑——通过resolveDependency去查找依赖找不到就触发创建。这个设计和构造器注入的定位非常相似。构造器注入的循环依赖无法解决本质上也是因为构造器参数必须在实例化之前全部就绪。Bean方法参数虽然不是传统构造器但从 Spring 的角度看它就是“创建这个 Bean 之前必须准备齐全的材料”。所以我自己在实践当中有一条经验不要把Bean方法参数当成独立的黄金法则认为“方法参数比构造器注入高明”。在循环依赖的判定逻辑里两者的命运差不多。你要是真写了互相引用的Bean方法参数报错速度和构造器循环一样快。特殊情况下可以对参数使用Lazy让参数解析阶段不必立即创建依赖 Bean而是生成一个延迟代理。后面会专门讲这个方案。2.3 FactoryBean与静态工厂方法的两类边界场景FactoryBean 是 Spring 中一个历史悠久的接口它本身是一个被容器管理的 Bean通过getObject()方法产出真正需要注入的目标对象。这样会产生两层对象一是 FactoryBean 自身二是getObject()产出的对象。这两层都有可能出现循环依赖。常见的一类循环是业务 Bean A 注入了 FactoryBean 生成的对象 B而 B 在创建过程中需要 A。这种情况下A 就是普通 BeanB 虽然由 FactoryBean 生成但它存在 BeanDefinition 和依赖解析被创建时仍然走 Spring 的缓存机制所以能否自愈取决于 A 是否已经提前暴露。另一类更容易踩坑的是线程池、HTTP 客户端等资源型 FactoryBean它们通常需要在初始化时拿到配置或管理器 Bean而这些配置可能反过来依赖 FactoryBean 提供的功能。这时与其在 Spring 层面上绕来绕去不如在getObject()方法内部延迟获取依赖或者把依赖迁移到方法调用期。静态工厂方法的问题更隐蔽。因为它无法被 CGLIB 代理当配置类被 Full 模式代理时static 方法调用不会触发代理的缓存检查逻辑。Spring 实例化 Bean 时仍然通过容器缓存保证单例但如果业务代码里有人手贱直接调用这个静态方法每调一次就必须新建一个对象单例语义就失效了。更麻烦的是静态方法天然访问不到实例字段如果它通过全局变量或 ThreadLocal 间接获取依赖循环依赖排查时会显得特别诡异。3. 实战拆解工厂方法循环依赖的四种典型故障3.1 两个Bean方法互相传参构造器级的死循环代码描述如下。Configuration public class CircularConfig { Bean public ServiceA serviceA(ServiceB serviceB) { return new ServiceA(serviceB); } Bean public ServiceB serviceB(ServiceA serviceA) { return new ServiceB(serviceA); } }启动时Spring 开始创建serviceA进入instantiateUsingFactoryMethod后先解析参数serviceB触发serviceB的创建。创建serviceB时要解析参数serviceA发现serviceA还在创建中就直接抛出BeanCurrentlyInCreationException日志会提示Requested bean is currently in creation。为什么三级缓存救不了因为serviceA此时还在工厂方法的参数解析阶段对应 Bean 实例还没有被 new 出来addSingletonFactory还没执行三级缓存中根本找不到serviceA的工厂对象。就算 B 想提前引用 A 的半成品也没得可引。我遇到这个场景时第一反应是“把其中一个 Bean 改成 Setter 注入不就行了”后来仔细看完时间线才明白问题不是注入方式而是Bean方法参数这个位置本身就走得太早。3.2 Bean创建的Bean与普通Bean互相依赖缓存能救但仍是隐患再看另一段代码。Configuration public class PartConfig { Bean public ServiceA serviceA() { return new ServiceA(); } } Component public class ServiceB { Autowired private ServiceA serviceA; public ServiceA getServiceA() { return serviceA; } } Component public class ServiceAReceiver { Autowired private ServiceB serviceB; }这里的ServiceA由Bean工厂方法创建假设ServiceA内部通过Autowired依赖了ServiceB为了说明问题让ServiceA自己持有ServiceB字段。创建ServiceA时工厂方法执行返回实例紧接着走属性填充发现需要ServiceB触发ServiceB创建。ServiceB属性填充时发现需要ServiceA因为ServiceA已经把 ObjectFactory 放进三级缓存所以ServiceB能拿到ServiceA的半成品完成创建。之后ServiceA的创建流程继续走完整个启动成功。这个过程看起来是自愈的但它有隐患ServiceA是由Bean工厂方法创建的它的实例化比较简单所以提前暴露得很早。如果工厂方法执行过程很长或者方法内部还有大量初始化逻辑ServiceB持有ServiceA半成品的时间窗口就会拉长。此时一旦ServiceA后期初始化失败ServiceB已经引用了一个残缺对象问题会被延迟到运行时才暴露。所以我的处理原则是能避免就避免不指望三级缓存兜底。特别是项目里同时依赖Configuration和组件扫描的情况下Bean 的创建顺序稍微被某个Profile或者条件注解打乱就可能从可解循环变成不可解循环。3.3 Prototype作用域工厂方法缓存根本不起作用把Bean方法的作用域改成 prototype。Bean Scope(prototype) public TaskHandler taskHandler() { return new TaskHandler(); }原型作用域的 Bean 每次获取都会创建新实例Spring 不会把它们放入三级缓存因为提前暴露机制只对单例 Bean 生效。如果某个单例 Bean 在创建时需要 prototype Bean而这个 prototype Bean 在创建时又依赖前一个单例 Bean结果就是单例 Bean 创建时触发 prototype Bean 创建prototype Bean 又回头触发单例 Bean 创建循环反复执行直到StackOverflowError或OutOfMemoryError。这种问题最容易发生在工厂方法加原型作用域的组合里比如每次需要生成一个任务处理器而任务处理器又注入了全局配置对象配置对象的初始化依赖任务处理器。日志里会出现大量重复创建记录排查起来特别混乱。原型循环的根治方法只有打破循环把双向依赖降级为单向依赖或者利用ObjectProvider延迟获取把“创建期必须依赖”转换成“运行期按需获取”。3.4 代理Bean与早期引用两次创建导致的对象不一致这是循环依赖里比较隐蔽的一种坑。当Bean方法返回的对象需要被 AOP 代理而它又恰好处于循环引用链路中时依赖方在三级缓存中拿到的是早期引用可能是原始对象或者早期代理但最终容器持有的单例可能是初始化之后生成的代理对象。Spring 内部有一道处理逻辑如果早期引用和最终单例不一致会在初始化完成后更新依赖方的引用。但这道逻辑有适用范围它依赖 Spring 内部对 Bean 依赖关系的登记以及依赖方是否正好在创建中。工厂方法场景下方法上标注Transactional或Async或者方法内部手动创建了代理就可能让类型预测和实际对象对不上导致最终注入的 Bean 与容器中实际单例不是同一个对象。我曾经排查过一个案例Bean方法返回类型写的是接口实际new的是实现类而该实现类又被切面代理。最终容器里保存的是代理对象但循环依赖中已经注入给另一个 Bean 的是原始对象。那个 Bean 调用方法时没走代理事务完全不生效业务数据直接写库没有回滚查了半天才发现是两个对象不一致造成的。应对这类问题除了打破循环还要特别注意Bean方法不要滥用返回接口类型返回实际类型能让 Spring 对 Bean 类型的预测更精确避免早期引用阶段就出现偏差。3.5 版本升级的坑Spring Boot 2.6默认禁止循环依赖Spring Boot 2.6 做出了一个重要调整默认禁止循环引用也就是spring.main.allow-circular-references的默认值为false。在这之前很多项目依赖 Spring 三级缓存自动解决循环依赖升级到 2.6 或更高版本后直接启动失败日志抛出BeanCurrentlyInCreationException并提示可以在配置里开启循环引用。升级到 Spring Boot 3 之后这个默认策略继续生效底层 Spring 6 对循环依赖的校验也更严格。工厂方法创建 Bean 时如果曾经隐性依赖循环大概率升级过程中的启动错误列表里会出现它。正确的做法不是一股脑把配置改成spring.main.allow-circular-referencestrue而是把循环依赖当成一次重构提示。因为循环依赖本质上是一种代码层面的不良布局就算现在通过配置放行了后续加一个新的切面或者调整某个 Bean 的初始化顺序它又会跳出来捣乱。4. 解决方案从配置到设计的分层解法4.1 Lazy延迟初始化的最快止血方案处理循环依赖最有效、最快速的办法就是加Lazy。把它写在注入点上Spring 会生成一个延迟代理先用这个代理完成注入等到业务代码真正调用代理的方法时它才会去容器中查找并创建目标 Bean。Bean public ServiceA serviceA(Lazy ServiceB serviceB) { return new ServiceA(serviceB); }Lazy可以放在字段、Setter、构造器参数、Bean方法参数上。正因为它是通过代理替换真实依赖所以连构造器循环依赖也能解掉。工厂方法参数上使用Lazy时参数解析阶段不会立即触发serviceB的创建而是生成一个延迟代理对象传给工厂方法从而打断了创建期的相互等待。这个方案也有代价。ServiceA拿到的ServiceB只是代理对象如果ServiceA在初始化阶段就要调用ServiceB的方法此时仍然会触发ServiceB的创建。换句话说Lazy只是把问题推迟到了第一次实际调用时并没有消除依赖关系。对于循环依赖双方都必须马上使用对方的情况Lazy不能真正解决只能让启动不报错。4.2 ObjectProvider把“立即获取”改成“按需获取”ObjectProvider是 Spring 5 开始强推的依赖获取方式它可以安全地延迟获取依赖对象。Bean public ReportService reportService(ObjectProviderAuditService auditServiceProvider) { return new ReportService(auditServiceProvider); }ReportService初始化时不会强制创建AuditService只有当业务代码需要时才调用getIfAvailable()或getIfUnique()获取实例。这在循环依赖场景下特别有利因为它意味着创建期的依赖变成了调用期的依赖两边的生命周期不再强绑定。ObjectProvider还能配合条件判断使用比如看看依赖是否为空再看是否有唯一实例。很多可选依赖的循环问题用ObjectProvider重写之后连Lazy代理都用不上了代码意图反而更清晰。4.3 重构注入方式Setter注入与DependsOn的正确姿势如果循环依赖是因为构造器注入或Bean方法参数导致的把其中一个注入方式改成 Setter 注入通常就能让该 Bean 在实例化后立即暴露半成品从而让另一个 Bean 拿到早期引用。Service public class GatewayService { private RouterService routerService; Autowired public void setRouterService(RouterService routerService) { this.routerService routerService; } }但这里必须说明白Setter 注入只是让创建顺序更友好不代表设计上没有循环。我通常在重构时会把 Setter 注入当作临时方案后续还是要想办法拆到单向依赖。DependsOn的使用场景和循环依赖并不完全相同。DependsOn控制的是 Bean 的初始化先后比如 A 初始化时依赖 B 已经就绪的某项状态。如果 A 和 B 是真正的双向引用光靠DependsOn指定顺序并不能解决创建期互等的问题。实际项目中它更适合用来给那些“隐式依赖”的 Bean 排序比如事件监听器需要核心组件先完成初始化。4.4 设计层面的根治拆依赖、加中间层循环依赖的最好解法永远是让循环消失。排查的时候先画一张 Bean 依赖图把谁依赖谁、依赖方向是什么标出来然后思考能不能把其中一方的依赖后移。一个常用的思路是引入中间层。A 和 B 互相需要往往是双方都需要对方的某部分能力这部分能力可以抽到独立的 C 里面让 A 和 B 都只依赖 C。比如 A 需要 B 的统计结果B 需要 A 的配置信息那可以把统计能力抽到StatisticsCollector把配置抽到ConfigHolderA 和 B 各自对接这两个组件。另一个思路是把初始化阶段的逻辑后置到运行时。利用ApplicationRunner或ApplicationReadyEvent容器启动完成后再执行那些需要跨 Bean 访问的初始化动作。这样在 Spring 创建 Bean 的核心时期依赖链非常短循环自然消失了。我在很多项目里推行的规则是构造器注入只放那些真正无法延迟的依赖可选的、后置执行的一律用ObjectProvider绝不通过静态工厂方法间接访问容器中的其他 Bean。5. 常见问题和排查技巧实录5.1 三段高频报错如何解读遇到循环依赖时Spring 的报错信息非常有特点。第一条是BeanCurrentlyInCreationException核心提示是Error creating bean with name xxx: Requested bean is currently in creation: Is there an unresolvable circular reference?这代表确实形成了创建期环路。第二条是BeanCreationNotAllowedException报错往往长这样Singleton bean creation not allowed while the singletons of this factory are still in creation。这个报错说明在某个单例 Bean 的创建过程中有代码直接调用了ApplicationContext.getBean()去拿另一个 Bean而容器这会儿正处在创建单例的阶段不允许这种行为。第三条出现在 Spring Boot 2.6 之后报错前面通常会带一句提示让你配置spring.main.allow-circular-references。看到这个提示先不要急着开启配置项而是先回到代码里确认环路位置。理解这些报错的共同点是Spring 容器在创建期对 Bean 的获取有严格顺序限制只要有人在创建期强行获取正在创建中的 Bean就会触发其中之一。5.2 Debug定位从getSingleton观察依赖树实际排查时我常用的方式是在AbstractBeanFactory.getBean方法和DefaultSingletonBeanRegistry.getSingleton方法上打条件断点。通过观察singletonsCurrentlyInCreation这个集合可以清楚地看到当前有哪些 Bean 正在创建。如果发现集合里出现了 A、B、A 的重复轨迹就意味着 A 创建到一半重新进入创建流程环路找到了。再利用编辑器自带的调用链反推从报错堆栈里找到是哪一行触发了getBean调用。比如堆栈里出现CircularConfig.serviceB那就说明循环的触发点在Bean方法参数解析阶段。Spring Boot Actuator 也提供了beans端点通过/actuator/beans能查看到每个 Bean 的依赖关系和类型信息。虽然这个端点不能直接告诉你“这里有循环”但配合依赖图可以快速定位到相互引用的 Bean 对。5.3 框架级FactoryBean循环依赖的处理经验实际业务里最常碰到的工厂方法循环反而不是自己写的Bean配置而是框架内部的 FactoryBean。比如 MyBatis 的MapperFactoryBean、Feign 的FeignClientFactoryBean。这类 FactoryBean 生成的 Bean 通常无法直接修改其创建逻辑那么解决问题的重点就放在注入方。Service 中注入 Mapper 或者 FeignClient 时如果 Mapper 的插件或拦截器反过来依赖 Service就会出现循环。最简单的处理就是在 Service 的字段上标注Lazy让 FeignClient 或 Mapper 的创建延迟到首次调用时。之前接手过一个 Spring Boot Admin 集成项目自定义 HealthIndicator 需要依赖业务 Service而业务 Service 初始化时又依赖了这个 Indicator 的监控状态。这种循环同样是把 HealthIndicator 内部改成ObjectProviderService来延迟获取问题就消失了。5.4 一张自查清单排查循环依赖时我习惯按照下面的清单顺序走一遍。检查项说明是否使用构造器注入或 Bean 方法参数注入这种情况最危险很可能直接启动失败循环链路中是否有 prototype 作用域 Bean有则无解必须拆依赖Bean 是否被 AOP 代理可能造成早期引用与最终对象不一致Spring Boot 版本是否大于等于 2.6默认禁止循环引用启动失败正常是否存在创建期主动 getBean 的行为检查 ApplicationContextAware 回调代码依赖双方是否可以在调用期再获取能改 ObjectProvider 就尽量改遇到循环依赖先不要急套上这张表基本十分钟内能定位问题属于哪一类。6. 我在项目里的最终落地建议实际操作中踩过几次坑之后我对工厂方法创建 Bean 的循环依赖有了一套自己的处理法则。第一配置类里的Bean方法参数尽量少用能用构造器传参进配置类就在配置类里持有不要把依赖解析的职责全压到每个Bean方法上。第二Spring Boot 2.6 之后默认禁止循环依赖这不是官方在制造麻烦而是提醒大家逐步清理旧设计。第三遇到循环依赖第一反应不是加Lazy而是想一想到底需不需要双方都持有对方。如果确实需要临时止血Lazy是最快的如果依赖是可选或者调用期的ObjectProvider更干净如果循环涉及多个框架级 FactoryBean那就要回到依赖注入的切入口去调整。我最想推荐的做法还是静态分析依赖关系。每次新增一个Bean工厂方法时顺手在注释里标明它的依赖方向一旦发现有回边就停下来重新设计。这个习惯帮我挡住了不少后来会变成生产事故的隐患也希望你能从中找到适合自己项目的节奏。