前几天有个读者跑来问我:网上都在说Spring三级缓存能解决循环依赖,为什么我把两个Service改成构造器互相注入,项目一启动直接报错Requested bean is currently in creation?我瞄了一眼堆栈,回答其实Spring源码里写得很清楚——三级缓存能覆盖的循环依赖只是其中一条窄路,构造器注入、原型Bean、异步增强这些场景,Spring根本没有打算救。
这篇文章就把“Spring解决不了哪些循环依赖”这件事彻底翻一遍,顺便讲清楚背后的源码逻辑。适合正在准备Spring源码面试的人、被循环依赖报错折磨到怀疑人生的开发,以及想搞懂三级缓存边界在哪的框架爱好者。我会先讲能解决和不能解决的边界,再逐个拆解构造器注入循环、原型Bean循环、AOP/异步代理陷阱,最后给出一套面试回答框架和排查工具箱。
1. Spring能解决哪些循环依赖,解决不了的又是哪些
1.1 三级缓存的生效边界:只有一条窄路能走通
很多人把三级缓存背得滚瓜烂熟:singletonObjects存完全体单例,earlySingletonObjects存提前暴露的半成品,singletonFactories存能产生早期引用的工厂。但真正要问的是:这三个Map到底在什么时候介入、能覆盖哪类场景。
在DefaultSingletonBeanRegistry的getSingleton(String beanName, boolean allowEarlyReference)里,逻辑是先从一级缓存取完整Bean,取不到再去二级缓存取早期引用,二级缓存还没有,才去三级缓存拿ObjectFactory并调用getObject()生成一个“尽量完整但还没初始化完”的Bean。也就是说,它依赖的前提是:当前Bean已经被实例化出来了。
再看AbstractAutowireCapableBeanFactory#doCreateBean的主流程:实例化(createBeanInstance)之后,才会addSingletonFactory把工厂放进三级缓存,然后才做属性填充(populateBean)和初始化(initializeBean)。所以只能解决“A已经被实例化、在填充属性时发现需要B,而B又需要A”的情况。换成构造器注入,A在实例化之前就要把构造参数B准备好,B回头要A时,A还没走到addSingletonFactory那一步,三级缓存里空空如也,循环就解不开。
换句话说,Spring能解决的循环依赖必须同时满足三个条件:Bean作用域是单例、注入方式是setter或字段注入、当前Bean实例化完成但还没初始化完成。这条窄路之外,基本上全是坑。
1.2 一张表搞清楚“能解决”与“不能解决”
在日常开发里,最常见的循环依赖场景其实就下面这几种,我按能不能解决做了个速查表:
| 场景 | Spring能否解决 | 原因 |
|---|---|---|
| 单例Bean + 字段/setter注入 | 能解决 | 实例化后提前暴露工厂,对方能拿到早期引用 |
| 单例Bean + 构造器注入 | 不能解决 | 实例化前就要解析依赖,三级缓存还没有当前Bean |
| 原型Bean + 任意注入 | 不能解决 | 原型不共享,容器只记录“正在创建”状态,不缓存早期引用 |
| 单例Bean + 普通AOP代理 + 字段注入 | 通常能解决 | 三级缓存里放的是ObjectFactory,有机会提前生成代理 |
| 单例Bean + @Async增强 + 字段注入 | 容易出问题 | 异步代理不一定在提前暴露阶段生成,拿到的是原始对象 |
| 跨容器/父子容器的循环依赖 | 不能解决 | 三级缓存按容器隔离,父容器看不到子容器的Bean |
| @DependsOn 显式依赖循环 | 不能解决 | 容器在创建前检查显式依赖关系,直接抛异常 |
看到这个表你就明白,网上说“Spring能解决循环依赖”这句话是不完整的。准确说法应该是:Spring能解决单例、非构造器注入、创建期依赖这一小块范围内的循环依赖。其余场景要么直接启动失败,要么启动成功但行为诡异。
2. 最典型的死局:构造器循环依赖为什么必然失败
2.1 从源码看构造器依赖解析发生在“实例化”之前
构造器循环依赖是面试里出镜率最高的一个。要理解它为什么必死,得把doCreateBean的调用顺序看清楚。
AbstractAutowireCapableBeanFactory#doCreateBean里,第一行就是创建Bean实例:
instanceWrapper = createBeanInstance(beanName, mbd, args);这一步会解析构造器参数。如果是@Autowired的构造器,ConstructorResolver会逐个调用beanFactory.getBean()去拿依赖。关键是:addSingletonFactory(把当前Bean放进三级缓存)发生在这行代码之后、populateBean之前。也就是说,构造器解析依赖时,当前Bean根本还没暴露到三级缓存里。
于是死循环出现了:创建A,解析A的构造器参数需要B;创建B,解析B的构造器参数需要A;容器查A的单例池,发现A还在创建中,三级缓存也没有A,直接抛BeanCurrentlyInCreationException。
我在本地断点调试过这个过程。断点打在DefaultSingletonBeanRegistry#getSingleton(String, boolean),当B去取A时,singletonFactories里只有B自己的工厂,A的名字根本不在里面。那一刻你会特别直观地感受到:不是Spring不救,是它想救也够不着。
2.2 在本地复现一次构造器循环依赖
写个最小Spring Boot项目,两个类互相构造器注入:
@Component public class DemoA { private final DemoB demoB; public DemoA(DemoB demoB) { this.demoB = demoB; } }@Component public class DemoB { private final DemoA demoA; public DemoB(DemoA demoA) { this.demoA = demoA; } }启动时Spring会给出非常明确的提示,通常长这样:
The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | demoA defined in file [...] ↑ ↓ | demoB defined in file [...] └─────┘如果直接通过context.getBean(DemoA.class)去取,则多半会看到:
BeanCurrentlyInCreationException: Error creating bean with name 'demoA': Requested bean is currently in creation: Is there an unresolvable circular reference?这时候不要怀疑是自己配置问题,这是Spring在明明白白告诉你:构造器循环依赖,我搞不定。
2.3 不想改结构的几种“逃生通道”及适用场景
遇到构造器循环依赖,拆类是最好的方案,但有时候改动面大、排期紧,可以先用临时方案解掉。我的经验是这三种手段最常用:
第一种,改成setter/字段注入。最简单直接,Spring能通过提前暴露解决。代价是失去了构造器注入的不可变性和“依赖必填”保证,等于用自己的判断力换取兼容性。适合内部服务、小范围改动。
第二种,在构造器参数上加@Lazy。Spring会注入一个代理对象,真正调用目标方法时才触发完整创建。代码看起来还是构造器注入,但语义变成了延迟加载。这种方式有个隐蔽问题:如果B被A的代理触发创建,而B又反过来依赖A,可能把创建顺序绕得更复杂。适合只需要打破启动期卡死、且对方方法调用频率不高的场景。
第三种,用ObjectProvider<T>延迟获取。在构造器里放ObjectProvider<DemoB>,需要时再getObject()。这种方式比@Lazy更可控,缺点是调用方代码会多一层获取动作。适合依赖有多个可能实现、或者创建时机需要动态决定的场景。
我用一个简单对比来总结:
| 方案 | 实现方式 | 优点 | 风险 |
|---|---|---|---|
| setter/字段注入 | 去掉构造器,改成@Autowired字段 | 改动小,立即生效 | 对象可能暴露未完全初始化的内部状态 |
| @Lazy构造器参数 | 构造器参数加@Lazy | 保持构造器注入风格 | 代理语义复杂,调试变量不直观 |
| ObjectProvider延迟获取 | 构造器注入ObjectProvider<T> | 获取时机可控,支持可选依赖 | 调用方需要感知容器概念 |
如果时间允许,我还是建议把互相依赖的公共逻辑抽出去,变成DemoA -> CommonService <- DemoB这种单向依赖。应急用技巧,长期靠结构。
3. 原型Bean的循环依赖:容器直接摆烂的另一个重灾区
3.1 原型Bean为什么进不了三级缓存
先明确一件事:三级缓存设计出来是为了保存共享单例的早期引用。原型Bean每次getBean都要返回一个新实例,它进了缓存也毫无意义——就算你把半成品存进去,下一次获取时容器仍然要重新创建。
真正让容器“摆烂”的,是源码里原型Bean的创建路径。在AbstractBeanFactory#doGetBean中,mbd.isPrototype()为true时会走独立的创建分支。容器不是不检测循环,它只是用一个prototypesCurrentlyInCreation集合记录当前线程正在创建的原型Bean名字。创建完成后立刻移除,但如果创建过程中又遇到同一个原型Bean,就会直接抛BeanCurrentlyInCreationException。
打个比方:单例缓存像一个寄存柜,快递员可以把还没包装完的包裹先放进去,另一个领取人拿到手之后继续补货。原型Bean则是一个只接新订单的工厂,你不可能把一个只做了一半的订单当作“成品”存起来,更不能让两个客户共享同一个半成品。所以容器宁可抛异常,也不愿意打破原型的作用域语义。
3.2 原型循环依赖的报错现场与解决路径
原型循环依赖在启动阶段往往不报错,因为原型Bean默认是懒加载的,只有业务代码真正获取时才会触发。比如这两个类:
@Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ProtoA { @Autowired private ProtoB protoB; }@Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ProtoB { @Autowired private ProtoA protoA; }项目能正常启动,但一旦执行:
applicationContext.getBean(ProtoA.class);马上抛异常。原因和构造器循环依赖一样,本质是创建中的Bean又去获取自己,但这次连二级缓存都没有,因为原型不会进缓存。
解决原型循环依赖,思路不是“让Spring去解”,而是“不要让它掉进创建期循环”。最推荐的做法是:如果只有一个方向需要动态获取另一个Bean,就把其中一个改成注入ObjectProvider<ProtoB>,在需要时再获取;如果两个方向都要动态获取,说明这两个原型Bean本身就不该强耦合在一起,需要把相互调用的部分抽成策略接口或公共组件。还有一种土办法是注入ApplicationContext后手动getBean,也能绕开创建期依赖,但代码可读性差,一般不推荐。
另外强调一点:不要试图用@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)硬解。代理模式确实能延迟解析依赖,但它掩盖了作用域语义,很容易出现“你以为是新实例,结果拿到的是同一个代理”的诡异问题,排查成本反而更高。
4. 表面被解决、实则埋雷的场景:AOP代理与@Async
4.1 getEarlyBeanReference是如何“救”普通AOP的
先讲个容易被忽略的设计:三级缓存里存的不是普通对象,而是ObjectFactory。为什么非要包一层工厂?就是为了给AOP代理留后门。
当A在属性填充时发现需要B,B又需要A,容器从三级缓存拿到A的工厂并调用getObject()。这个getObject()最终会调用SmartInstantiationAwareBeanPostProcessor#getEarlyBeanReference。像AbstractAutoProxyCreator这类普通AOP处理器,会在这个方法里提前给A生成代理,并把代理对象放进二级缓存。
后面A继续走初始化流程,等走到正常的postProcessAfterInitialization时,AbstractAutoProxyCreator会检查earlyProxyReferences,发现这个Bean已经提前代理过了,就不再做第二次包装。这套机制保证:即使A被提前暴露,最终注入到各处的仍然是同一个代理对象。
所以,单例Bean + 字段注入 + 普通@Transactional这种场景,Spring是能解循环依赖的,这也是网上文章里最常见的结论。但注意,它只对“实现了提前代理逻辑的处理器”有效,不是所有增强都走这条路。
4.2 @Async为什么在循环依赖里最容易踩坑
@Async的增强处理由AsyncAnnotationBeanPostProcessor负责。这个类跟AbstractAutoProxyCreator不是一套体系,异步代理通常是在Bean初始化完成后才生成的。问题就出在这里:
如果Bean在初始化前就被循环依赖强制“提前暴露”了,依赖它的那个Bean拿到的就是还没有套上异步代理的原始对象。等目标Bean完成初始化、异步代理真正生成后,早期引用已经写死在对方Bean里了,不会自动替换。
表现最典型的是:项目能正常启动,甚至日志里也没报错,但调用某个@Async方法时它同步执行了,完全不异步。很多人排查半天也找不到原因,因为单看目标Bean本身,代理是存在的;但看持有方的引用,却不是代理。
我建议遇到这类问题的第一反应就是:这个Bean是不是卷进了循环依赖?如果是,先找环,再谈异步。否则你给目标Bean加多少配置都没用。
4.3 一个字段注入案例:能启动,但异步方法同步执行
模拟一个很容易被忽略的场景。假设OrderService和UserService互相字段注入,OrderService里有一个@Async方法:
@Component public class OrderService { @Autowired private UserService userService; @Async public void asyncHandle() { System.out.println("thread = " + Thread.currentThread().getName()); } }@Component public class UserService { @Autowired private OrderService orderService; }启动时Spring大概率不会报错,因为字段注入能用三级缓存硬解。但你调用orderService.asyncHandle(),输出里往往看不到task-1这类异步线程名,方法在当前线程直接跑完了。原因就是前面说的:提前暴露时拿到的是原始对象的半成品,异步代理还来不及生成。
定位方式也很简单,在UserService.orderService字段上打断点,看它的实际类型是普通类还是CGLIB代理类。如果是普通类,基本可以断定是循环依赖下的提前暴露导致增强失效。
处理办法通常是三种:把异步方法抽到独立的@Component,和当前Bean解耦;或者给其中一个字段注入加@Lazy,让依赖方延迟获取完整代理;再或者直接依靠Spring Boot的循环依赖禁用开关,让这种隐患在启动阶段就暴露出来,而不是运行时悄悄失效。
5. 更多Spring管不了的“循环”,排查时可别漏
5.1 跨容器与父子容器:三级缓存救不了“跨柜台”
三级缓存按容器实例隔离,每个容器各有一套DefaultSingletonBeanRegistry。在Spring MVC常见的父子容器架构里,子容器能看到父容器的Bean,父容器看不到子容器的Bean。如果A在子容器,B在父容器,A依赖B一路向上能拿到,但B反向依赖A时,父容器查不到子容器里的A,于是报错。
这种问题最容易出现在老项目中:Controller在子容器里注入了Service,Service通过ApplicationContextAware反向获取Controller或某些Web层组件,直接踩进跨容器循环。排查时如果发现报错信息里出现两个不同容器路径下的同名Bean,基本就是这个问题。修复方案一般是把公共依赖下沉到父容器统一管理,或者让子容器的Bean通过配置方式引用父容器Bean,绝对不要跨容器反向引用。
5.2 初始化回调与BeanPostProcessor:Spring看不见的循环
三级缓存解决的是创建期依赖,也就是Bean已经实例化但还没初始化完的那段时间。但现实中还有不少循环发生在生命周期回调里,Spring就不管了。
比如InitializingBean#afterPropertiesSet里手动调ApplicationContext.getBean(),又或者SmartInitializingSingleton#afterSingletonsInstantiated中两个Bean互相调用。这些循环不在三级缓存的覆盖范围内,因为所有Bean都已经创建完了,Spring不会帮你做任何延迟处理,只能是运行期不停互相调、递归调用,最后栈溢出。
还有一种更隐性的:BeanPostProcessor本身依赖普通Bean,而普通Bean又依赖这个BeanPostProcessor增强时,会形成极其诡异的启动期故障。这种问题建议不要纠结“为什么Spring不解”,而是直接重构:BeanPostProcessor的依赖尽量只停留在基础设施层面,不要反向依赖普通业务Bean。
5.3 自定义作用域与@DependsOn:两个容易被误判的循环依赖
Session、Request这类自定义作用域同样走不进单例三级缓存,出现创建期循环依赖时也没得救。不过它们通常是为了延迟绑定而存在,实际遇到循环的概率比较小,知道有这回事即可。
更容易被忽略的是@DependsOn。它表示“当前Bean创建之前先创建另一个Bean”,是显式顺序依赖。如果A@DependsOn("b"),B又@DependsOn("a"),容器在创建前就会检测到这种循环并抛出BeanCreationException,提示信息是Circular depends-on relationship。很多人看到这个报错会下意识以为是普通循环依赖,其实它和三级缓存完全无关,是容器在创建期前做的强制检查。解决方式就是砍掉其中一个@DependsOn,别让显式依赖成环。
6. 面试与实战:源码级回答框架和排查心法
6.1 面试这样答:从三级缓存讲到“管不了的边界”
如果面试官问“Spring如何解决循环依赖”,建议先定义边界,再讲机制,最后补“反面案例”。我平时推荐的回答顺序是四步。
第一步,定性:Spring解决的是单例Bean、setter/字段注入、创建期提前暴露这一条路径。第二步,讲三级缓存:一级存完整单例,二级存提前暴露的早期引用,三级存ObjectFactory,工厂可以在早期引用阶段触发AOP代理。第三步,说流程:A实例化后放入三级缓存,填充B时发现B要A,B从三级缓存取A的早期引用,B创建完成后A继续初始化。第四步,立刻抛出“但Spring解决不了”的部分:构造器注入循环、原型Bean循环、@Async增强被提前暴露、跨容器循环、@DependsOn显式循环,这些都会报错或出现运行时诡异行为。
这样回答既展示了源码细节,又说明你理解边界,不是只会背八股。面试官再追问“为什么三级缓存不能改成两级缓存”,你就可以把AOP提前代理的机制展开讲,正好接上getEarlyBeanReference的逻辑。
6.2 诊断循环依赖的实操手段:日志、断点与依赖图
说点实际的排查技巧。遇到循环依赖报错,第一步不是去改代码,而是把启动日志拉全,看Spring打印的依赖环。老版本通常会有这样的提示:
The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | a ↑ ↓ | b └─────┘如果日志里没有,可以临时开启BeanFactory的DEBUG日志,在配置里加:
logging.level.org.springframework.beans.factory=debug更彻底的办法是在DefaultSingletonBeanRegistry#getSingleton(String, boolean)打断点,重点看三个对象:singletonObjects、earlySingletonObjects、singletonFactories。当发现一个Bean已经在创建、但三级缓存里又没有它时,那个等待点就是循环的根源。
对于运行期异步失效这种“不报错但行为不对”的问题,优先在注入字段上打断点,看实际类型是否代理类。不是代理类就说明它被提前暴露了,顺着报错依赖链往回找,很容易定位到环上的另一个Bean。
6.3 团队如何从根源上拦截循环依赖
让开发自觉规避循环依赖并不现实,最有效的办法是让工程层面的默认配置来拦截。Spring Boot 2.6开始,spring.main.allow-circular-references默认就是false,肯定会有一批老项目升级后启动失败。我的建议是:如果项目维护者能控制代码结构,就不要为解一时之痛把这个开关改回true。启动时报错不可怕,运行时静默失效才可怕。
协同上可以在项目里把“禁止Bean之间成环”写进代码规范,代码评审时重点看Service层之间的互相注入。工具层面,IDE的依赖图已经能直观看到Bean之间的注入关系,IDEA里用Show Dependencies或者安装分析依赖的插件,可以快速发现环。我的经验是,定期把核心模块的依赖图导出来扫一遍,比等到运行期踩坑划算得多。
最后说一个我自己的习惯。遇到循环依赖报错,我第一反应不是去查什么偏门配置,而是把报错信息里带箭头的依赖图拉出来,然后问自己:如果我把其中一个Bean从自动注入改成方法参数传入,环是不是自然消失了?这个习惯帮我解决过大量启动故障。很多时候所谓“Spring解决不了”,其实是代码结构把Spring逼到了墙角——它确实有很多解不了的循环,但更值得反思的,是我们为什么要让它去解。