十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring循环依赖与三级缓存源码解析:从现象到原理

Spring循环依赖与三级缓存源码解析:从现象到原理 有没有遇到过这种场景项目启动的时候一切正常但某一天加了一个新功能Spring容器启动到一半直接报了个错日志里赫然写着BeanCurrentlyInCreationException后面跟着一句话大概是“Requested bean is currently in creation: Is there an unresolvable circular reference?”。如果没见过说明你运气好或者项目里对象关系一直比较规矩。如果见过那当时大概率是一脸懵我明明没有写递归调用怎么就说我循环引用了其实这就是Spring里最常被问到、也最容易在面试中展开的“循环依赖”问题。网上解析这个问题的文章一抓一大把但很多都停留在“三级缓存”这个名词上翻来覆去就那几句一级缓存存成品、二级缓存存半成品、三级缓存存工厂。至于为什么非要有三级、二级行不行、AOP代理又掺和进来干了什么讲清楚的并不多。这篇文章我想换个角度不堆概念直接从问题出发把循环依赖这件事从“现象”拆到“原理”再落到“源码”和“排查手段”。如果你正在准备面试或者项目里已经踩了循环依赖的坑又或者纯粹是想把手写Spring的底子打牢一点这篇都值得耐心看完。1. 循环依赖的核心问题1.1 什么是循环依赖它在什么场景下出现循环依赖这个概念本身不复杂两个或两个以上的Bean在创建过程中互相引用形成一个闭环。最典型的就是A依赖B、B又依赖A。稍微绕一点的还有A依赖B、B依赖C、C又依赖A这种链路型的循环。本质上都一样就是依赖关系图里出现了一个环。但在真实的业务代码里这种“环”往往不是一眼就能看出来的。我见过一个比较典型的例子订单服务依赖用户服务用户服务依赖消息服务消息服务又反向依赖订单服务去查订单信息。三个类表面上看都属于正常的业务调用但把它们画成依赖图就是一个三角形闭环。Spring容器在默认情况下是单例模式管理Bean的。单例意味着整个容器里同一个Bean只有一个实例。Spring创建Bean的过程大致分两步先通过构造器实例化出一个原始对象然后再往里填充属性也就是依赖注入。当容器创建A时发现A需要B于是转去创建B创建B时发现B需要A于是又转回来找A。这时候A还没创建完只是一个半成品如果容器直接说“我没有A”那B就创建不下去A也永远等不到B整个启动直接失败。这就是BeanCurrentlyInCreationException的来源。所以循环依赖本质上是一个“先有鸡还是先有蛋”的问题A要等B创建完才能完成自己B要等A创建完才能完成自己。如果没有任何机制打破这个僵局Spring容器就只能抛出异常启动失败。1.2 为什么只有单例Bean需要关心循环依赖这里需要先厘清一个容易混淆的点循环依赖不是所有作用域下都能解决的。Spring里Bean的作用域有很多种但绝大多数项目里用的都是singleton也就是单例。单例Bean的特点是容器启动时就创建好整个生命周期只有一个实例。因为只有一个Spring才能“提前暴露”一个不完整的引用给别的Bean用——反正最后创建完也是同一个对象引用不会变。这就是解决循环依赖的前提条件。如果是prototype作用域每次获取都是新对象那就完全没法用“提前暴露”的思路。你暴露出去一个引用下一次再取又是另一个对象逻辑上无法保证一致性。所以Spring对原型Bean的循环依赖完全不处理遇到了直接报错。这一点很多人容易忽略面试里也经常被追问。现在再看那句话——“Spring能解决循环依赖”准确的说法应该是Spring能解决单例模式下、基于属性注入的循环依赖。少了这三个限定条件结论就不成立。1.3 实例化与初始化的分离是理解一切的钥匙要真正看懂Spring的解决方案必须先明白一个基础概念Spring创建Bean的过程不是一步到位的而是分成了“实例化”和“初始化”两个阶段。实例化指的是调用构造器new出一个对象。这时候对象已经存在了但里面的属性全是默认值或者说还是空的。初始化则是指往这个对象里填充属性、执行各种BeanPostProcessor的增强逻辑、如果有AOP代理还要生成代理对象。用大白话说实例化是“把一个人生下来”初始化是“给他配齐装备、完成训练”。循环依赖之所以能解关键就在于这两个阶段之间存在一个时间差。Spring的思路是A在实例化完成之后、还没开始初始化之前先把A的早期引用早期引用就是那个还没填充属性的原始对象放进一个缓存里。这样当B需要A时Spring发现缓存里有A的早期引用就直接把它交给B。B拿到A的引用后可以继续完成自己的创建和初始化。等B彻底创建完A再回过头来把B注入到自己里面完成自己的初始化。整个过程中A和B拿到的都是同一个对象引用没有人需要等待对方“完全创建好”僵局自然就打破了。这套机制就是Spring容器里那三道缓存存在的意义。2. 三级缓存的设计与定位2.1 一级、二级、三级缓存各自做什么Spring解决循环依赖靠的是三个Map也就是俗称的三级缓存。源码里定义在DefaultSingletonBeanRegistry这个类中名字分别是singletonObjects、earlySingletonObjects和singletonFactories。先记住这三个Map各自的作用和存放对象。一级缓存singletonObjects存放的是创建完成、属性填充完毕、可能已经经过代理增强的完整Bean也就是我们平时通过getBean拿到的那个对象。二级缓存earlySingletonObjects存放的是已经实例化但还没完成初始化的早期对象用来提前暴露给其他Bean。三级缓存singletonFactories存放的是ObjectFactory注意三级缓存里存的不是对象本身而是一个“工厂”——一个可以产生早期引用的函数式接口。用一个表格来对比会清楚得多缓存层级内部Map存放内容作用一级缓存singletonObjects成品Bean最终获取Bean的入口二级缓存earlySingletonObjects半成品Bean早期引用提前暴露给依赖方避免重复创建三级缓存singletonFactoriesObjectFactory工厂延迟生成早期引用兼顾AOP代理这个设计里有一个很关键的点Spring并不是直接把半成品放到二级缓存而是先把工厂放到三级缓存。至于为什么要绕这么一圈这是下一节的重点。2.2 为什么需要“提前暴露”这个操作在没有循环依赖的正常场景下一个Bean的生命周期是实例化、初始化、放入一级缓存。其他Bean依赖它时直接从一级缓存拿成品就行二级和三级缓存根本用不上。一旦出现循环依赖情况就变了。A创建到一半时B需要A。这时候A还在一级缓存里没有位置如果Spring直接把“当前还没有A”这个结论返回给B启动就失败了。所以Spring必须提前把A的某种形态暴露出来让B能拿到一个“虽然还没完工但确实是A”的引用。这就是“提前暴露”这个说法的来源。操作上对应两个动作实例化A之后立刻把A的工厂放入三级缓存以及在B真正需要A时通过工厂生成A的早期引用放入二级缓存并把这个早期引用交给B。注意一个细节B拿到A的早期引用时A的属性其实还是空的。如果此时B调用A的某个业务方法而这个方法依赖了A自己的属性就会拿到null或者触发空指针。所以Spring解决循环依赖解决的是“能不能启动”的问题至于业务上“启动后调用有没有问题”那是另一回事后面讲注意事项时会单独展开。2.3 源码中的判断逻辑先从成品找再从半成品找最后用工厂造如果你打开源码跟踪一次getSingleton方法会发现整个查找过程其实是一套非常朴素的“从快到慢、从完整到不完整”的顺序查找。Spring首先去一级缓存singletonObjects里找这里存的都是完整Bean能找到就直接返回这是最快路径。找不到就去二级缓存earlySingletonObjects里找。这里存的是已经提前暴露的半成品。如果命中说明当前确实有Bean处在创建中途且已经暴露过早期引用直接返回。二级缓存也找不到才会去三级缓存singletonFactories里找工厂。取到工厂后执行getObject()方法得到一个早期引用然后把这个早期引用放入二级缓存同时把对应的工厂从三级缓存里移除。为什么要移除因为工厂是一次性的用完就没必要继续保留了后续再有人要这个Bean直接从二级缓存拿早期引用就行不用重复执行工厂逻辑。这个顺序也解答了一个常见的疑惑为什么不是直接去三级缓存拿。原因是Spring要先保证“有成品优先用成品”只有确实没有成品才考虑用半成品过渡。3. 三级缓存机制的核心为什么必须是三级3.1 把“为什么要三级”拆开看很多人背过“三级缓存解决循环依赖”这个结论但被问到“二级缓存行不行”时就卡住了。这个问题其实非常值得展开因为它直接触及Spring设计的核心权衡。先做一个假设如果只有一级缓存也就是只有一个存Bean的Map那A实例化后放进这个MapB来拿A时能从Map里取到循环依赖好像也能解决。但这个方案有个致命问题Map里同时存在成品和半成品调用方无法区分。如果某个Bean还没初始化完就被别人拿去用了而这个Bean又需要AOP代理那拿到手的对象跟最终容器里的对象根本不是同一个程序会跑出莫名其妙的问题。那用两级缓存呢一级缓存放成品二级缓存放半成品。看起来已经能区分状态了也存在一个可行性方案A实例化后直接把原始对象放入二级缓存B从二级缓存拿到A的原始引用等A彻底创建完再把自己放入一级缓存。这样确实能解决循环依赖但有个问题如果A需要AOP代理该在什么时候创建代理如果A在实例化后、放入二级缓存前就创建代理那不管有没有人循环依赖AA都会提前生成代理对象。但Spring的默认逻辑是AOP代理在Bean初始化完成后才统一生成提前生成会让AOP的执行时机变得很不可控。如果A不提前生成代理等初始化完再生成那B从一开始拿到的就是无代理的原始对象。A最终生成代理后容器里存放的是代理对象B持有的却是原始对象。这两个对象不是同一个一旦A的某些方法被切面拦截B调用A时走的完全不是代理逻辑AOP直接失效。3.2 ObjectFactory如何兼顾“需要才生成”和“统一后处理”三级缓存里存放的不是对象本身而是ObjectFactory。这个工厂的核心价值在于“延迟”和“按需触发”。当B依赖A时Spring会从三级缓存中取出A对应的工厂执行工厂方法获取A的早期引用。这个工厂方法的内部逻辑很关键它会调用getEarlyBeanReference方法。如果A不需要AOP代理那这个方法直接返回原始对象如果A需要AOP代理它会提前生成一个代理对象并返回。这样做的好处是只有当确实存在循环依赖、确实有人需要提前拿A的引用时A才会提前生成代理。如果A从头到尾没有被人循环依赖那三级缓存里的工厂可能永远不会被触发A的代理依然按照正常流程在初始化完成后统一生成。这就在“不破坏Spring原有代理时机”的前提下解决了循环依赖问题。够巧妙也很值得记下来三级缓存存在的根本原因是让AOP代理的生成时机可以被延迟决定。如果没有AOP两级缓存就已经够用正因为Spring要同时兼顾AOP才必须引入第三级。3.3 SingletonFactory里到底发生了什么这里把三级缓存中的工厂逻辑再往下挖一层。ObjectFactory的getObject()方法最终会走到AbstractAutowireCapableBeanFactory的getEarlyBeanReference方法。这个方法会遍历容器中所有的SmartInstantiationAwareBeanPostProcessor调用它们的getEarlyBeanReference方法。在Spring的默认实现里最关键的一个处理器是AbstractAutoProxyCreator。它做的事情是如果当前Bean需要被代理就在这里提前创建代理对象如果不需要就返回原始对象。所以整个流程可以这么理解循环依赖发生时三级缓存里的工厂不需要提前知道A到底要不要代理它只需要在“被需要的那一刻”去问一圈BeanPostProcessor。需要就当场生成代理不需要就返回原对象。所有决策都发生在“有人来取”的瞬间时机刚刚好。这也是为什么手写Spring的时候很多人简化掉三级缓存后总觉得哪里不对劲——表面上是少了一个Map实际上是丢掉了这个“延迟决策AOP”的关键机制。4. 源码级拆解一次完整的循环依赖解决流程4.1 从getBean到doCreateBean的调用链这一节我们走一遍源码只看最关键的方法。Spring获取Bean的入口是getBean方法它内部会调用doGetBeandoGetBean里有个核心调用getSingleton(beanName)这一步就是去三级缓存里查找。如果找不到而且当前Bean确实处于创建中就会转去创建Bean。创建的核心方法是createBean最终会走到doCreateBean。这个方法做了三件事实例化Bean、提前暴露Bean、填充属性。实例化通过构造器完成对应createBeanInstance。实例化完成后doCreateBean会判断当前Bean是否满足三个条件单例、允许循环依赖、当前正处于创建中。三个条件都满足就会执行addSingletonFactory把当前Bean的工厂放入三级缓存。这一步就是“提前暴露”。做完暴露动作后Spring才开始真正填充属性对应populateBean。如果循环依赖发生在填充属性阶段三级缓存的工厂就会派上用场。4.2 A依赖B的注入路径和早期引用获取用一个具体例子走一遍全流程假设容器要创建AA依赖BB依赖A。第一步A开始创建实例化出原始对象a然后把一个能返回a的工厂放入三级缓存。第二步A进入属性填充阶段发现需要B转去调用getBean(b)创建B。第三步B开始创建实例化出原始对象b同样把b的工厂放入三级缓存然后进入属性填充发现需要A。第四步B转去调用getBean(a)。此时A还在一级缓存里没有位置但在三级缓存里能找到A的工厂。Spring执行getSingleton(a, true)从三级缓存取出工厂执行工厂方法得到A的早期引用a然后把a放入二级缓存跨过三级缓存中的工厂。第五步B拿到A的早期引用a完成属性填充B初始化完成被放入一级缓存。第六步A从getBean(b)调用中返回拿到的是已经创建完成的B完成自己的属性填充和初始化最终也被放入一级缓存。整个过程里最值得关注的是第四步B拿到的A确实还不是最终形态但这个引用指向的对象和最终A完成初始化后放入一级缓存的对象是同一个对象不考虑代理情况。所以B持有的引用是有效的不会出现引用失效的问题。4.3 创建过程中不同缓存的变化轨迹把上面六步里三个Map的状态变化列出来会更直观。这里把“创建A之前”作为时间点T0“A放入一级缓存”作为时间点T5。T0三个Map均为空。T1A实例化完成一级空二级空三级{A: 工厂A}。T2B实例化完成一级空二级空三级{A: 工厂A, B: 工厂B}。T3B获取A早期引用后一级空二级{A: 原始对象a}三级{B: 工厂B}。T4B创建完成一级{B: 完整对象B}二级{A: 原始对象a}三级{B: 工厂B已经被移除}。T5A创建完成一级{A: 完整对象A, B: 完整对象B}二级{A: 原始对象a}虽然还在但已经没有实际意义三级{空}。注意T5这里有个细节二级缓存里的A早期引用并没有被主动移除。因为此时A已经完成初始化后续获取A的入口已经切到一级缓存二级缓存里这个早期引用就“自然过期”了不会影响正确性。这套状态清理机制也说明一个问题三级缓存只是创建过程中的临时状态Bean创建完成后真正决定对象身份的是它在一级缓存里的位置。5. 哪些场景无法解决以及怎么绕过5.1 构造器注入为什么不行前面已经说过Spring解决循环依赖的前提是“实例化和初始化分离”。构造器注入会打破这个前提构造器注入时对象还没有实例化完成根本不存在一个“可以提前暴露的早期引用”。A的构造器需要BB的构造器需要A。A还没new出来就没有工厂可放B也一样。两个都没法提前暴露形成了真正意义上的死锁。Spring也就在这一步直接抛出BeanCurrentlyInCreationException告诉你循环依赖无法解决。所以如果你的项目里用了构造器注入Spring官方其实推荐这种注入方式同时又存在循环依赖那这个“组合”是致命的。要么改注入方式要么调整对象设计。5.2 Async、代理与循环依赖的经典冲突有一种情况非常隐蔽项目跑着跑着突然启动失败日志指向循环依赖但代码里看起来并没有环。这种问题最常见的原因就是Async注解。Async的实现原理是AOPSpring会在Bean初始化完成后通过AbstractAutoProxyCreator为它生成代理对象。按照正常流程代理在初始化完成后才生成不会有问题。但一旦Bean处于循环依赖中有别的Bean提前来取它的早期引用getEarlyBeanReference就会提前触发代理创建。问题在于Async使用的代理处理器并不会参与getEarlyBeanReference的逻辑。也就是说如果A被B循环依赖了B拿到的A是原始对象而不是Async代理。更麻烦的是A最终完成初始化后Spring发现A已经被提前拿过早期引用就不会再生成新的代理覆盖它。最终容器里的A和B持有的A都是没有异步能力的原始对象Async直接失效。我知道有人会寄希望于Spring后续版本修复这个问题但截至目前Async和循环依赖的组合依然是官方不推荐的用法。解决方案还是那句话尽量避免循环依赖。实在避免不了用Lazy在依赖注入处做延迟加载让A被注入给B时不是直接注入A而是注入一个A的代理从而打断循环。5.3 排查循环依赖的常见手段真的遇到循环依赖问题时定位手段比背结论更重要。我常用的排查套路有三种。第一种启动时加--debug参数或者把日志级别调到DEBUG。Spring在创建Bean时会输出大量的创建过程日志包括“Creating shared instance of singleton bean X”这种信息。通过观察创建顺序很容易把环上的Bean找出来。第二种如果是BeanCurrentlyInCreationException异常信息里会明确写出“currently in creation”的Bean名。拿着这个名字去项目里全局搜索看谁依赖它、它又依赖谁基本就能把环画出来。第三种如果是Async这类隐式代理导致的问题直接看启动日志里有没有“early reference”相关的提示再结合Async的使用位置基本能定位到。这里也给一个实用建议与其等报错再排查不如在设计阶段就梳理清楚对象依赖。如果发现一个Service里注入了超过三四个其他Service而且它们之间互相调用很频繁就要警惕循环依赖的出现。代码里用Lazy确实能绕过去但它掩盖了设计问题属于“治标不治本”。5.4 三个值得记住的注意事项allowCircularReferences默认是trueSpring默认允许循环依赖。如果你在配置里把这个值改成了false那即使代码里明明能解的循环依赖也会直接启动失败。这一点很多人不知道排查时容易绕弯路。循环依赖虽然能解但会给Bean的AOP增强带来不确定性。尤其是在多个代理处理器同时生效时同一个Bean的早期引用和最终对象可能不是同一个导致切面不生效。所以设计上依然是“能避免就避免”。如果循环依赖已经存在且短期内不好改最稳妥的绕过方式是Lazy。它通过生成一个代理对象替换真实依赖从根上阻断循环。缺点是引入了一点动态代理的开销但对大多数业务场景来说这个开销可以忽略。6. 从面试到手写Spring的延伸思考6.1 一个经典的连环追问循环依赖是面试高频题但很多人的回答停留在“三级缓存”四个字上。如果你想答得有区分度可以沿着下面这条线组织思路。先回答“Spring如何解决循环依赖”单例Bean实例化后提前暴露工厂到三级缓存其他Bean依赖时通过工厂获取早期引用最终打破死锁。再回答“为什么需要三级”因为AOP代理的生成时机不能提前确定。三级缓存里存的是工厂能延迟到“有人需要早期引用”的那一刻再决定要不要生成代理兼顾了循环依赖和AOP。再回答“二级缓存行不行”如果没有AOP两级缓存完全够用。有了AOP两级缓存会导致要么提前对所有Bean生成代理、破坏统一时机要么B拿到原始对象导致AOP失效。三级是权衡后的最优解。最后可以补一句“构造器注入为什么不行”因为构造器执行完才产生对象没有早期引用可以提前暴露两个Bean互相等构造器执行直接死锁。这样答下来基本能把面试官想听的几个点都覆盖到。6.2 如果让我手写一个简化版Spring我会怎么设计手写Spring是最近比较热的一个练习方向很多人到循环依赖这一环就会卡住。这里分享一个我实践过的思路不追求和源码完全一致但能还原核心机制。我维护三个MapsingletonObjects存成品earlySingletonObjects存早期引用singletonFactories存工厂。创建Bean时先执行构造器得到实例然后立刻把“能返回这个实例的工厂”放入三级缓存。填充属性时如果发现依赖的Bean还没创建完就通过三级缓存取早期引用并把这个引用提升到二级缓存。等Bean完整创建完再把自己放入一级缓存同时清掉二级和三级缓存里的条目。如果需要支持AOP就在工厂的getObject()里增加getEarlyBeanReference的逻辑判断Bean是否需要提前代理。沿着这个思路写下来你会发现三级缓存并不是什么高深的设计而是一个非常务实的“缓存延迟决策”方案。理解了这层源码里的那些细节就水到渠成了。6.3 聊聊循环依赖背后的设计理念最后想聊点代码之外的东西。Spring解决循环依赖的方案本质上是“用空间换时间”和“延迟决策”这两个通用设计思想的组合。空间换时间体现为多级缓存延迟决策体现为ObjectFactory的按需触发。这种思想在分布式系统、业务架构里也经常出现与其在创建时就把所有事情确定下来不如留一个“工厂”等真正需要时再做判断。理解了这一点你不仅理解了循环依赖也理解了很多中间件里缓存和代理设计的底层逻辑。回到实践层面我还是想重复那句话Spring能解决循环依赖但这不意味着你应该主动制造循环依赖。循环依赖本身就是设计上的一种“坏味道”它让对象之间的关系变成了一张纠缠不清的网增加了理解和维护成本。能用Lazy绕开是止损能通过拆分类、调整分层消除才是治本。我见过很多项目里为了省事搞出循环依赖结果某次改动后启动直接失败最后花了大半天才理清依赖关系——这个成本远远高于一开始就把依赖设计清楚。
返回列表