ARTICLE DETAIL

资讯详情

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

Spring循环依赖源码解析:三级缓存能解决什么,解决不了什么

Spring循环依赖源码解析:三级缓存能解决什么,解决不了什么 这个标题是我面试别人时最爱用的一个问题。能把“Spring三级缓存”背得滚瓜烂熟的候选人不少但只要追问一句“那哪些循环依赖问题是Spring解决不了的”十个人里能有七八个当场卡住。原因也很简单大部分人都只记住了结论没真正看过DefaultSingletonBeanRegistry和AbstractAutowireCapableBeanFactory里的代码路径自然不知道三级缓存的边界在哪里。这篇文章我就从源码执行路径出发把Spring能解的、不能解的循环依赖全部盘一遍每个结论都标注对应的源码位置和触发时机。看完之后你不仅能回答面试官碰到线上类似报错也能第一时间定位到根因。1. 三级缓存不是万能药先把Spring手里的牌面看清要理解哪些循环依赖解决不了首先得知道Spring解决循环依赖靠的是什么。说白了就是DefaultSingletonBeanRegistry里的三张表外加singletonsCurrentlyInCreation这个创建状态标记。1.1 三张缓存表各自的职责网上讲三级缓存的文章很多但大部分都停留在概念层面。我直接把这些缓存对应的数据结构和使用时机完整摊开来看。缓存层级数据结构存储内容写入时机读取时机一级缓存singletonObjectsMapString, Object完全创建好的单例BeanBean初始化完成后任何时候获取单例Bean二级缓存earlySingletonObjectsMapString, Object提前暴露的早期Bean引用可能未完成属性填充和初始化从三级缓存获取后升级循环依赖中其他Bean获取当前Bean时三级缓存singletonFactoriesMapString, ObjectFactory?早期Bean的对象工厂Bean实例化完成后检测到循环依赖时lookup时的顺序也值得记一下先查一级缓存再查二级缓存最后查三级缓存。三级缓存取到ObjectFactory后执行它的getObject()拿到早期引用然后立刻删掉三级缓存里这个条目把引用放入二级缓存。这个“删三级、存二级”的动作保证了同一个单例Bean在创建过程中早期引用只会被工厂生产一次。1.2 为什么三级缓存放的是 ObjectFactory 而不是直接放对象这是源码设计里最精髓的一个点。早期引用这个东西正常情况下每家只需要一个但如果Bean后续需要AOP代理呢看AbstractAutoProxyCreator.getEarlyBeanReference()// AbstractAutoProxyCreator.java Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean, beanName); if (!this.earlyProxyReferences.contains(cacheKey)) { this.earlyProxyReferences.add(cacheKey); } return wrapIfNecessary(bean, beanName, cacheKey); }这个方法什么时候被调用只有当某个Bean正在创建中、且被其他Bean引用时三级缓存里的ObjectFactory执行getObject()才会走到这里。这里面的关键在于earlyProxyReferences。如果Bean在实例化后一直没有被循环引用那么getEarlyBeanReference根本不会被触发这个Bean之后会走正常的initializeBean流程在BeanPostProcessor的postProcessAfterInitialization阶段生成代理。如果一实例化就无脑把代理对象扔到二级缓存里那么每个Bean不管有没有被循环引用都会提前被代理一遍这显然是对性能和行为的双重污染。所以三级缓存里塞ObjectFactory的本质是延迟决策先把“是否需要代理”这个判断延后到“必须返回引用”的那一刻再执行。你可以理解为先答应把东西借给别人但具体借出去的是原件还是复印件等对方真正上门来取的时候再定。1.3 实例化和初始化的分离是根本前提Spring能解决循环依赖最底层的原因是doCreateBean里把Bean的创建过程拆成了两步createBeanInstance()通过构造器反射创建出一个半成品实例populateBean()initializeBean()填充属性、执行初始化方法拆开之后半成品就有机会在属性填充之前被提前暴露出去。这个时机在源码里卡得相当死看AbstractAutowireCapableBeanFactory.doCreateBean()// AbstractAutowireCapableBeanFactory.java protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final Nullable Object[] args) throws BeanCreationException { // 1. 实例化此时可能已经依赖了其他Bean构造器注入 BeanWrapper instanceWrapper null; if (mbd.isSingleton()) { instanceWrapper this.factoryBeanInstanceCache.remove(beanName); } if (instanceWrapper null) { instanceWrapper createBeanInstance(beanName, mbd, args); } ... boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { // 2. 提前暴露把ObjectFactory放入三级缓存 addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); } // 3. 属性填充setter/字段注入 populateBean(beanName, mbd, instanceWrapper); // 4. 初始化 exposedObject initializeBean(beanName, exposedObject, mbd); ... }注意第2步的判断条件里有个isSingletonCurrentlyInCreation(beanName)它扫描的是singletonsCurrentlyInCreation这个Set。这说明Spring在实例化Bean之前就已经把这个BeanName放进了“创建中”集合里。这个标记是循环依赖检测的哨兵后面isPrototypeCurrentlyInCreation走的是同一套路。理解了这张牌面再去分析“哪些解决不了”才不会看哪儿都觉得是循环依赖。2. A依赖B、B依赖A单例场景下的完整通关流程先把Spring能解决的标准场景走一遍。这个场景里A和B都是单例Bean且通过setter或字段注入互相依赖。2.1 一次完整的getSingleton调度过程假设先创建AgetSingleton(a)调用一级缓存没有singletonsCurrentlyInCreation加入“a”进入doCreateBean创建AcreateBeanInstance反射构造出A的早期实例A满足earlySingletonExposure条件addSingletonFactory(a, factory)把A的早期引用工厂放入三级缓存执行populateBean给A填充属性发现A依赖B触发getBean(b)创建B同样走一遍doCreateBeanB实例化完成B的属性填充发现自己依赖AB执行getSingleton(a, true)一级缓存没有二级缓存没有三级缓存有A的工厂执行getObject()拿到A的早期引用这个早期引用被放入二级缓存同时删除三级缓存的A条目B把A的早期引用注入到自己属性里B完成populateBean和initializeBeanB被放入一级缓存回到A的populateBean从一级缓存拿到完整B注入给AA继续执行initializeBean完成后放入一级缓存整个过程里A的早期引用在步骤8就确定下来了。后面A即使有代理逻辑走的也是getEarlyBeanReference提前代理好的那个对象而不是再包一层。2.2 无循环依赖时三级缓存形同虚设很多人没意识到的是如果所有Bean都不存在循环依赖那么三级缓存里的工厂大部分时候是永远不会执行的。getSingleton里对三级缓存的访问有一个前置条件——isSingletonCurrentlyInCreation(beanName)// DefaultSingletonBeanRegistry.java 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) { ... singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } return singletonObject; }只有目标Bean正处于“创建中”才会去看二级、三级缓存。否则直接走一级缓存查完整对象。所以Spring这一套三级缓存机制平时安安静静躺在那里只有在BeanA创建到一半回头要BeanB、BeanB又回头要BeanA的时候才真正开始运转。2.3 为什么二级缓存加标记位替代不了三级缓存单独用二级缓存理论上也能解决循环依赖只要在addSingletonFactory时直接调用getEarlyBeanReference拿到对象存入二级缓存就行。但这会带来一个问题没被循环引用的Bean也会被迫提前生成代理或早期引用。还是回到AbstractAutoProxyCreator.getEarlyBeanReference它调用wrapIfNecessary创建代理的时候会把目标对象和代理的关系记录在earlyProxyReferences里。这个提前代理的结果会影响后续initializeBean时postProcessAfterInitialization的判断Override public Object postProcessAfterInitialization(Nullable Object bean, String beanName) { if (bean ! null) { Object cacheKey getCacheKey(bean, beanName); if (this.earlyProxyReferences.remove(cacheKey) ! bean) { return wrapIfNecessary(bean, beanName, cacheKey); } } return bean; }这段代码的意思是如果这个Bean在早期暴露阶段已经被代理过那么初始化完成后就不再重复代理。如果没有被早期代理过这里才正常生成代理对象。假如Spring把所有Bean在实例化后都第一时间放入二级缓存——也就是强制所有Bean“提前暴露”——那么代理的时机就被提前了每次从二级缓存拿到早期引用的人都会直接拿到代理对象。这会导致大量不需要代理的单例Bean也提前进入代理创建流程而且任何在早期阶段之后再注册的BeanPostProcessor增强逻辑都可能失效因为对象已经定型了。三级缓存的存在价值是给“是否生成早期代理”留了一个按需触发的开关。没有循环依赖就不打开有循环依赖才打开。这个设计思想我在翻源码的时候越看越觉得妙。3. 四大死局这些循环依赖Spring根本解不开现在我们进入正题。下面这四类循环依赖Spring单靠自身机制是解不开的每一类我都会给出源码层面的根因。3.1 死局一构造器注入的循环依赖这是面试中最高频的答案很多人知道这个结论但说不出为什么。构造器注入的解不开根本原因在于createBeanInstance发生在addSingletonFactory之前。构造器参数必须实打实获取到依赖Bean的实例此时当前Bean连早期引用都还没暴露到三级缓存里。// AbstractAutowireCapableBeanFactory.java protected BeanWrapper createBeanInstance(String beanName, RootBeanDefinition mbd, Nullable Object[] args) { ... // 解析构造器参数如果参数是一个尚未创建的Bean这里会触发getBean Constructor?[] ctors determineConstructorsFromBeanPostProcessors(bean, beanName); if (ctors ! null || mbd.getResolvedAutowireMode() AUTOWIRE_CONSTRUCTOR) { return autowireConstructor(beanName, mbd, ctors, null); } ... }autowireConstructor解析构造器参数的时候如果A的构造器需要B而B的构造器又需要A那么A在等B完成创建B在等A完成创建但A根本没有把早期引用暴露出去因为addSingletonFactory这行代码还没执行到这就像两个人约好见面A说“等B出门我再出门”B说“等A出门我再出门”结果两人永远都在家里耗着。最终getSingleton检查singletonsCurrentlyInCreation发现A已经在创建中直接抛出BeanCurrentlyInCreationException。3.2 死局二prototype作用域的循环依赖单例Bean能靠三级缓存“拖”一下是因为有缓存可以暂存半成品。prototype作用域的Bean每次getBean都返回新实例根本没有一级缓存这层“缓冲垫”。看AbstractBeanFactory.getBean的调用链prototype在创建之前会执行beforePrototypeCreation把当前BeanName放入prototypesCurrentlyInCreation// AbstractBeanFactory.java if (mbd.isPrototype()) { Object prototypeInstance null; try { beforePrototypeCreation(beanName); prototypeInstance createBean(beanName, mbd, args); } finally { afterPrototypeCreation(beanName); } return prototypeInstance; }理论上Spring可以在prototype之间互相引用时给出一个更直白的错误但实际它的表现就是无限递归创建最终在prototypesCurrentlyInCreation检查时抛出BeanCurrentlyInCreationException。日志里通常能看到这样的信息Requested bean is currently in creation: Is there an unresolvable circular reference?任何时候看到这条异常先看一眼作用域。如果Bean是prototype不用怀疑这类问题Spring从设计上就没打算解。3.3 死局三DependsOn 显式依赖形成的环DependsOn是用来控制Bean初始化顺序的但它和自动注入循环不在一个处理链路上。在doGetBean的最前面Spring会先处理显式依赖// AbstractBeanFactory.java String[] dependsOn mbd.getDependsOn(); if (dependsOn ! null) { for (String dep : dependsOn) { if (isDependent(beanName, dep)) { throw new BeanCreationException(mbd.getResourceDescription(), beanName, Circular depends-on relationship between beanName and dep ); } registerDependentBean(dep, beanName); try { getBean(dep); } catch (NoSuchBeanDefinitionException ex) { ... } } }也就是说创建A之前必须先把它的dependsOn目标B创建出来而B如果又声明了dependsOn A那么isDependent检测到相互依赖直接抛出BeanCreationException文案是 “Circular depends-on relationship”。这个和构造器循环很像但它发生在更早的阶段——早在getSingleton加入创建中标记之前Spring就已经通过dependentBeanMap和dependenciesForBeanMap两张表把依赖关系记录在案了所以能及时发现并拒绝。3.4 死局四AOP代理Bean与裸早期引用的错位这是线上最容易踩的一个坑也是最难从报错信息直接看出来的。典型的触发场景是A和B互相依赖且B上使用了Async或Transactional这类需要AOP增强的注解。我在排查一次Async循环依赖问题时控制台打出了这么一段异常Bean named b is expected to be of type com.example.B but was actually of type jdk.proxy2.$Proxy90更常见的是下面这条Bean with name b has been injected into other beans [a] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean.问题的根源说出来其实不复杂先前getEarlyBeanReference已经把B的早期引用给了A但早期暴露阶段生成的代理并不包含Async背后那个增强逻辑。等到B初始化完成后AbstractAdvisingBeanPostProcessor又基于B生成了另一个包含异步增强逻辑的最终代理对象。早期引用和最终对象不一致Spring无法把最终对象塞回已经注入给A的引用里只能抛出异常提示你这里发生了循环依赖而且早期暴露的那个“裸引用”和你最终要用的“包装后对象”对不上。这里要赘述一下为什么Async这么容易踩雷AsyncAnnotationBeanPostProcessor继承自AbstractAdvisingBeanPostProcessor它是在postProcessAfterInitialization阶段生成代理的而循环依赖的早期引用生成走的是SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference扩展点。AbstractAdvisingBeanPostProcessor在大多数Spring版本里并没有实现这个早期引用方法所以早期暴露阶段拿到的B压根没有异步代理的逻辑。等最终代理出来之后两者已经无法合并了。4. 从异常信息反推根因一道源码题的定位思路看源码的人最终都要落到解决实际问题。我提供一个非常实用的排错思路先看异常类型再看堆栈前几行最后确定创建链路。4.1 报错信息对照表报错关键字对应的死局类型关键源码位置BeanCurrentlyInCreationExceptionRequested bean is currently in creation构造器循环 / prototype循环DefaultSingletonBeanRegistry或AbstractBeanFactoryCircular depends-on relationshipDependsOn显式依赖环AbstractBeanFactory.doGetBeanhas been injected into other beans ... raw version ... but has eventually been wrappedAOP代理与早期引用错位AbstractAutowireCapableBeanFactory.doCreateBean末尾的exposedObject检查The dependencies of some of the beans in the application context form a cycleSpring Boot 2.6 启动时全局检查DependencyInjectionApplicationContextInitializer4.2 通过堆栈定位到具体哪种创建链路上面四类异常里前两类最容易混淆。看堆栈可以很快区分构造器循环的堆栈里必然会出现at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBeanprototype循环的堆栈里则会出现at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean重复出现的doGetBean调用栈会把递归路径完整暴露出来。再过一遍exposedObject那段检查代码它写在doCreateBean的收尾部分if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { exposedObject earlySingletonReference; } else if (!this.allowRawInjectionDespiteWrapping hasDependentBean(beanName)) { // 这里就是“已注入早期引用但最终对象被包装”的报错出处 } } }这个判断逻辑很有意思。如果exposedObject就是原始Bean对象说明初始化后没有代理加工直接采用早期引用即可。如果exposedObject被包装成了新对象比如B被Async代理了那么早期引用和最终对象就要打架了。4.3 Spring Boot 2.6 之后默认拒绝循环依赖说完源码再说一层现实问题。Spring Boot 2.6 开始spring.main.allow-circular-references默认变成了false。也就是说即使依赖注入是setter方式、Spring三级缓存理论上能解Boot也会在启动时直接拒绝提示Description: The dependencies of some of the beans in the application context form a cycle这个变化导致很多老项目升级Spring Boot后启动直接失败。临时解法很简单spring.main.allow-circular-referencestrue但这是一个治标不治本的口子。允许循环依赖意味着放宽了对依赖图质量的检查长期来看代码会越来越难维护。我在实际项目中见过一个Service类互相调用形成四五个Bean的循环网最后每次启动都要靠这个开关续命的情况。5. 解不动的循环依赖实战里怎么破源码层面搞清楚了“为什么解不开”接下来就是工程师真正要关心的问题代码已经写成这样了怎么改5.1 首选方案重构依赖结构循环依赖本质上是一种设计坏味道。两个Service互相调用往往说明职责边界划得不对。最常见的一招是把互相调用的公共逻辑抽到第三个组件里。假设A和B相互依赖A需要B的getData()B需要A的validate()可以抽象出一个C来承载这些公共方法让A和B都依赖C循环自然消失。第二种常见做法是引入事件机制。A完成某个操作后发布事件B监听事件再做自己的处理两个Bean之间不再有编译期的直接依赖运行期通过ApplicationEventPublisher解耦。第三招是让其中一个依赖变成延迟获取。这解决不了设计问题但可以快速打破在创建期的硬依赖具体见下面两种实现方式。5.2 Lazy用代理占位符打破死锁Lazy能解决构造器注入循环的原因是它注入了代理占位符而不是立刻触发目标Bean的创建。日志里看不到getBean调用代理对象只有到真正调用方法时才会通过TargetSource从容器里获取真实目标Service public class A { private final B b; public A(Lazy B b) { this.b b; } } Service public class B { private final A a; public B(A a) { this.a a; } }创建A时构造器参数B被替换成一个B的代理占位对象这个代理不需要B先完成创建。A顺利构造并暴露早期引用然后开始创建BB构造器里的A走正常的循环依赖路径拿到A的早期引用。等到B创建完成A的构造器早就执行完了代理占位符在后续第一次被调用时才去容器拿真实B。这个方案的代价是引入了一层代理排查问题的时候代理会“吃掉”一层真实调用栈。能用重构解决的优先重构Lazy是万不得已的补丁。5.3 ObjectProvider把依赖获取延后到业务时刻Java Config里还有一种延迟获取的写法用ObjectProvider显式包装依赖。这比Lazy更透明因为依赖关系在编译期就看得清清楚楚Service public class A { private final ObjectProviderB bProvider; public A(ObjectProviderB bProvider) { this.bProvider bProvider; } public void doSomething() { B b bProvider.getIfAvailable(); // 使用b } }ObjectProvider在构造器注入阶段不触发getBean(b)只有调用getObject()/getIfAvailable()时才去容器查找。这相当于把“我需要B”这个动作从创建期挪到了业务代码里循环依赖自然也就不存在了。5.4 Async 循环依赖的专项解法如果你遇到的是Async/Transactional参与循环依赖导致的“已注入raw version但最终对象被wrap”的报错上面这些方案通通适用但有一个更对口的思路不要让异步代理的Bean成为其他Bean的早期引用依赖。实操中有两个可行方向方向一是拆分异步逻辑。把包含Async的方法挪到一个独立的组件里让它专门负责异步执行原来的Bean只注入这个新组件。这样异步代理不会出现在循环链的关键位置上。方向二是使用AspectJ方式的代理。spring.aop.proxy-target-classtrue可以强制CGLIB代理但这不是解决错位问题的关键。早期引用和最终代理不一致的根因是异步增强发生在postProcessAfterInitialization而早期引用发生在更早阶段。让异步Bean提前在早期引用阶段生成包含增强逻辑的代理才是正解。Spring在高版本里实际上已经在调整AsyncAnnotationBeanPostProcessor的行为但不同版本表现不完全一致依赖版本特性解决生产问题太冒险不如从代码结构上拆开。5.5 两个容易被忽视的工程细节最后说两个我在实践中经常看到人踩的小坑。第一个是循环依赖的启动报错不一定在依赖注入阶段。很多项目引用了spring-cloud-starter-*之类的框架框架内部也会注册一些Bean并形成循环依赖出现问题的Bean名字跟业务代码完全无关。这时候不要急着改业务代码先把报错里提到的BeanName和BeanPostProcessor名字捞出来看看是不是框架的锅。第二个是**DependsOn报错不一定会指明是哪个字段引用的**。它只会告诉你两个Bean之间存在显式的depends-on关系查起来更费劲。这时候全局搜一下DependsOn注解把所有显式依赖环列出来逐对排查比瞎猜快得多。Spring解决循环依赖靠的是三级缓存但它能兜住的只是“单例Bean setter/字段注入 不需要AOP增强或早期代理”的理想情况。构造器注入、prototype作用域、显式依赖、代理对象错位这四类问题是顺着源码逻辑设计出来的边界不是版本bug。理解了这层边界你才算真正看懂了Spring的Bean生命周期。
返回列表