ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring循环依赖深度解析:哪些场景解决不了,源码与避坑指南

Spring循环依赖深度解析:哪些场景解决不了,源码与避坑指南 写Spring源码解析这个系列循环依赖是绕不开的坎。网上讲三级缓存、讲Bean生命周期、讲AOP提前暴露的文章一大把但大多数都是把Spring“能解决”的循环依赖讲得很透彻很少有文章认真梳理那些Spring同样面对、但确实解决不了或者处理得很别扭的场景。这几天我在重读DefaultSingletonBeanRegistry和AbstractAutowireCapableBeanFactory的源码时又把之前踩过的几个坑翻了出来今天索性用一篇长文把这些“解决不了的循环依赖”讲透包括背后的源码逻辑、触发条件、报错现场以及那些看似能跑但实际埋雷的规避手段。1. 先搞清楚Spring默认解决的是哪类循环依赖要看明白“哪些问题解决不了”首先得把Spring“能解决”的范围边界画清楚。很多面试者和初级开发对三级缓存的理解停留在“有三个Map所以能解决循环依赖”这个层面但实际源码里三个Map的职责完全不同使用顺序也有严格区分。三级缓存在DefaultSingletonBeanRegistry里对应三个成员变量singletonObjects、earlySingletonObjects、singletonFactories。第一个是成品缓存存放完整初始化好的单例Bean第二个是早期引用缓存存放已经实例化但还没完成属性填充的Bean第三个是对象工厂缓存存放可以提前生成Bean引用的工厂。Spring解决循环依赖的核心套路是提前暴露当Bean A创建过程中发现需要注入Bean B而Bean B在创建过程中又反过来需要Bean A时Spring可以从第三级缓存的ObjectFactory里拿到一个A的早期引用先把这个早期引用注入给B等B创建完成后再把B的“成品”注入给A最后A完成初始化。但这里有一个重要前提决定了一大批循环依赖根本走不到这套机制提前暴露只发生在单例Bean的“实例化之后、属性填充之前”。如果是构造器注入Bean还没实例化完就不得不去拉依赖的Bean这时候早期缓存里根本不可能有当前Bean的引用循环依赖直接卡在实例化这一步。这也是为什么构造器循环依赖是最容易触发BeanCurrentlyInCreationException的场景之一。还有作用域的问题。三级缓存是给单例Bean准备的prototype作用域的Bean根本不走缓存每次获取都是全新创建从头到尾不存在“提前暴露”这个概念。如果两个原型Bean互相依赖Spring能做的基本上就是直接抛异常告诉你这段关系无法处理。所以Spring的“解决办法”其实是一个非常狭义的窗口单例作用域 setter注入或字段注入 非构造器循环依赖。越过这个窗口要么报错要么表面不报错但行为已经变味。2. 构造器注入的循环依赖为什么直接凉凉2.1 Spring压根没给构造器场景留后路构造器注入的循环依赖是面试里问得最多的“解决不了”的场景。很多人不理解同样是循环依赖为什么setter注入能解决构造器注入就报错这要回到Spring创建Bean的顺序来看。在AbstractAutowireCapableBeanFactory的createBeanInstance方法里Spring解析构造器、确定参数、执行构造器这一步发生在doCreateBean的早期阶段而addSingletonFactory也就是把早期引用暴露到三级缓存的调用点在populateBean属性填充之前。也就是说执行构造器的时候当前Bean连“早期引用”都还没注册到缓存里。假设构造器注入场景下Bean A的构造器需要Bean BBean B的构造器需要Bean A。Spring开始创建A执行A的构造器时发现要B于是转去创建B创建B时执行构造器又发现要A于是再回来创建A。但此刻A的实例还没创建出来早期缓存里也没A的任何痕迹Spring只能认定这是一个无法完成的创建链抛出BeanCurrentlyInCreationException。直接看源码理解更快。DefaultSingletonBeanRegistry的getSingleton方法里有这样一段核心逻辑protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }注意这里的第二个判断条件isSingletonCurrentlyInCreation(beanName)。构造器注入场景下Spring先走到A的构造器但直到构造器里需要B之前A连创建都没完成singletonsCurrentlyInCreation集合里虽然已经标记了A正在创建但singletonFactories里并没有A的工厂。回到getSingleton时拉不到A的引用只能重新走创建流程最终检测到“重复创建正在创建中的Bean”直接抛异常。2.2 报错现场长什么样如果你用构造器注入触发循环依赖常见的报错是BeanCurrentlyInCreationException: Error creating bean with name a: Requested bean is currently in creation: Is there an unresolvable circular reference?这个异常含金量很高它直接告诉你“当前Bean还在创建中”言下之意是Spring已经在创建A了但在某个依赖环节发现自己需要重新获取A而A还没有可以提前暴露的引用。我在一个老项目里就碰到过类似问题。两个核心服务互相调开发图省事直接放在了构造器里一启动就报这个错。当时组里有个同学提出“把其中一个构造器注入改成字段注入”就解决了这确实是最直接的方案。但问题在于只改了一个注入点项目其他地方可能还有类似隐患所以后来我们直接立了规矩涉及到有互相调用倾向的服务构造器里只注入稳定的基础设施类业务依赖一律用setter注入或者Lazy延迟。2.3 Lazy能救场吗能但要明白代价构造器循环依赖确实可以通过在其中一个构造参数上标注Lazy来绕过被Lazy标记的依赖会生成一个代理对象延迟到真正调用方法时才去容器里解析目标Bean。举个例子Component public class A { private final B b; public A(Lazy B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }这种情况下Spring创建A时构造器参数B被解析为一个代理B的创建不再被立刻触发A得以创建完成并放进单例缓存等创建B时注入A的引用也已经是成品。看起来问题解决了。但我个人对这种写法持保留态度。Lazy隐藏了真实的创建时机如果B的初始化逻辑较重或者B在初始化时需要依赖A已完成的状态延迟代理可能在第一次调用时才触发B的创建导致时序问题和诡异的空指针。它更适合作为临时救场手段而不是一种默认设计。3. prototype作用域的循环依赖没有缓存就没有救3.1 原型Bean的创建链路里压根没有“缓存”这个环节如果说构造器循环依赖是Spring“想救但没法救”那prototype作用域的循环依赖就是Spring完全“没有救的欲望”。原因非常本质原型Bean每次获取都是新建实例而三级缓存机制是为单例Bean服务的它解决循环依赖的前提是“这个Bean的生命周期里可以有一个缓存引用位置”。运行时上原型Bean不走getSingleton的缓存逻辑而是直接走createBean流程。当A原型的创建过程中依赖B原型B的创建过程中又依赖ASpring在doGetBean方法里会有一个专门的保护逻辑if (isPrototypeCurrentlyInCreation(beanName)) { throw new BeanCurrentlyInCreationException(beanName); }Spring维护了一个prototypesCurrentlyInCreation集合创建原型Bean前先记录创建完成后移除。如果创建过程中发现自己反复进入同一个原型Bean的创建逻辑说明循环依赖无法打破直接抛异常。这个保护逻辑比单例场景更简单粗暴因为原型Bean没有缓存可查不存在“提前暴露”的可能性。有人可能会问单例Bean的循环依赖需要三级缓存原型Bean为什么不能有类似的“弱缓存”这个问题的答案在于语义设计原型Bean本来就不承诺复用如果为了打破循环依赖而缓存某个中间态引用那它本质上就不是原型了语义会被彻底破坏。Spring选择直接拒绝我认为是合理的设计取舍。3.2 混合作用域下最容易被忽视的坑更麻烦的是单例和原型混用时的循环依赖。假设A是单例B是原型A依赖BB的创建过程中又依赖A。A是单例有三级缓存兜底B创建时可以拿到A的早期引用所以这种组合往往能创建成功但B每次被获取时都是全新实例这意味着B的引用在A内部是“不断变化的”。如果B在创建时需要依赖A内部的某些状态而A当时还在初始化早期阶段行为就会变得难以预测。反过来A是原型B是单例A依赖BB又依赖A这种同样会爆炸。因为B创建时虽然是单例但B的创建过程中触发了A的创建A又是原型A创建过程中又回到B又因为B正在创建中singletonObjects里没有B的成品singletonFactories里因为B还没走到暴露阶段也不会有早期引用最终B的getSingleton调用只能返回null然后重新进入创建逻辑最终由isPrototypeCurrentlyInCreation或isSingletonCurrentlyInCreation拦截报错结束。这种场景下最麻烦的是排查成本高。报错信息可能指向“Requested bean is currently in creation”但真正的原因来自跨作用域的循环触发。我的习惯是遇到这类异常先把所有涉及Bean的作用域列出来看有没有原型Bean被单例Bean直接或者间接依赖再看原型Bean的依赖链上有没有回到单例Bean的路径。3.3 原型循环依赖的实用排查思路原型循环依赖没有太好的自动解法。如果业务上确实需要可以尝试拆分发把循环链路中的某个依赖改成ObjectProvider或ApplicationContext.getBean延迟获取把“创建时依赖”变成“使用时获取”。这是非常有效的策略因为它把创建期的循环打破改成了运行期的按需获取代价是需要接受一定的耦合和控制反转退让。4. Async和AOP代理最容易被忽略的“假解决”与真翻车4.1 三级缓存是如何处理正常AOP代理的要理解Async场景为什么特殊先要看懂三级缓存和AOP代理之间的关系。在普通AOP场景里Bean A被切面代理是很常见的事。假设A和B互相依赖A是被切面代理的BeanSpring在创建A时提前暴露到第三级缓存的ObjectFactory会在真正被调用时执行getEarlyBeanReference方法。AbstractAutoProxyCreator中重写了getEarlyBeanReference它会提前创建AOP代理public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }这意味着如果A是切面代理的Bean当B在创建过程中需要A的早期引用时三级缓存里生成的早期引用就已经是代理对象而不是原始对象。这样B拿到的是代理后续方法调用都经过切面行为符合预期。三级缓存就是这么巧妙地做到“循环依赖和AOP代理共存”的。但也正是这个提前代理机制暴露出了另一批问题如果一个Bean被提前暴露过它的原始对象已经被记录在earlyProxyReferences里那么后续在Bean初始化完成后AbstractAutoProxyCreator的postProcessAfterInitialization不会再包裹一次代理而是直接返回早期引用。这个逻辑本身没问题问题出在部分增强处理并不走postProcessAfterInitialization的常规AOP代理链。4.2 为什么Async场景会翻车Async在Spring里的实现本质上是AOP代理但它的代理创建工作发生在Bean初始化完成之后由AbstractAutoProxyCreator的postProcessAfterInitialization触发。这里有一个很关键的矛盾如果某个Bean既是循环依赖的参与者又带有Async注解它的创建可能不会走到“初始化完成后二次代理”这一步而是提前暴露了原始对象的引用。结果是什么B拿到了A的早期引用这个引用不是Async代理对象而是原始对象。A的方法调用不会经过异步代理Async注解直接失效。更致命的是这种失效通常不会报错你调用了返回void的异步方法看起来执行了实际上是在调用线程同步执行的。线上排查这种问题相当痛苦因为没有任何异常只有日志埋点能暴露线程名的异常。我在实际项目里遇到过不止一次某个服务启动正常也没有任何报错但异步批量处理方法的日志显示执行线程一直是Tomcat线程而不是taskExecutor的线程排查半天最后定位到是这个Bean参与了循环依赖导致Async代理没有被正确应用。4.3 网上流传的“解法”为什么是在埋雷对于Async 循环依赖的问题网上有一个非常流行的解法在Async方法所在类上强制开启AOP代理的exposeProxy然后从AopContext.currentProxy获取代理对象调用方法或者用ObjectProvider去延迟获取自身代理。这些方案在某些版本和配置下确实能跑但隐患也非常明显第一AopContext.currentProxy依赖exposeProxytrue配置而且代理暴露是线程绑定的异步场景下容易取不到代理。第二从容器里再次获取Bean可能会触发第二次初始化或导致作用域语义混乱。第三这类解法掩盖了循环依赖本身的设计问题让代码结构越来越脆。我的建议是遇到Async 循环依赖先别急着找“绕过方法”回到设计本身看能不能解耦。常见手段是把Async方法所在的依赖拆出去让异步Bean不再参与循环链或者把异步执行逻辑包装成独立组件通过事件/消息机制解耦彻底走出Bean依赖的泥潭。5. 源码走一遍doGetBean里那些容易被忽略的关键判断5.1 单例获取的前置检查流程虽然前面已经在多个场景里提到了getSingleton但真正把循环依赖的源码链路串一遍还是得回到doGetBean的主流程。这里我摘出最核心的逻辑做一个精简版说明。AbstractBeanFactory的doGetBean在获取单例Bean时第一阶段先调getSingleton(beanName)看缓存里有没有成品。没有的话判断当前Bean是否正在创建这一步依赖DefaultSingletonBeanRegistry里的singletonsCurrentlyInCreation集合。Spring在开始创建单例Bean前会调用beforeSingletonCreation记录当前Bean创建完成后调用afterSingletonCreation移除记录。如果依赖方需要获取一个正在创建中的Bean就进入了三级缓存的晋升逻辑先查singletonObjects再查earlySingletonObjects再查singletonFactories第三级缓存里存的ObjectFactory被调用后生成早期引用并晋升到二级缓存。这套机制最精妙的地方在于每个单例Bean在创建过程中只会暴露一次早期引用之后缓存路径就稳定了。5.2 addSingletonFactory的暴露时机决定一切真正决定循环依赖能否被处理的分水岭在doCreateBean方法里if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这段代码的位置在populateBean之前也就是属性填充之前。这也从源码层面印证了前面说的结论只要注入动作发生在实例化过程中而不是属性填充阶段三级缓存就救不了。早期暴露的判定条件是earlySingletonExposure它的赋值依赖于mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)。allowCircularReferences默认是true但可以通过配置关闭。如果你在配置里显式关闭了循环依赖支持那即使setter注入的循环依赖也会直接报错这个参数经常被忽略。5.3 BeanCurrentlyInCreationException的抛出时机接着看一下构造器循环依赖和原型循环依赖的异常到底是怎么触发的。getSingleton方法里有这么一段if (isSingletonCurrentlyInCreation(beanName)) { throw new BeanCurrentlyInCreationException(beanName); }但这里有个细节这个判断不是每次getSingleton都走它只发生在新单例Bean创建之前。也就是说Spring在准备创建A时如果发现A已经在创建中说明上一次创建流程还没结束就又进来了这就是循环依赖的信号。原型Bean也有类似逻辑只是判断集合换成了prototypesCurrentlyInCreation。理解了这几个关键判断点之后再看网上流传的各种报错日志基本一眼就能判断出问题类型如果异常栈里出现两次同一个Bean的创建记录大概率是构造器循环依赖如果异常信息指向isPrototypeCurrentlyInCreation那基本可以断定是原型Bean参与了循环依赖。6. 实战排查我遇到过的几个诡异问题实录6.1 加了Async后突然出现循环依赖报错这个案例印象很深。一个老模块原本一切正常后来为了把批量通知改成异步给Service的方法加了Async注解启动时直接报了BeanCurrentlyInCreationException。一开始以为是新增依赖导致的排查下来发现循环链其实一直存在只是之前没有Async时Spring通过三级缓存可以“静默”处理掉setter注入的循环依赖。加了Async之后代理创建逻辑变得复杂提前暴露的早期引用和异步代理之间产生了冲突。这种问题最坑的地方在于代码的依赖关系没有变变的只是一个注解但Spring内部的处理路径已经彻底不同。遇到这类情况不要试图调试代理创建过程先检查循环依赖链本身能不能剪断。6.2 构造器循环依赖把自己坑了还有一次是接手新项目代码里大量使用构造器注入其中一个核心服务在启动时必然循环依赖。当时团队里习惯了“构造器注入最优雅”的理念没有意识到构造器循环依赖完全不支持。后来把其中一个注入点改成Lazy救场但之后维护成本明显上升新增依赖时经常要判断哪些地方被Lazy延迟了不容易阅读。事后我给自己定了一个规矩构造器注入适合“稳定且不可能循环”的基础依赖比如配置类、工具类业务类之间的依赖尤其是可能形成双向调用的用字段注入加Autowired或者setter注入更安全。虽然网上对字段注入有很多批评但循环依赖角度下它确实是容错性最高的选择。6.3 ObjectProvider的延迟获取是更优雅的解法如果不想用Lazy又想打破构造器循环依赖可以考虑ObjectProvider。Spring从4.3版本开始支持ObjectProvider注入它本质上是一个延迟解析的引用Component public class A { private final ObjectProviderB bProvider; public A(ObjectProviderB bProvider) { this.bProvider bProvider; } public void doSomething() { B b bProvider.getIfAvailable(); } }用ObjectProvider替代直接注入后A的创建不再强依赖B的创建循环就断了。这个方法比Lazy更清晰因为它把延迟加载的意图直接写在了构造器签名里一看就知道这个依赖是“按需获取”的。6.4 快速判断循环依赖类型的方法我整理了一个判断循环依赖问题的速查逻辑排查新报错时可以按这个顺序走异常或现象大概率原因优先处理方式启动即抛BeanCurrentlyInCreationException堆栈反复出现两个Bean构造器注入循环依赖改字段/setter注入或加Lazy异常栈里有prototype创建记录原型Bean参与循环依赖拆分依赖或改用ObjectProvider/ApplicationContext按需获取启动正常但Async方法同步执行Async Bean参与循环依赖导致代理未生效拆解循环链让异步Bean不依赖回环对象修改配置后突然循环依赖报错allowCircularReferences被显式关闭打开配置或审视依赖设计是否过度耦合这套速查表不覆盖所有边界情况但能解决大部分实际问题。6.5 几个独家避坑经验最后分享几个写代码时就能规避循环依赖的实用经验。第一个所有新建的单例Bean在创建时先问一句“它会不会在属性填充阶段被其他正在创建的Bean反向依赖”。如果答案有可能就不要把它放进构造器注入的参数列表。第二个一个Bean如果加了Async就默认假设它不参与任何循环依赖。依赖它的Bean要么在更晚的阶段获取它要么通过事件机制调用。第三个使用Transactional和Async这类依赖代理的注解时要额外注意循环依赖。因为这类Bean的核心功能高度依赖代理生效而循环依赖的提前暴露恰好会绕过代理。我在实际项目里还发现一个规律循环依赖出现比较多的模块往往不是“设计规范”能解决的而是模块边界划分出了问题。A要调BB要调A很多时候意味着公共逻辑应该下沉到C由A和B分别依赖C。如果能在设计阶段把这种双向调用关系拆开后面能省掉大量排查时间。说回Spring本身它对循环依赖的支持其实是一种“尽力而为”的宽容处理对最常见的单例setter注入循环依赖通过三级缓存优雅解决对其他场景果断选择报错或者牺牲部分语义。理解这个边界比记住几个注解和配置管用得多。
返回列表