ARTICLE DETAIL

资讯详情

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

Spring循环依赖与三级缓存:为什么非得是三级?

Spring循环依赖与三级缓存:为什么非得是三级? 循环依赖这个问题Spring面试基本是必问的网上分析文章也一抓一大把。但很多文章要么只讲结论要么贴一堆源码却讲不清楚关键转折点导致不少人看完还是似懂非懂知道三级缓存三个Map的名字但从来没搞明白为什么非得是三级二级行不行一级行不行构造函数注入到底为什么不行我自己带团队这几年每次招人聊到这块能把这个链条讲透的人真不多。大家更多是“背答案”而不是“理解设计”。所以这篇我不打算按教科书顺序平铺直叙而是从问题的本质出发一层一层还原Spring作者在设计时的思考过程。先说一个我个人的结论三级缓存并不是为了解决循环依赖这个问题的唯一方案它是Spring在“保持单例Bean生命周期一致性”和“兼容AOP代理”之间的一个精妙折中。理解了这句话三级缓存基本就吃透了。1. 循环依赖的本质不是Spring的问题而是对象生命周期的错位要理解三级缓存首先得搞清楚循环依赖到底难在哪。很多人觉得循环依赖就是两个类互相引用的简单问题用Java自己的构造函数就能解决搞不懂Spring为什么绕这么大一圈。1.1 一段最普通的代码引发的启动失败假设我们有两个Service代码长这样Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }启动项目Spring容器直接抛出异常核心报错信息是BeanCurrentlyInCreationException: Error creating bean with name aService: Requested bean is currently in creation: Is there an unresolvable circular reference?第一次遇到这个报错的人都会懵我代码里互相引用得很自然啊为什么容器创建不出来1.2 创建一个Bean到底经历了哪些阶段要回答这个问题得先看看Spring创建一个普通单例Bean要经过哪些步骤这里只说生命周期主干不考虑各种BeanPostProcessor的扩展点阶段做的事情通俗理解实例化通过构造器new出对象相当于把人的“身份证”办下来此时对象已经有了但内部属性全是空的属性填充通过Setter、字段注入等方式给这个新对象赋值它依赖的其他Bean相当于把人的“银行卡、社保、驾照”都绑上去初始化执行InitializingBean、PostConstruct等方法相当于领到证之后去激活各种功能使用放入单例池singletonObjects正式进入“可用状态”关键在于第二步。当Spring创建AService时发现AService依赖BService于是转去创建BService。创建BService时发现又依赖AService——于是Spring回头想找AService但AService正处于“实例化完成、属性填充到一半”的状态还没有放进单例池此时Spring手里没有AService的完整引用。如果就这样傻等着两个Bean会互相等待对方先完成谁也等不到谁死锁。1.3 循环依赖的本质矛盾对象完整状态与对象引用的时间差Java里两个对象互相引用本身毫无难度因为new出来的对象引用可以直接赋值。真正难的是Spring管理的是“完整的Bean”也就是要走完实例化、属性填充、初始化三个阶段的Bean。而循环依赖需要在一个Bean尚未完成全部生命周期时把它提前暴露给另一个Bean使用。以前项目里自己写管理器的时候也遇到过这个场景我当时的做法是搞一个“半成品池”把还没初始化好的对象先放进去等别的对象来取。这个思路跟Spring的三级缓存其实是一个道理只是Spring做得更精细因为还需要照顾AOP代理。所以不要被“三级缓存”这个名字吓住它的核心目标只有一个**在Bean的实例化阶段提前暴露引用但又不能破坏Bean的完整生命周期语义。**想清楚这个目标后面的设计就顺理成章了。2. 三级缓存的结构拆解为什么要分三个Map而不是一个Spring的三级缓存说白了就是三个Map它们分工完全不一样。很多文章直接列出来但不解释为什么这么划分读者就会背了Map名字却讲不出设计思路。2.1 三个Map各自的职责与存在意义直接看定义在DefaultSingletonBeanRegistry类里/** 一级缓存最终存放完整的、可用的单例Bean */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存存放早期暴露的Bean引用但此时Bean可能还没完成初始化 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存放ObjectFactory工厂用来生成早期Bean引用 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);逐个说清楚singletonObjects一级缓存这个是Spring的单例池日常开发中通过getBean拿到的Bean最终都在这里。不管三级缓存怎么折腾只有真正走完生命周期的Bean才能进入这个Map。earlySingletonObjects二级缓存存放“早期引用”也就是对象已经new出来了但属性还没填充完、初始化方法还没执行。为什么需要单独放一个Map因为不能让它和完整的单例Bean混在一起否则有些依赖它的代码拿到一个残缺对象而不自知会出大乱子。singletonFactories三级缓存存放ObjectFactory。这个跟二级缓存有微妙区别二级缓存直接放对象引用三级缓存放的是一个“工厂”等真正需要的时候再通过工厂getObject()生成对象。为什么要多绕这一层这是解决代理问题的关键后面单独讲。2.2 一个Bean在三级缓存中的完整流动路径光看定义不直观我给你画一个文字版的流动过程创建AService 1. new AService() - 对象产生 2. addSingletonFactory(aService, () - getEarlyBeanReference(...)) - 放入三级缓存 singletonFactories 3. 填充属性时发现需要BService - 转而创建BService 4. BService 填充属性时发现需要AService - getSingleton(aService, true) - 一级缓存没有二级缓存没有三级缓存有 - 执行三级缓存的ObjectFactory.getObject() - 得到AService的早期引用可能是代理对象 - 放入二级缓存并从三级缓存移除 - 把早期引用注入给BService 5. BService 完成初始化进入一级缓存 6. 回到AService继续属性填充注入BService 7. AService 完成初始化进入一级缓存注意第5步BService注入的AService是“早期引用”。如果此时AService后续没有被AOP代理那这个早期引用和最终的一级缓存对象是同一个如果AService被代理了情况就复杂了这是第三部分要讲的重头戏。2.3 一级缓存为什么不够用直接放进去不行吗有人可能会问既然都要提前暴露为什么不干脆在new完对象之后直接放进一级缓存省得搞三级缓存这么麻烦。答案是**不能。**如果直接把new出来的半成品对象放进singletonObjects那么所有调用getBean的地方都可能拿到未初始化完成的对象。比如一个Bean在初始化阶段要加载配置、建立连接如果被提前消费了那依赖它的另一个Bean拿到的是一个“残废版”。而且Spring在执行完生命周期之后还会把对象再put一次到singletonObjects同一个key被半成品和成品覆盖整个单例池的语义就乱了。一级缓存表达的是“这个Bean已经可用”这个强语义。任何提前暴露都必须走另外的通道。这也是为什么需要二级缓存来放“早期引用”让完整对象和半成品对象物理隔离。3. 核心关键点二级缓存能解决普通循环依赖为什么还需要第三级这是整个三级缓存讨论里最有含金量的问题。如果你能回答清楚“为什么二级缓存还不够”面试官基本就会点头。3.1 两级缓存的假想方案剥离三级缓存会怎样先做一个思想实验如果Spring只保留一级缓存和二级缓存三级缓存完全砍掉它还能解决循环依赖吗完全可以。流程会变成这样1. new AService() - 直接放入二级缓存 earlySingletonObjects 2. 填充属性发现需要BService - 创建BService 3. BService 填充属性发现需要AService - 从二级缓存取到AService注入 4. BService 完成进入一级缓存 5. AService 继续填充完成进入一级缓存并从二级缓存移除代码逻辑上没有障碍。早期引用可以直接在new出来之后放进二级缓存依赖方过来取就行了。3.2 代理对象带来的麻烦拿到的是原始对象还是代理对象但是现实的Spring不是这么简单的这里有AOP。假设AService有一个Transactional方法或者被Async标注那么Spring容器中最终保存的、大家依赖的AService应该是经过动态代理增强后的代理对象而不是原始new出来的那个对象。如果只有两级缓存AService在new出来之后直接放了一个原始对象的早期引用到二级缓存BService注入的是原始对象。等AService走完生命周期Spring生成代理对象把代理对象放入一级缓存。问题来了BService持有的AService是原始对象而后续其他Bean从一级缓存拿到的AService是代理对象同一个Bean出现了两个不同的实例。这会导致一个非常隐蔽的BugBService调AService的方法时走的不是代理逻辑事务注解、异步注解全部失效。而且两个对象内部状态还不一致排查起来极其痛苦。3.3 第三级缓存的真实用途延迟决定是否代理第三级缓存的精妙之处在于它不直接存“对象”而是存一个ObjectFactory。这个工厂什么时候执行、要不要执行完全由Spring控制。核心看一下getEarlyBeanReference的逻辑protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) { Object exposedObject bean; if (!mbd.isSynthetic() hasInstantiationAwareBeanPostProcessors()) { for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessors()) { exposedObject bp.getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }关键就在这一行bp.getEarlyBeanReference(exposedObject, beanName)。当Spring处理循环依赖、需要提前暴露AService时三级缓存里的工厂会调用这个后置处理器。如果有AOP需求这里就会返回代理对象如果没有就返回原始对象。也就是说**三级缓存把“是否生成代理对象”这个决定推到了“真正需要暴露给其他Bean时”这个时间点。**放在三级缓存阶段时AService还没经过完整的BeanPostProcessor链Spring还不知道它有没有AOP增强需求所以不能提前生成代理只能存一个工厂等确定了再生成。用大白话讲Spring不敢提前做决定于是搞了个“代金券”ObjectFactory等你来消费的时候再告诉你最终能换到什么。这就是第三级缓存存在的全部理由。3.4 结论第三级缓存是AOP代理与单例一致性之间的桥梁所以完整的结论是这样如果Spring没有AOP二级缓存完全够了。因为有了AOP代理Bean在生命周期的不同阶段可能对应不同的对象原始对象 vs 代理对象。为了不破坏“同一个单例Bean在容器中必须只有一个实例”的语义Spring用三级缓存放ObjectFactory让早期引用的生成时机尽可能推迟在真正需要时通过后置处理器生成“正确的对象”并把这个对象缓存进二级缓存保证整个创建周期里被拿到的引用是同一个。这也是为什么二级缓存叫earlySingletonObjects——一旦三级缓存执行了工厂方法生成的对象就被提升到二级缓存后续再有Bean依赖AService直接从二级缓存取工厂不会重复执行代理对象不会反复生成。4. 源码走读从getSingleton看三级缓存的完整协作链路前面讲的都是设计思路这一节进源码。我挑最核心的几个方法把调用链拉出来。源码版本以Spring 5.x为主整体逻辑在6.x里也没变化。4.1 getSingleton方法三级缓存查找的入口Spring创建Bean的时候第一步就是查缓存。入口方法在DefaultSingletonBeanRegistryprotected Object getSingleton(String beanName, boolean allowEarlyReference) { // 第一步查一级缓存 Object singletonObject this.singletonObjects.get(beanName); // 一级缓存没有且当前Bean正在创建中 if (singletonObject null isSingletonCurrentlyInCreation(beanName)) { // 第二步查二级缓存 singletonObject this.earlySingletonObjects.get(beanName); // 二级缓存也没有且允许提前引用 if (singletonObject null allowEarlyReference) { synchronized (this.singletonObjects) { // 加锁后再次确认Double Check singletonObject this.singletonObjects.get(beanName); if (singletonObject null) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null) { // 第三步从三级缓存拿ObjectFactory 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)为true时才允许走二级缓存和三级缓存逻辑。也就是说如果一个Bean压根没有在创建过程中缓存查不到就直接返回null不会误用半成品。加锁用的是synchronized (this.singletonObjects)锁的是一级缓存对象。因为这三个Map的读写需要保持一致性用singletonObjects作为锁对象是Spring作者的一个小技巧。三级缓存取到ObjectFactory之后立即执行getObject()结果放入二级缓存同时从三级缓存移除。这个“提升”动作非常重要保证了同一个Bean的早期引用只生成一次避免后续并发请求时重复执行工厂方法生成多个代理对象。4.2 addSingletonFactory三级缓存何时写入看完了读取再看写入。Bean实例化完成后属性填充之前Spring会调用addSingletonFactoryprotected void addSingletonFactory(String beanName, ObjectFactory? singletonFactory) { synchronized (this.singletonObjects) { if (!this.singletonObjects.containsKey(beanName)) { this.singletonFactories.put(beanName, singletonFactory); this.earlySingletonObjects.remove(beanName); this.registerSingleton(beanName, singletonObject); } } }写入时机其实在AbstractAutowireCapableBeanFactory的doCreateBean方法里// 允许提前暴露引用 boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }也就是说**默认情况下所有单例Bean在完成实例化后都会立刻把自己的ObjectFactory放进三级缓存。**不管是不是真的有循环依赖这个动作都会做。这是一种“预先准备”的策略——Spring不提前判断你有没有循环依赖而是先把这个通道打开等真正需要的时候再说。这个细节也解释了另一个常见疑惑为什么单例Bean默认都支持循环依赖而prototype的Bean不行。4.3 addSingleton合并一级缓存的时间点Bean走完属性填充和初始化之后会调用addSingletonprotected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); this.earlySingletonObjects.remove(beanName); this.registeredSingletons.add(beanName); } }这里把一级缓存写入、二级和三级缓存删除。至此这个Bean正式成为容器中的“正规军”。整个链路闭环了。4.4 getEarlyBeanReference对SmartInstantiationAwareBeanPostProcessor的调用前面讲了getEarlyBeanReference的代码这里再展开讲一个点。这个方法的入参是原始对象返回值可能是代理对象。转换逻辑在AbstractAutoProxyCreator里Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }注意这一行this.earlyProxyReferences.put(cacheKey, bean)。它提前把原始对象记录在一个Map里后面Bean走完正常生命周期Spring再次执行包装逻辑时会先查一下earlyProxyReferencesOverride public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean ! null) { Object cacheKey getCacheKey(bean.getClass(), beanName); if (this.earlyProxyReferences.remove(cacheKey) ! bean) { return wrapIfNecessary(bean, beanName, cacheKey); } } return bean; }如果发现这个Bean已经通过getEarlyBeanReference包装过了就说明早期引用已经暴露给其他Bean了。此时不能再包装第二次直接返回原始对象。因为已经把代理对象放进二级缓存了一级缓存里只能用这个代理对象。这是一个非常容易忽略的细节但恰恰是保证“提前暴露的代理对象”和“最终单例池对象”是同一个的关键。Spring用earlyProxyReferences这个Map巧妙地避免了同一个Bean被代理两次的Bug。5. 循环依赖的三类死局哪些场景三层缓存也救不了三级缓存不是万能的有些场景下Spring照样报错或者产生恶性结果。面试官很爱问这个因为能区分“背答案”和“真理解”的人。5.1 构造函数注入的循环依赖实例化阶段就卡死构造函数注入的场景下Spring必须调用构造函数才能new出对象。但是构造函数里需要的参数本身就是还没创建好的Bean这就成了死结Service public class AService { private final BService bService; public AService(BService bService) { this.bService bService; } } Service public class BService { private final AService aService; public BService(AService aService) { this.aService aService; } }启动直接报错。原因很简单三级缓存解决循环依赖的前提是“实例化已完成只是属性还没填充完”而构造函数循环依赖是在实例化阶段就卡住了。对象都还没new出来缓存里当然没有东西可以提前暴露。我自己的建议是Spring Boot 2.6之后这个现象更明显了因为默认禁止了循环依赖。在新项目里尽量用构造器注入的好处是不养成循环依赖的习惯但老项目要破循环依赖还是得靠Lazy或者Setter注入。5.2 prototype作用域的Bean压根没有三级缓存参与prototype的Bean不会进入singletonObjects、earlySingletonObjects、singletonFactories这三个缓存。它是每次getBean都新建实例Spring容器不会缓存它。循环依赖出现时Spring无法通过缓存提前暴露半成品直接抛异常BeanCurrentlyInCreationException: Error creating bean with name aService: Requested bean is currently in creation: Is there an unresolvable circular reference?这个其实很好理解缓存池都没有你哪来的中间商提前暴露给你所以如果你在原型Bean里搞循环依赖唯一出路就是重构或者使用Provider等技术延迟获取。5.3 Async与循环依赖的叠加问题代理生成时机错乱还有一种场景非常恶心循环依赖的两个Bean里有一个加了Async。很多老项目拆循环依赖时能拆掉一旦遇到这种组合问题就变得隐蔽。原因在于Async的处理本质上也是AOP增强但它不是通过常规的getEarlyBeanReference暴露代理的。在早期引用生成时Async的后置处理器可能还没被触发导致提前暴露出去的对象没有被异步代理包装而最终单例池里的对象却是代理后的。结果就是调用方拿到的对象没有异步能力方法同步执行。解决方式很简单把Async标注的Bean从循环依赖链里摘出去优先处理循环依赖再考虑异步能力不要让一个Bean同时处于循环依赖和异步增强的叠加态。说到这类问题的排查我建议团队里写代码时立一个规矩**循环依赖允许存在但必须显式标注、说明原因且不允许Async、Transactional这类带有代理增强语义的注解和它叠加。**一旦出现奇怪的代理失效问题先从循环依赖和AOP的关系入手而不是去翻业务代码。6. 面试高频追问与扩展从“背答案”到“讲设计”最后这部分是我的私货把面试中和循环依赖相关的追问全部串一遍。这些问题没有标准答案但思路比答案重要。6.1 为什么单例Bean默认支持循环依赖原型Bean不支持单例Bean在整个容器生命周期内只有一个实例Spring可以用缓存提前暴露半成品等完整生命周期结束后再替换成正品。原型Bean每次都新建没有缓存概念Spring根本没法在“另外一次请求”中定位到同一个半成品对象。换句话说循环依赖的破局前提是“这个Bean最终只有一个”而三级缓存里存的所有东西都是围绕这个“唯一实例”服务。6.2 三级缓存都是线程安全的吗singletonObjects是ConcurrentHashMap线程安全。earlySingletonObjects也是ConcurrentHashMap线程安全。singletonFactories不是它是个普通HashMap但所有读写都发生在synchronized (singletonObjects) 锁块内所以外部操控安全。这个设计有点意思因为singletonFactories只在Bean创建阶段写入和读取而创建过程是单线程的所以不需要ConcurrentHashMap用synchronized保证可见性和原子性就够了。如果想在回答里加分可以提一下这句。6.3 为什么早期引用生成后要放入二级缓存因为三级缓存存的ObjectFactory每次getObject()理论上都能生成一个新对象。如果不放入二级缓存每次都从工厂拿可能拿到不同的代理对象副本。放入二级缓存后同一个早期引用只有一次生成机会后面直接复用保证实例的一致性。6.4 循环依赖是坏设计吗Spring为什么要“容忍”它Spring支持循环依赖不代表Spring鼓励循环依赖。从工程角度看循环依赖往往意味着职责划分不够清晰两个模块耦合太深。但Spring不能直接禁止因为现实世界里存在大量历史遗留代码它们用Setter注入和循环依赖组织业务直接禁止会破坏兼容性。Spring的取舍是默认开启循环依赖支持allowCircularReferences默认true让老项目能跑但Spring Boot 2.6以后默认关闭循环依赖spring.main.allow-circular-referencesfalse想用可以手动打开。这是一个很有代表性的“向上兼容”策略。6.5 手写一个最小版三级缓存需要哪些类如果面试官让你口述手写Spring IOC的循环依赖支持你可以按这个最小结构答一个BeanDefinitionRegistry保存Bean的定义信息一个SingletonBeanRegistry提供getSingleton、addSingletonFactory、addSingleton三个核心方法三个Map分别对应一、二、三级缓存一个简单的BeanPostProcessor链用来模拟AOP代理生成创建流程里在实例化后、属性填充前显式调用addSingletonFactory。逻辑跑通的标准两个互相引用的单例Bean通过容器获取时拿到的引用必须和注入的引用是同一个完整Bean。6.6 解决循环依赖除了三级缓存还有哪些手段给团队里的同学排错的时候我一般按这个优先级给建议Lazy在注入点加Lazy注入一个代理占位符真正调用时才去解析直接破除创建期的强依赖。这是改动最小的方式。Setter/字段注入如果原来是构造器注入导致循环依赖改成Setter注入可能直接解决问题但不推荐只是为了绕过循环依赖去改注入方式。重构把A依赖B、B依赖A里的公共逻辑抽到C或者把其中一个依赖改成事件、缓存、回调等方式。ApplicationContext.getBean延迟获取使用ObjectProvider或者ApplicationContextAware在运行时手动获取把创建期依赖变成运行期依赖。优先级排序理由很简单能用Lazy解决的就别大动干戈但长期来看循环依赖一旦出现总归是个坏味道能重构还是重构。最后再分享一个小技巧不知道你们有没有遇到过这种场景本地启动一个老项目报循环依赖错误但是看代码根本不知道是谁和谁循环了。报错信息里只有BeanCurrentlyInCreationException不显示完整链路。这种时候别慌把日志级别调到DEBUG重点看Creating shared instance of singleton bean开头的日志它会按创建顺序把每个Bean的创建过程打印出来。沿着日志找到第一个出现两次的Bean名那就是循环依赖的起点。还有个更省事的办法直接在配置文件加上启动参数spring.main.allow-circular-referencestrue如果你的Spring Boot版本是2.6以上的先确认这个开关当前是什么状态很多莫名其妙循环依赖报错其实是升级版本后这个开关默认关闭导致的。我踩过一次最深的坑是在一个微服务工程里一个看似完全没关系的Bean在初始化时需要去查配置中心配置中心客户端又依赖了那个Bean的早期引用。查了整整半天最后发现是三级缓存工厂提前触发了一个远程调用。从那时候起我对一个问题的理解就彻底改变了三级缓存不只是面试题它实实在在影响着线上应用启动时的每一个Bean创建顺序。真正理解了它你才能在诡异的启动问题面前不慌。
返回列表