ARTICLE DETAIL

资讯详情

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

Spring循环依赖深度解析:三级缓存原理与实战避坑指南

Spring循环依赖深度解析:三级缓存原理与实战避坑指南 昨晚一个同事在群里甩了张启动报错的截图红通通的异常堆栈第一行就是BeanCurrentlyInCreationException下面跟着一句经典的提示Requested bean is currently in creation: Is there an unresolvable circular reference?。我一看就知道这是又踩到 Spring 循环依赖的坑了。当时群里几个人有的说是配置问题有的说加个Lazy试试还有人直接说把字段注入改成构造器注入就好了。各说各的显然大家对循环依赖这件事的理解都停留在“听说过三级缓存”这个层面。Spring 处理循环依赖的核心机制就是面试里被问了八百遍的“三级缓存”。但如果你真的去翻源码会发现这里的门道比八股文里写的要深得多为什么靠三级缓存而不是两级为什么构造器注入的循环依赖就解决不了为什么 Spring Boot 2.6 以后默认反而禁用了循环依赖这些才是真正值得弄明白的事情。这篇文章我就从实际排错经历出发把三级缓存的设计思路、源码调用链、以及那些“治不了”的场景一次讲透争取让看完的人既能应付面试也能在真实项目里少踩几个坑。1. 循环依赖到底是什么先从一个启动报错说起1.1 我遇到的那次BeanCurrentlyInCreationException同事的项目是个典型的微服务模块两个 Service 互相调用OrderService里注入了UserServiceUserService里又注入了OrderService。代码大概是这样的Service public class OrderService { Autowired private UserService userService; public String getOrderInfo() { return order: userService.getUserName(); } } Service public class UserService { Autowired private OrderService orderService; public String getUserName() { return user: orderService.getOrderInfo(); } }按说这种“循环引用”是很常见的写法而且在 Spring 4.x、Spring 5.x 时代直接启动是不会报错的。但同事用的是 Spring Boot 2.6启动的时候直接抛出了上面的异常。这里就引出了第一个关键点Spring Boot 2.6 开始官方把循环依赖的默认开关关掉了默认不再允许 bean 之间循环引用。这是很多人容易忽略的历史变化。Spring Boot 2.6 的 release notes 里写得很清楚默认禁止循环引用如果确实需要要在配置里显式打开spring.main.allow-circular-referencestrue或者使用SpringApplicationBuildernew SpringApplicationBuilder(App.class) .allowCircularReferences(true) .run(args);但话说回来处理循环依赖的核心机制也就是三级缓存在底层是一直存在并且在默认开启情况下生效的。所以要想真正弄懂这个问题不能只看 Spring Boot 层面的开关得往 Spring 容器内部看。1.2 循环依赖的本质对象创建顺序的矛盾循环依赖的本质是A 的创建需要 BB 的创建又需要 A形成了一个先有鸡还是先有蛋的死结。用一个更生活化的类比你想装修房子需要先让木工进场但木工说他的工具放在仓库里而仓库钥匙在装修公司那边装修公司又说钥匙在木工手里。三个人都等着别人先动事情就卡住了。放到 Spring 容器里就是容器启动时扫描到OrderService决定创建它。创建过程中发现它需要UserService于是转而去创建UserService。创建UserService时又发现它需要OrderService。可是此刻OrderService还在创建途中连一个完整的 bean 都算不上于是系统陷入死循环。如果没有任何缓存机制这种场景只能抛异常。Spring 的解决思路其实也很直白既然大家都需要对方那就别等“完整版”了先给个“半成品”顶着用。这就是三级缓存存在的意义。1.3 最容易触发循环依赖的几种设计模式根据我实际看过的项目循环依赖通常出现在这么几类场景里两个 Service 直接互相注入最常见通常是业务边界没划清。Service 和它关联的 Manager/Dao 层互相引用多见于老代码分层不彻底。配置类之间存在依赖比如Configuration类 A 依赖配置类 B 的实例。定时任务和业务服务互相调用任务里注入 ServiceService 里又反向调用任务的某个方法。说实话碰到循环依赖的第一反应不应该是“怎么让 Spring 支持它”而应该想想这里的设计是不是有问题只不过在代码已经跑起来的情况下先理解 Spring 的应对机制再讨论是否重构才是正常的排查顺序。2. 三级缓存的完整设计逻辑为什么必须是三级2.1 先回顾一下Bean的生命周期谈三级缓存之前必须先把 Spring bean 的生命周期拉出来对齐不然后面看源码会一头雾水。一个普通单例 bean 从创建到就绪大致要经历这么几个阶段实例化通过构造器或者工厂方法创建对象此时对象的属性都是 null是一个“裸对象”。属性填充执行依赖注入把Autowired、Resource标注的属性值填进去。初始化执行各种BeanPostProcessor的前后置方法、PostConstruct标注的方法、InitializingBean接口的afterPropertiesSet等。就绪放入一级缓存后续通过getBean直接拿到完整对象。循环依赖的突破口就藏在第 1 步和第 2 步之间。Spring 在实例化完成、但还没做属性填充的时候其实已经“认识”这个 bean 了完全可以把这个半成品暴露出去让别人先用着。问题在于如果直接把这个半成品放进普通的 Map 缓存里后续 Spring 怎么区分哪些是完整的、哪些是没填属性的这就是缓存分级的意义。2.2 一级缓存singletonObjects只放“完整品”DefaultSingletonBeanRegistry里定义了三个核心 Map先看第一个/** Cache of singleton objects: bean name to bean instance. */ private final MapString, Object singletonObjects new ConcurrentHashMap(256);singletonObjects是最终缓存里面放的是已经完整创建好的单例 bean。所谓“完整”指属性填充完成、初始化完成、可以正常对外提供服务。平时我们通过context.getBean(orderService)拿到的对象正常情况下都是从这里取的。它是面向外部的结果缓存也是判断一个 bean 是否已经创建完毕的依据。那为什么不把所有对象都直接放这里因为如果 A 在属性填充阶段被半路塞进这个 Map等 B 反过来引用 A 的时候虽然能拿到对象但拿到的可能是属性还不完整的对象。更麻烦的是如果后面 A 的初始化阶段报了错这个 Map 里就残留一个脏数据。区分“创建中”和“已创建”是非常必要的。2.3 二级缓存earlySingletonObjects用来缓存“提前暴露的半成品”/** Cache of early singleton objects: bean name to bean instance. */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16);这个 Map 专门存放已经实例化但尚未完成属性填充和初始化的早期单例对象。看到这你可能疑惑既然一级缓存不能放半成品那把半成品放二级缓存不就够了为什么还需要第三级这个问题的答案恰恰是整个三级缓存设计最精妙的地方也是面试里最容易卡壳的点。2.4 三级缓存singletonFactories延迟决策的关键/** Cache of singleton factories: bean name to ObjectFactory. */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);这里存的不是 bean 对象而是一个工厂接口ObjectFactory。它的作用不是直接返回完整对象而是允许在“真正需要引用该 bean 的时刻”才执行一次定制逻辑。三种缓存对比如下缓存存储内容状态用途singletonObjectsbean 实例完整创建getBean 的结果缓存earlySingletonObjects早期 bean 实例实例化完成、未初始化暴露半成品singletonFactoriesObjectFactory 工厂可生成早期引用延迟代理决策三级缓存存在的核心原因很简短因为 AOP 代理。假设有一个被Transactional或者Aspect修饰的 bean它最终放进一级缓存的应该是一个代理对象而不是原生对象。Spring 的 AOP 代理通常是在 bean 初始化完成之后通过BeanPostProcessor创建的。现在问题来了如果 A 和 B 循环依赖B 在填充属性时拿到的 A 是一个“手快的半成品引用”。等到 A 初始化完成Spring 发现 A 需要被代理于是最终缓存里放的是代理对象 A。那 B 里的引用怎么办B 拿到的还是那个没有经过代理增强的原始对象 A这就导致B 里调用的 A 方法没有任何事务、切面增强而且和容器里缓存的对象还不是同一个后果非常隐蔽。如果只有一级、二级缓存Spring 只能采取一个简单粗暴的办法在创建任意 bean 时都提前执行一遍 AOP 代理工厂把代理对象直接放进二级缓存。但这样一来所有根本没有循环依赖的 bean 也会被提前代理白白损失性能而且某些切面逻辑在 bean 早期就被触发会引发各种奇怪的问题。三级缓存通过ObjectFactory把“是否生成代理、生成什么代理”的决策延迟到了真正发生循环依赖的那一刻。如果没有循环依赖就完全不需要触发ObjectFactory.getObject()代理还是按正常流程在初始化后创建一旦发现循环依赖才会通过getEarlyBeanReference提前曝光代理引用。所以三级缓存并不是比二级缓存多了一级那么简单它解决的是“要不要提前 AOP”这个决策时机问题。3. getBean源码链路逐段拆解从查找缓存到暴露早期引用3.1 先认识DefaultSingletonBeanRegistry三级缓存的定义就在org.springframework.beans.factory.support.DefaultSingletonBeanRegistry这个类里。整个循环依赖解决的核心逻辑几乎都集中在这个类以及它的兄弟类AbstractBeanFactory、AbstractAutowireCapableBeanFactory中。我建议你有空的时候用 IDEA 打开这几个类跟着下面的链路一起看比单纯看文章理解要深得多。3.2 getSingleton方法的三级查找顺序循环依赖的解析入口可以从getSingleton(String beanName, boolean allowEarlyReference)这段方法说起。它的核心逻辑按顺序查三个缓存伪代码大致如下protected Object getSingleton(String beanName, boolean allowEarlyReference) { // 1. 先查一级缓存看看 bean 是不是已经完整创建 Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 2. 查二级缓存看看这个 bean 是否已经被提前暴露 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) { // 3. 查三级缓存拿到 ObjectFactory ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { // 通过工厂生成早期引用 singletonObject singletonFactory.getObject(); // 把生成的早期引用放入二级缓存并移除三级缓存 this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }几个关键信息查找顺序是一级 → 二级 → 三级逐级向上查到了就返回。三级缓存拿到后会调用ObjectFactory.getObject()生成一个对象可能是原始对象也可能是代理对象然后升级放入二级缓存同时把三级缓存里的对应工厂移除。整个流程有个前提isSingletonCurrentlyInCreation(beanName)也就是说这个 bean 必须正在创建中。平时通过getBean拿一个已经创建完的 bean直接一级缓存就命中了根本不会走后面两步。代码里的earlySingletonObjects.containsKey(beanName)也是一种提前暴露的标记。3.3 createBean流程中的addSingletonFactory时机那三级缓存里的singletonFactory是什么时候放进去的关键在AbstractAutowireCapableBeanFactory.doCreateBean方法里。实例化完成后Spring 会执行一段逻辑大致如下boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }也就是说bean 实例化完成之后、属性填充之前Spring 就主动把ObjectFactory放进了三级缓存。这个工厂执行的时候调用的是getEarlyBeanReference方法它会回调所有SmartInstantiationAwareBeanPostProcessor的getEarlyBeanReference方法用来决定是否提前创建代理。之后才进入populateBean阶段开始处理属性填充。当它发现OrderService需要注入UserService时会调用beanFactory.resolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);而resolveDependency最终会回到doGetBean里的Object sharedInstance getSingleton(beanName);这里的getSingleton就是前面说的那个三级查找方法。此时B比如UserService在级联查找 A 时就会发现 A 的singletonFactory已经在三级缓存里了于是调用工厂拿到 A 的早期引用A 的半成品就被 B 成功持有。整个时序可以概括为创建 A实例化后把 A 的objectFactory放入三级缓存。填充 A 属性发现需要 B转去创建 B。创建 B实例化后把 B 的objectFactory放入三级缓存。填充 B 属性发现需要 A此时调用getSingleton(a, true)。三级缓存命中执行工厂拿到 A 的早期引用存入二级缓存。B 持有 A 的引用继续初始化并完成创建。回到 AA 持有 B 的引用继续初始化并完成创建。两个 bean 最终都进入一级缓存容器启动成功。3.4 getEarlyBeanReference与AOP代理的幕后配合getEarlyBeanReference的逻辑也很值得细看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, beanName); } } return exposedObject; }这里的关键是实现类AbstractAutoProxyCreator。如果你的 bean 上配置了 AOP 相关逻辑AbstractAutoProxyCreator.getEarlyBeanReference会提前创建代理对象并且把“已经提前代理”这个状态记在一个earlyProxyReferences缓存里。等到 bean 走完初始化流程AbstractAutoProxyCreator.postProcessAfterInitialization再次执行代理逻辑时发现这个 bean 已经被 early 代理过了就不会再重复代理直接返回原来的早期代理对象。这样保证最终放进一级缓存的和之前暴露给 B 的是同一个代理对象。这一环扣一环设计得非常严密。如果你曾经 debug 过循环依赖看到earlyProxyReferences应该会觉得眼熟。4. 这些循环依赖Spring解决不了四个典型场景实测三级缓存不是万能的。实际项目中有一些循环依赖场景即便打开了allow-circular-referencesSpring 依然无能为力。下面是我自己在测试和排障中验证过的典型例子。4.1 构造器注入为什么连三级缓存也救不了构造器注入是 Spring 官方推荐的依赖注入方式但在循环依赖场景下它恰恰是死路一条。原因并不复杂构造器注入发生在实例化阶段。A 的构造器需要 B 的实例可此时 A 自身的实例都还没创建出来三级缓存里自然没有 A 的ObjectFactory。Spring 转而去创建 BB 的构造器又需要 A可 A 还是不存在如此循环只能抛异常。用一个伪代码表示就是Service public class A { public A(B b) { this.b b; } } Service public class B { public B(A a) { this.a a; } }这种代码无论怎么开缓存开关都启动不了。因为三级缓存只能暴露“已经实例化、还没初始化”的 bean而构造器阶段连实例都没建立根本无处暴露。所以很多人说“Spring 支持循环依赖”准确的说法应该是Spring 只支持基于 setter/字段注入的单例循环依赖。如果你是构造器注入党那循环依赖最好在代码层面直接杜绝。4.2 prototype作用域的bean每次getBean都是一次新旅程三级缓存只对单例 bean 生效prototype作用域的 bean 每次getBean都会新建一个实例根本不会被缓存。DefaultSingletonBeanRegistry只缓存 singleton而doGetBean在获取prototypebean 时走的是另一条分支不查缓存、不注册创建状态、不暴露半成品。两个 prototype bean 循环依赖唯一的结局就是抛BeanCurrentlyInCreationException。这一点其实很好理解缓存的核心前提是“同一个对象大家共享同一个引用”。prototype 每次都是新对象没法提前暴露一个“商定好”的引用给对手方。所以如果你用了Scope(prototype)就别指望循环依赖能自动解开。4.3 Async与代理增强吃了早期引用的亏Async注解是另一个容易踩坑的地方。它通过代理机制实现异步调用但问题在于Async的代理和普通 Spring AOP 代理的创建时机不一样。如果你在循环依赖链路上某个 bean 加了Async即使 Spring 能通过getEarlyBeanReference提前暴露引用那个早期引用也可能只是原始对象而不是异步代理对象。结果就是别人持有的引用不具备异步能力方法调用变成同步执行而且这个引用可能和最终容器缓存的对象不是同一个。表面上项目能启动但运行时的行为已经和预期完全不一样了。我实际遇到过一个案例A 和 B 互相依赖B 被标记了Async结果调用 B 的业务方法时发现始终是同步执行查了很久才发现是循环依赖导致的“半成品引用”问题。这种 bug 的隐蔽性很强日志不报错只有时序和性能异常。所以如果项目中大量使用Async最好也对循环依赖保持零容忍。4.4 DependsOn与显式顺序强制DependsOn注解用来强制指定某个 bean 在另一个 bean 之前初始化。如果两个 bean 互相通过DependsOn指定对方先初始化Spring 会直接抛出BeanCurrentlyInCreationException或者DependsOnCycleException。这是显式依赖顺序冲突Spring 不会尝试用缓存去解开因为这是用户主动声明的构造顺序矛盾属于典型的配置错误应该直接暴露出来让开发者修正。5. 实践建议与面试视角怎么看待循环依赖这件事5.1 实际项目中最稳妥的四种处理方式遇到循环依赖先别想着靠开关硬撑按下面顺序排查一遍基本都能干净解决。第一步重构拆分消除循环。两个 Service 互相调用往往是职责边界没有划清楚。把双方公用的逻辑抽到第三个 Service 中或者用一个中间层 Broker 来承接跨服务调用循环关系就自然解开了。这也是我最推荐的做法因为它能从根上消除问题。第二步Lazy 延迟注入。如果循环依赖一时改不动可以在注入处加LazyService public class A { Lazy Autowired private B b; }Lazy的思路不是获取 B 的实例而是注入一个 B 的代理对象真正调用方法时才去容器里找目标 bean从而绕开创建顺序问题。代价是你拿到的是一个懒加载代理调试和追踪时没有真实对象直观。第三步事件机制解耦。如果是方法调用层面的互相依赖可以引入 Spring 的ApplicationEvent把双向调用改造成单向的事件发布-监听。比如OrderService不再直接调用UserService而是发布一个OrderCreatedEvent由UserService监听处理。这样代码之间是解耦的循环依赖自然消失。第四步实在不行再开关。Spring Boot 2.6 把allow-circular-references默认设成 false是为了倒逼开发者写出更清晰的依赖结构。如果真的因为历史包袱必须临时启动加上配置但务必留下 TODO等技术债清理后去掉。5.2 面试回答的框架和深度面试被问循环依赖很多人第一反应就是背“三级缓存”。但说实话面试官想听到的不只是缓存名字而是你对这三级缓存为什么存在的理解。我建议按这个思路答先说清楚循环依赖是什么什么场景下会发生。再说 Spring 解决循环依赖依赖的核心数据结构是三级缓存每一级分别存什么。重点讲为什么是三级不是二级因为需要延迟 AOP 代理决策避免所有组件在无循环时被提前代理。说明适用的边界单例、字段/setter 注入可以处理构造器注入、prototype 作用域、Async 场景处理不了。如果能再提一句 Spring Boot 2.6 默认禁用循环依赖并简单解释官方的用意面试观感会好很多。这样答下来既展示了源码阅读能力也展示了工程判断力比单纯把三个缓存名字说出来要立体得多。5.3 循环依赖是不是设计坏味道我的观点很明确循环依赖虽然不是洪水猛兽Spring 也确实有能力处理一部分但它永远是设计上的信号灯提示模块依赖关系可能存在不合理。如果你统计过自己项目里的循环依赖大概率会发现它们集中在几处“上帝类”上——那些承担了过多职责、几乎人人都要依赖的 Service。这类对象本身就该被拆解。Spring 官方在 2.6 里默认禁止循环引用出发点也是这个。它希望开发者能尽早暴露问题而不是靠框架的宽容把隐患带到线上。所以我个人的态度是新的代码一律不要写循环依赖发现历史遗留的循环依赖优先重构而不是加配置掩盖。我在实际开发中始终强调一个原则依赖关系应该是有向无环图而不是网状结构。这不仅是 Spring 容器能否处理的问题更关系到代码的可维护性、可测试性和可扩展性。靠着对循环依赖的深入理解把依赖关系理顺了项目跑起来会省心很多。
返回列表