
前几天有个读者跑来问我网上都在说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增强 字段注入容易出问题异步代理不一定在提前暴露阶段生成拿到的是原始对象跨容器/父子容器的循环依赖不能解决三级缓存按容器隔离父容器看不到子容器的BeanDependsOn 显式依赖循环不能解决容器在创建前检查显式依赖关系直接抛异常看到这个表你就明白网上说“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可能把创建顺序绕得更复杂。适合只需要打破启动期卡死、且对方方法调用频率不高的场景。第三种用ObjectProviderT延迟获取。在构造器里放ObjectProviderDemoB需要时再getObject()。这种方式比Lazy更可控缺点是调用方代码会多一层获取动作。适合依赖有多个可能实现、或者创建时机需要动态决定的场景。我用一个简单对比来总结方案实现方式优点风险setter/字段注入去掉构造器改成Autowired字段改动小立即生效对象可能暴露未完全初始化的内部状态Lazy构造器参数构造器参数加Lazy保持构造器注入风格代理语义复杂调试变量不直观ObjectProvider延迟获取构造器注入ObjectProviderT获取时机可控支持可选依赖调用方需要感知容器概念如果时间允许我还是建议把互相依赖的公共逻辑抽出去变成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就把其中一个改成注入ObjectProviderProtoB在需要时再获取如果两个方向都要动态获取说明这两个原型Bean本身就不该强耦合在一起需要把相互调用的部分抽成策略接口或公共组件。还有一种土办法是注入ApplicationContext后手动getBean也能绕开创建期依赖但代码可读性差一般不推荐。另外强调一点不要试图用Scope(value prototype, proxyMode ScopedProxyMode.TARGET_CLASS)硬解。代理模式确实能延迟解析依赖但它掩盖了作用域语义很容易出现“你以为是新实例结果拿到的是同一个代理”的诡异问题排查成本反而更高。4. 表面被解决、实则埋雷的场景AOP代理与Async4.1 getEarlyBeanReference是如何“救”普通AOP的先讲个容易被忽略的设计三级缓存里存的不是普通对象而是ObjectFactory。为什么非要包一层工厂就是为了给AOP代理留后门。当A在属性填充时发现需要BB又需要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在子容器里注入了ServiceService通过ApplicationContextAware反向获取Controller或某些Web层组件直接踩进跨容器循环。排查时如果发现报错信息里出现两个不同容器路径下的同名Bean基本就是这个问题。修复方案一般是把公共依赖下沉到父容器统一管理或者让子容器的Bean通过配置方式引用父容器Bean绝对不要跨容器反向引用。5.2 初始化回调与BeanPostProcessorSpring看不见的循环三级缓存解决的是创建期依赖也就是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”是显式顺序依赖。如果ADependsOn(b)B又DependsOn(a)容器在创建前就会检测到这种循环并抛出BeanCreationException提示信息是Circular depends-on relationship。很多人看到这个报错会下意识以为是普通循环依赖其实它和三级缓存完全无关是容器在创建期前做的强制检查。解决方式就是砍掉其中一个DependsOn别让显式依赖成环。6. 面试与实战源码级回答框架和排查心法6.1 面试这样答从三级缓存讲到“管不了的边界”如果面试官问“Spring如何解决循环依赖”建议先定义边界再讲机制最后补“反面案例”。我平时推荐的回答顺序是四步。第一步定性Spring解决的是单例Bean、setter/字段注入、创建期提前暴露这一条路径。第二步讲三级缓存一级存完整单例二级存提前暴露的早期引用三级存ObjectFactory工厂可以在早期引用阶段触发AOP代理。第三步说流程A实例化后放入三级缓存填充B时发现B要AB从三级缓存取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.factorydebug更彻底的办法是在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逼到了墙角——它确实有很多解不了的循环但更值得反思的是我们为什么要让它去解。