ARTICLE DETAIL

资讯详情

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

Spring循环依赖与三级缓存:从异常排查到源码原理再到实战治理

Spring循环依赖与三级缓存:从异常排查到源码原理再到实战治理 第一次看到BeanCurrentlyInCreationException的时候我刚从 Go 转 Java 不久第一反应是 Spring 容器写 bug 了。后来翻了一下午源码才明白这是我接触 Spring 容器管理机制以来最经典的一道坎——循环依赖。如果你也在 A 依赖 B、B 依赖 A 的配置里栽过跟头或者准备去面试被问到“Spring 如何解决循环依赖”时只会背三级缓存的名字这篇文章值得你花十几分钟静下心看完。我不会照着官方文档给你复述一遍而是从实际排查、源码断点、异常堆栈和几个真实事故的角度把循环依赖这件事彻底讲透。包括三级缓存到底怎么协作、为什么三级不是两级、哪些循环依赖 Spring 真的解决不了、Async和 AOP 代理为什么会让循环依赖死得更惨最后附一份面试应答参考。写这篇文章的素材来自我给线上服务处理过的两次循环依赖宕机故障以及给团队做源码分享时的验证笔记都是踩过的坑换来的。1. 先搞清楚循环依赖的三种形态和典型报错1.1 什么是循环依赖直觉和实现差距有多大循环依赖的直观定义很简单A 在创建过程中需要注入 B而 B 在创建过程中又需要注入 A两个 Bean 互相等着对方先完成形成死锁。用代码表示就是Component public class A { Autowired private B b; } Component public class B { Autowired private A a; }直觉上这是个死结但 Spring 靠三级缓存把死结解开了所以很多人对此有误解以为所有循环依赖 Spring 都能搞定。真相是Spring 能解决的只是其中一部分而且解决过程非常讲究时机。为了理解哪些能解、哪些不能解我习惯把循环依赖拆成三种形态构造器注入循环依赖A 的构造方法需要BB 的构造方法需要A。两个 Bean 在实例化new阶段就需要对方谁都没法先被创建出来。Setter/字段注入循环依赖A 实例化后Spring 在填充属性populate时才去找 B此时 A 虽然没完全初始化但至少已经是一个“存在”的对象了可以提前暴露给别人。混合模式循环依赖A 用构造器注入 BB 用字段注入 A。这种既涉及构造器、又涉及字段的情况能不能解决取决于从哪边开始创建以及 Spring 在哪个阶段发现循环。后两种有救第一种基本没救。原因我们后面在源码追踪里详细说你现在只要记住一句话Spring 解决循环依赖依赖的是“提前暴露半成品对象”的能力构造器阶段对象还不存在无对象可暴露所以解决不了。1.2 典型报错长什么样怎么从堆栈反推原因遇到循环依赖最常见的报错是一大段BeanCurrentlyInCreationException核心信息长这样org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name a: Requested bean is currently in creation: Is there an unresolvable circular reference?很多刚接触的人看到Requested bean is currently in creation会一头雾水不知道这跟循环依赖有什么关系。实际上这行字的意思是Spring 要创建 Bean A往缓存里查了一圈发现没有 A正准备创建的时候发现当前线程已经在创建 A 了。一个大老爷们不可能同时从两个地方领结婚证一个 Bean 也不可能在同一个线程里被创建两次。所以 Spring 只能直接把这个异常抛出来。我见过一个很有意思的现场某团队在Configuration配置类里用构造器注入两个服务结果服务销毁时又互相调用日志里循环打印销毁日志。那个报错不是创建期间的BeanCurrentlyInCreationException而是BeanCreationNotAllowedException提示Singleton bean creation not allowed while singletons of this factory are in destruction。这类问题属于生命周期冲突不是标准循环依赖但也值得留意排查循环依赖时别盯着异常名称里的“Circular”三个字要优先看它发生在 Bean 的哪个阶段。为了让你直观感受不同注入方式在循环依赖下的表现我整理了一张表注入方式依赖形态是否能被三级缓存解决报错阶段字段注入A 字段注入 BB 字段注入 A能一般不会报错Setter 注入A 的 setter 注入 BB 的 setter 注入 A能一般不会报错构造器注入两边都通过构造器互相依赖不能实例化阶段直接抛BeanCurrentlyInCreationException混合模式A 构造器依赖 BB 字段依赖 A看创建顺序可能报错可能侥幸通过Async代理场景A 依赖 BB 依赖 A且至少一方有Async表面能建但代理失效运行时才知道这张表在后面几节会反复用到你可以先存个印象。2. 三级缓存设计为什么一级不行、两级尴尬、三级将将好2.1 三级缓存各自存什么分别解决什么问题Spring 的单例 Bean 缓存不是一个 Map而是三个 Map源码里定义在DefaultSingletonBeanRegistry中/** Cache of singleton objects: bean name to bean instance. */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** Cache of singleton factories: bean name to ObjectFactory. */ private final MapString, ObjectFactory? singletonFactories new HashMap(16); /** Cache of early singleton objects: bean name to bean instance. */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16);三个 Map 各有其位一级缓存singletonObjects存放已经完整走完初始化流程的成品 Bean。这是最终对外提供服务的那一个也是getBean通常命中的地方。三级缓存singletonFactories存放的是ObjectFactory也就是一个“对象工厂”。它不直接存对象而是存了一个可以生成对象的工厂函数。Bean 在刚实例化、还未填充属性时就会被封装成这样一个 Factory 放进来这个操作叫“早期暴露”。二级缓存earlySingletonObjects存放提前暴露的半成品对象。什么时候会从三级缓存升级到二级缓存当别的 Bean 在创建过程中需要引用当前这个半成品时Spring 会调用三级缓存里的 Factory 拿到早期引用然后放进二级缓存同时把三级缓存里的 Factory 移除。这里有个很容易被忽略的设计细节三级缓存里存的是ObjectFactory不是直接的实例。为什么非要多包一层工厂因为有些 Bean 在早期暴露时还没经过 AOP 代理而 Spring 希望你这个半成品被“拿出去”的时候如果它最终需要代理那你拿到的就应该是代理对象而不是原始对象。ObjectFactory的存在就是为了在“被引用”的那一刻才决定返回原始对象还是代理对象。2.2 为什么不能用两级缓存AOP 代理卡死了设计这是面试高频题也是理解三级缓存设计的核心。很多人默认 Spring 解决循环依赖靠的是二级缓存一直不理解第三级存在的意义。我给团队讲这块时习惯用“提前拍婚纱照”来做类比。想象这样一个场景你要办婚礼但另一半的婚纱还在定制你总不能等婚纱完全做好再办婚礼所以婚庆公司说行先拍一张素颜照放门口迎宾等婚纱到了你再补一张精修照。这个“素颜照”就是earlySingletonObjects它让流程能先走下去。但问题来了如果这个新郎最终是要做形象包装的比如上电视要化妆对应 Spring 里的 AOP 代理你在门口放一张素颜照来宾看到的就不是最终上电视的样子。有两种解决办法方案 A两级缓存一开始就知道要化妆直接在门口放一张“精修照”也就是在早期暴露时就生成代理对象。听起来可行但问题是你怎么在刚实例化时就确定这个 Bean 要不要被代理确定切面逻辑需要拿到完整的 Bean 后经过AnnotationAwareAspectJAutoProxyCreator判断而判断依赖的注解、方法、类信息虽然都在但此时属性还没填充后置处理器还没跑完贸然生成代理很可能生成错。方案 B三级缓存先放一个“摄影工作室的联系方式”也就是ObjectFactory告诉来宾“你只要报我的名字工作室现场帮你修图出片”。谁需要这个半成品谁就去触发 FactoryFactory 内部会调用代理创建逻辑决定给素颜照还是精修照。Spring 选择的是方案 B。它的巧妙之处是延迟决策在未被别人引用之前早期对象到底长什么样不重要一旦被引用就要保证给出去的版本和最终版本一致要么都是原始对象要么都是代理对象。这就是三级缓存不是两级缓存的根本原因——两级缓存只能给固定的对象而三级缓存的ObjectFactory可以根据需要临时生成正确的版本。2.3 三级缓存解决不了代理一致性关键在 getEarlyBeanReference你可能要问ObjectFactory返回的真是代理对象吗这里涉及到一个关键方法AbstractAutowireCapableBeanFactory.getEarlyBeanReference。它的核心逻辑是通过SmartInstantiationAwareBeanPostProcessor链来暴露早期引用protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) { exposedObject bp.getEarlyBeanReference(exposedObject); } } return exposedObject; }这里的关键点是AbstractAutoProxyCreator.getEarlyBeanReference中的wrapIfNecessary逻辑它会判断当前 Bean 是否有资格被代理如果有就提前创建代理并放入缓存。Spring 还用一个earlyProxyReferences集合记录“已经提前做过代理”的 Bean防止后续在正常初始化流程中再代理一次造成代理对象包装代理对象的问题。如果去掉三级缓存、只保留二级缓存也不是绝对不行但必须做到“在放入二级缓存时就提前 AOP 代理”。问题是这会把 AOP 代理的决策时机提前导致很多后置处理器的判断逻辑失效。所以三级缓存不是过度设计它是“在正确时机做正确事”的产物。3. 源码级拆解从 getSingleton 到 populate循环依赖一步一步怎么转3.1 入口阶段getSingleton 的缓存命中与未命中循环依赖能解核心流程都在AbstractBeanFactory.doGetBean和DefaultSingletonBeanRegistry里。我们跟着代码走一遍 A、B 互相依赖的场景。假设先创建 A。A 的创建入口是getSingleton(beanName)第一次进来缓存全空返回 null接着调用getSingleton(beanName, () - createBean(beanName, mbd, args))进入真正创建逻辑。getSingleton(String beanName, ObjectFactory? singletonFactory)这个方法很重要它做了几件事加锁、调用singletonFactory.getObject()创建 Bean、创建成功后放入三级缓存的 Collection 里。但这不是我们关注的重点重点是创建前它会检查当前是否存在同名 Bean 正在创建中boolean newSingleton false; synchronized (this.singletonObjects) { // 检查是否已存在 Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { // 检查当前是否处于创建状态循环依赖检测关键 beforeSingletonCreation(beanName); // ... } }beforeSingletonCreation会维护一个singletonsCurrentlyInCreation集合。如果 A 在创建中又被二次请求这个集合里已经有 A 了就会抛异常。这正是我们前面看到BeanCurrentlyInCreationException的源头。3.2 创建阶段doCreateBean 里提前把工厂塞进三级缓存在createBean里调用doCreateBean后Bean 会被实例化通过构造器反射得到原始对象然后执行一个对循环依赖至关重要的操作——addSingletonFactoryboolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }addSingletonFactory做的事情就是把当前 Bean 的ObjectFactory放进三级缓存singletonFactories。注意这段代码的位置它在属性填充populateBean之前在 Bean 后置处理器初始化之前。也就是说A 刚 new 出来还没设置任何成员变量就已经被“挂出去”了。很多初学者以为三级缓存是在 A 完全初始化后才放的其实不对。早期暴露的时机非常早早到对象身上一切属性都是默认值这就是“半成品”。3.3 填充阶段B 出现getSingleton 命中三级缓存并升级A 继续执行populateBean开始处理Autowired字段发现需要注入 B。于是它调用getBean(B)B 开始走同样的流程B 实例化 - addSingletonFactory(B 的工厂放入三级缓存)B 开始 populate发现需要 AB 调用getSingleton(A)这次缓存命中逻辑就复杂了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)的判断它保证只有“A 确实正在创建中”才走三级缓存逻辑。B 在singletonFactories里找到了 A 的 Factory调用getObject()拿到 A 的早期引用早就不包含 B 的 A并把 A 从三级缓存升级到二级缓存earlySingletonObjects。然后这个半成品 A 被注入到 B 的字段里。B 继续完成初始化最终成为一个完整 Bean放进一级缓存。3.4 收尾阶段A 的早期引用与最终对象的一致性检查B 创建完成后Spring 回到 A 的创建流程。此时 A 继续填充属性把 B 注入进来。A 的初始化流程继续走下去如果 A 有 AOP 代理或BeanPostProcessor要处理会在initializeBean阶段完成。A 完整初始化后执行getSingleton(beanName, false)做一次检查关键点在这里if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { // 如果早期引用和最终实例不一致说明有人改了引用通常说明发生了代理 if (exposedObject bean) { exposedObject earlySingletonReference; } } }如果 A 在早期被 B 引用过二级缓存里有 A而且 A 经过完整的 Bean 生命周期后依然是原始对象没有被代理那么最终注入给容器的还是earlySingletonReference。如果 A 被 AOP 代理了这段逻辑也保证最终暴露的是代理对象。这个设计保证了“早期拿出去的那个对象”和“最终存在容器里的那个对象”是同一个引用不会出现引用不一致的问题。这也解释了为什么三级缓存解决循环依赖是有代价的早期暴露的对象可能没有完成完整的后置处理器链调用如果这个半成品在初始化过程中状态被其他 Bean 修改可能和你预期的成品不一致。这也是为什么 Spring 官方其实建议大家尽量不要依赖循环依赖。3.5 用流程时序直观感受一次完整的循环依赖解决过程为了让你更能想象整个过程我画一个文字版的执行顺序不用图用编号描述getBean(A)- 缓存 miss - 创建 A - A 实例化 - 放入三级缓存A FactoryA populate - 发现需要 B -getBean(B)- 缓存 miss - 创建 B - B 实例化 - 放入三级缓存B FactoryB populate - 发现需要 A -getSingleton(A)- 一级无、二级无、三级命中 - 调用 A Factory 得到半成品 A可能代理 - 放入二级缓存移除三级缓存中的 A FactoryB 注入半成品 A - B 剩余初始化完成 - B 放入一级缓存回到 A populate - 注入完整的 B - A 剩余初始化完成getSingleton(A, false)- 二级缓存命中半成品 A - 如果 A 未被代理则暴露该引用如果 A 被代理则用最终代理A 放入一级缓存移除二级缓存中的半成品 A在实际断点调试时你会在第 3 步看到 B 的a字段确实是一个 A 的早期引用此时 A 的b字段还是 null。只有到第 5 步A 的b字段才被赋值。这个“先有对象、后填属性”的中间态就是循环依赖能解的本质。理解了这个时序后面再去看 Spring Boot 2.6 默认关闭循环依赖的决策就很容易明白官方在担心什么了。4. 构造器注入的死结为什么三级缓存救不了它4.1 构造器阶段的“无对象可暴露”前面提到三级缓存解决循环依赖的核心是“把半成品提前暴露”。但半成品暴露的时机在new之后、属性填充之前。构造器注入的循环依赖发生在哪发生在new那一刻。A 的构造方法需要 B但 B 还没创建B 的构造方法需要 A可 A 的构造还没执行完对象根本不存在。Spring 想暴露 A 的半成品也暴露不了——A 连个原始对象都没 new 出来。更直白地说循环依赖的“依赖”发生在构造函数传入参数时而三级缓存只解决“属性赋值”阶段的依赖两者根本不在同一个时间维度上。我经常用开派对来比喻A 是个厨师B 是个服务员。如果 A 上班第一天就要求“必须服务员 B 先到位我才进场”B 也要求“必须厨师 A 先到位我才进场”那这场派对永远开不了但如果 A 先到场哪怕没带食材B 看到 A 先到了也愿意进场派对就能开始。三级缓存解决的是“先进场备菜”的问题解决不了“谁都不先进场”的僵局。4.2 构造器注入循环依赖的报错过程我们模拟一下构造器注入的循环依赖Component public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final A a; public B(A a) { this.a a; } }启动容器时Spring 找 A 的构造器参数需要 B于是先创建 B。B 的构造器需要 AgetSingleton(A)会检查A 是不是正在创建中是因为 A 还没创建完。接着查三级缓存发现 A 的三级缓存根本不存在——因为 A 连new都还没 new 出来。于是抛BeanCurrentlyInCreationException。如果只看报错日志你甚至可能一头雾水明明日志先打印 “Creating bean with name a”接着又报 “Requested bean is currently in creation: Is there an unresolvable circular reference?”。原因就是 A 的创建流程被 B 的依赖“卡”住了B 又回来找 A把 A 自己给锁死了。4.3 如何治理构造器循环依赖能改结构就别动缓存构造器注入循环依赖既然没法靠容器解决只能靠代码层面修复。我的治理顺序是这样的优先重构把互相依赖的类拆分或者引入中间层。例如抽出一个C让 A 和 B 都只依赖 C从根上消除环。改注入方式把其中一个 Bean 的构造器注入改成 Setter 注入或字段注入。虽然团队里总有人喷字段注入不好但在循环依赖这种场景下活下来比风格更重要。不过要注意改了之后可能引入“半成品状态被外部访问”的隐患需要做好空值保护。用Lazy打破依赖在构造器参数上加LazySpring 会注入一个代理对象真正调用时才去解析目标 Bean。比如Component public class A { private final B b; public A(Lazy B b) { this.b b; } }这样 A 构造时拿到的不是完整的 B而是一个延迟解析的代理B 构造时再依赖 A 就能正常拿到。代价是访问 B 的方法时多了一层代理调用性能损耗极小但语义上要求使用方接受“延迟解析”。我总是跟团队强调一句话框架能帮你兜底但兜底不等于鼓励你制造环。构造器循环依赖就是典型的“代码结构缺陷”即使靠Lazy蒙混过关后续维护的人看到两个类互相构造还是会头皮发麻。如果让我评审代码构造器循环依赖是一票否决项。5. 隐藏得更深的坑Async、AOP 代理与循环依赖的组合事故5.1 Async 循环依赖为什么表面正常、实际代理失效如果说构造器循环依赖是“直接报错”那Async与循环依赖的组合就是“埋雷不响点火才爆”这也是我线上碰到过最隐蔽的问题。先看代码Component public class A { Autowired private B b; Async public void asyncMethod() { System.out.println(A async); } } Component public class B { Autowired private A a; }A 依赖 BB 依赖 A。按照前面讲的逻辑三级缓存能解决这个循环依赖启动不会报错。但问题来了A 上有Async而Async是通过AsyncAnnotationBeanPostProcessor后在initializeBean阶段生成代理对象的。当 B 在 populate 阶段需要 A 时它从三级缓存singletonFactories里触发getEarlyBeanReference。这个阶段AsyncAnnotationBeanPostProcessor有没有生效要看getEarlyBeanReference的执行链。AbstractAutoProxyCreator.getEarlyBeanReference会对有资格的对象提前创建代理但Async的处理器AsyncAnnotationBeanPostProcessor继承自AbstractAdvisingBeanPostProcessor它没有实现SmartInstantiationAwareBeanPostProcessor所以不会参与早期代理。这意味着 B 拿到的 A 是一个原始对象不是代理对象。后续 A 完成初始化时Spring 发现 A 被代理了但代理发生在初始化之后此时 A 如果发生过早期暴露exposedObject ! bean判定成立容器最终可能采用的是早期引用原始对象。结果就是A 最终注入到容器和 B 里的都是原始对象A 的Async方法全部失效变成同步调用。线上表现就是接口响应时间从 50ms 涨到 3 秒查日志发现异步任务全都在调用线程里跑完了。当时排查了很久才发现是循环依赖 Async的组合问题。5.2 为什么 Transactional 循环依赖一般没事很多人会问Async有这问题Transactional怎么好像没事原因是Transactional的代理创建处理器InfrastructureAdvisorAutoProxyCreator继承了AbstractAutoProxyCreator支持getEarlyBeanReference的早期代理。所以它在循环依赖中能正确生成代理对象B 拿到的就是代理不会出现“代理丢失”的问题。Async的处理器不走SmartInstantiationAwareBeanPostProcessor这条路所以完全没有早期代理能力。这提醒我们能不能被三级缓存正确暴露取决于代理处理器是否实现了getEarlyBeanReference。凡是没实现早期代理的注解类 AOP一旦和循环依赖组合都要警惕代理失效。5.3 Spring Boot 2.6 起为什么默认禁止循环依赖Spring Boot 2.6 发布时官方做了一个破坏性变更spring.main.allow-circular-references默认为false。如果你在 2.6 及以上版本中使用循环依赖容器直接启动失败并提示Description: The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | a defined in file ... ↑ ↓ | b defined in file ... └─────┘这个变更背后的逻辑官方说是为了让开发者强制避免循环依赖因为循环依赖是设计缺陷的信号会让代码变得难以维护。实际上在我看来还有一个现实原因循环依赖 代理对象的组合不确定性太高像Async这种失效问题根本不是容器能控的。宁可启动报错也不能看着应用起来了但行为全错。如果你是老项目升级可以在application.yml里临时开一个口子spring: main: allow-circular-references: true但这个配置应该只是过渡方案。正确做法是借机排查把循环依赖逐个拆掉。我升级过几个老项目经验是大部分循环依赖其实是设计问题改造成本没想象中高用Lazy或者抽公共依赖就能解决。5.4 排查循环依赖的两个实战技巧排查循环依赖最烦的是不知道环在哪里。Spring Boot 2.6 以上的报错已经帮我们画出了环的形状但老版本只有一段抽象异常。我分享两个自己常用的排查手段技巧一启动时打印所有 Bean 的依赖关系。写一个ApplicationRunner用DefaultListableBeanFactory拿每个 Bean 的PropertyValues和构造器参数构建有向图做环检测。代码量不大但能快速定位环的边界。Component public class CircularDependencyDetector implements ApplicationRunner { Override public void run(ApplicationArguments args) { // 获取所有 bean 定义 // 对每个 bean 收集依赖构造器参数、Autowired 字段、setter // 用 DFS 找环并打印路径 } }技巧二断点打在DefaultSingletonBeanRegistry.getSingleton的singletonFactories获取处。只要看到isSingletonCurrentlyInCreation(beanName)为 true 且 singletonFactories 有值这里就是循环依赖发生的位置。观察调用栈你能看到是从哪个 Bean 的属性填充发起对当前 Bean 的二次获取。这个方法虽然费时间但能帮你真正理解问题而不是靠猜。6. 几个必须搞清的高频面试问题与一针见血的答法6.1 三级缓存能解决所有循环依赖吗不能。构造器注入的循环依赖解决不了非单例 BeanScope(prototype)的循环依赖解决不了部分Async组合场景虽然启动能过但代理会失效。面试时不要一上来就说“能解决”这是个陷阱题。更好的回答是Spring 的三级缓存解决的是“单例、允许循环引用、通过 setter 或字段注入”的循环依赖本质上是把 Bean 的实例化和初始化解耦用提前暴露半成品的方式打破循环。6.2 为什么是三级缓存不是二级缓存你怎么设计这个问题我建议结合代码回答不要只背结论。核心逻辑是一级缓存存成品为避免重复创建需要加锁检查创建过程中出现循环依赖时其他 Bean 需要拿到的不是最终对象而是一个可以触发代理决策的ObjectFactory。如果只有两级就必须在对象放进二级缓存时立刻决定是否代理这会把 AOP 代理的时机过早提前破坏很多后置处理器的执行顺序。三级缓存的价值在于延迟代理决策只有真正被引用时才触发getEarlyBeanReference去决定返回原始对象还是代理对象。如果面试官追问“那你自己实现会怎么做”你可以回答同样的场景下我会把“BeanDefinition 处理完、实例化后、初始化前”这个阶段作为暴露时机用一个MapString, SupplierObject存半成品工厂被依赖时调用Supplier判断是否需要代理需要则创建代理否则返回原始对象初始化完成后用成品覆盖。这样设计后本质上就会得到一种“三级缓存”。6.3 Spring 是怎么判断当前 Bean 正在创建中的DefaultSingletonBeanRegistry维护了一个SetString singletonsCurrentlyInCreation在beforeSingletonCreation时向里面添加 beanName在afterSingletonCreation时移除。当getSingleton发现一级缓存没有、二级缓存没有、三级缓存没有但singletonsCurrentlyInCreation里已有当前 beanName就会判定发生了循环依赖。这个判断机制也解释了 prototype 作用域为什么解决不了循环依赖prototype Bean 不会进singletonsCurrentlyInCreation的缓存保护机制每次getBean都是全新的Spring 无法定位到“当前正在创建的实例”自然谈不上提前暴露。6.4 循环依赖有没有性能损耗可以完全避免吗性能损耗是有的主要是缓存访问和加锁开销但对绝大多数业务系统来说可以忽略。更严重的不是性能而是对象语义的复杂性半成品对象可能被多个 Bean 引用期间如果状态发生变化引用方可能看到不一致的数据加上代理失效的问题会让系统行为变得不可预期。所以我的建议是能避则避不要在业务代码里滥用循环依赖。用构造器注入、拆接口、抽中间层这些方式能消除绝大多数环。6.5 面试时怎么组织这段回答更有说服力我建议按这个顺序组织先说结论解决的是单例 setter/字段注入的循环依赖。抛出三个 Map 的名字和各自职责说明“早期暴露”的时机。走一遍 A/B 互相依赖的流程重点说三级缓存如何在 B 注入 A 时触发getEarlyBeanReference。解释为什么是三级不是两级用 AOP 代理决策时机做拔高点。补充构造器注入无法解决的原因展示你对边界的理解。最后提一句 Spring Boot 2.6 默认禁止循环依赖说明你知道这个特性的演进。这套回答大概三分钟既有深度又有广度面试官一般问到这里就会点头。7. 从一次线上故障总结我对循环依赖的态度处理完那次Async循环依赖故障后我在团队里立了一条不成文的规定新代码禁止出现循环依赖看到构造器注入的环直接打回。老代码逐步改造利用 Spring Boot 2.6 的启动报错做检查清单一个一个清。这不是教条是因为循环依赖带来的问题太隐蔽了。举个最简单的例子A 依赖 BB 依赖 A。你觉得功能没问题反正 Spring 能处理。但三个月后有人给 A 加了一个Cacheable注解或者给 B 的方法加了个事务代理链条一变整个循环依赖的稳定性就崩了。这类问题你不在初始化阶段暴露出来就只能在运行时用事故来提醒你。如果你现在正在为循环依赖头疼我的建议是先照着文章第 3 节的时序把流程走一遍确定自己的场景到底是哪一类能改的设计马上改不能改的临时用Lazy顶一下然后在代码里加 TODO尽快从环里跳出来。技术是为业务服务的但技术债务该还还得还越早还利息越低。最后分享一个我自己习惯的检查方式每次写完一个 Spring Boot 服务我会把启动日志里Bean xxx is a candidate for getting processed by CallbackBeanPostProcessor这类代理相关的日志扫一遍再确认下有没有被循环依赖代理陷阱命中。这个习惯帮我避开过好几次线上事故也推荐给你。
返回列表