尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring循环依赖与三级缓存源码深度解析

Spring循环依赖与三级缓存源码深度解析 1. 先说说循环依赖是怎么把Spring搞崩的1.1 经典报错现场做Java后端的朋友特别是平时用Spring Boot写业务系统的多多少少都遇到过下面这种报错尤其是项目升级过Spring Boot版本之后某天启动应用直接起不来了The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | userService (field private com.example.UserMapper userMapper) ↑ ↓ | userMapper └─────┘刚接触Spring的人第一次看到这个提示往往会愣住“我明明只是两个类互相注入了一下怎么启动就挂了”如果你用的是Spring Boot 2.6之前的版本可能从来没有遇到过因为老版本默认允许循环依赖“绕过去”Spring自己会想办法把Bean创建出来。但从2.6版本开始Spring Boot把默认配置改成了禁止循环依赖很多原本能在老版本跑得飞起的项目升级之后第一个报错往往就是这个。这篇文章不打算讲那种“背答案”式的三级缓存面试八股而是想站在实际开发和源码验证的角度把Spring解决循环依赖的思路拆开揉碎说清楚它到底解决了什么、解决不了什么以及你在真实项目里应该怎么处理。1.2 什么是循环依赖循环依赖说白了就是Bean和Bean之间形成了环形的依赖关系直接循环A依赖BB又依赖A。间接循环A依赖BB依赖CC又依赖A。在Spring容器里这种“依赖”的落地方式主要有三种构造器注入、Setter注入、字段注入Autowired直接打在字段上。这里的关键结论是Spring能解决的循环依赖仅限于Setter注入和字段注入这两种构造器注入导致的循环依赖Spring是真的一点办法都没有。原因后面会从源码角度讲清楚你先记住这个结论下面的一切分析都围绕它展开。2. 三级缓存机制Spring这套设计的核心2.1 三个缓存分别放了什么Spring解决单例Bean循环依赖的底层实现在DefaultSingletonBeanRegistry这个类里。这个类维护了三组Map也就是所谓的“三级缓存”。先看代码/** 一级缓存完整创建好的单例Bean */ private final MapString, Object singletonObjects new ConcurrentHashMap(256); /** 二级缓存提前暴露的早期Bean引用还没有完成属性填充和初始化 */ private final MapString, Object earlySingletonObjects new ConcurrentHashMap(16); /** 三级缓存存放BeanName到ObjectFactory的映射 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16);三者的差别我整理了一张表面试或者自己复习的时候看这个就够了缓存层级数据结构存放内容什么时候放入一级缓存singletonObjects完整创建结束、属性填充完成、初始化方法执行完的单例BeandoCreateBean执行完之后二级缓存earlySingletonObjects已经实例化但尚未属性填充/初始化的早期Bean对象有人从三级缓存拿到工厂并调用后放入三级缓存singletonFactories存放ObjectFactory工厂对象用于按需生成早期引用Bean刚完成实例化就立刻放入这里要注意一个细节一级缓存是ConcurrentHashMap二级也是但三级缓存用的是普通的HashMap。原因在于三级缓存的读写都发生在doCreateBean和getSingleton的同步块里不需要高并发支持。很多人在网上看到“三级缓存解决循环依赖”这句话就背下来了但你要是问他“二级缓存里存的是什么”他可能随口就说“存的是半成品Bean”。这种说法其实不准确。二级缓存里存的不是ObjectFactory而是ObjectFactory.getObject()调用后的结果也就是真正被提前暴露出去的那个对象引用。2.2 一次创建过程里三级缓存是怎么流转的用一个最典型的场景OrderService和UserService互相通过字段注入依赖对方两个类都是单例都走Setter/字段注入。Spring创建这两个Bean的过程大致如下第一步创建OrderService。Spring先调用构造器实例化出OrderService对象此时对象已经存在但里面的属性全是null。紧接着Spring把OrderService对应的ObjectFactory放入三级缓存singletonFactorieskey是beanNamevalue是() - getEarlyBeanReference(...)这样的lambda表达式。第二步填充OrderService的属性。Spring发现OrderService里需要注入UserService于是触发getBean(userService)。第三步创建UserService。同样先实例化对象然后把UserService的ObjectFactory放入三级缓存。第四步填充UserService的属性。它需要注入OrderService于是触发getBean(orderService)。这时一级缓存里没有OrderService因为它的属性还没填充完二级缓存里也没有。但Spring发现OrderService正在创建中容器维护了一个singletonsCurrentlyInCreation集合于是去三级缓存找到了OrderService对应的工厂对象调用工厂拿到OrderService的早期引用把这个引用放入二级缓存同时把三级缓存里的工厂移除。第五步UserService拿到OrderService的早期引用属性填充完成走完初始化逻辑最终被放入一级缓存。第六步回到OrderService的创建流程。它继续填充属性此时UserService已经在一级缓存了直接拿到完整对象然后走完初始化最终也被放入一级缓存。到这里两个Bean全部创建完成全程没有报错。整个过程的精髓在于Spring允许Bean“还没完全变好就先露个脸”让其他Bean先拿着引用等大家都创建完了再各自补全属性。2.3 为什么必须要有第三级缓存两级不行吗这是面试里最喜欢追问的问题也是很多人理解最模糊的地方。先把结论摆出来如果没有AOP代理只有一级和二级缓存就足够解决循环依赖了。那为什么Spring非要搞一个三级缓存出来关键就在ObjectFactory里的那行lambda() - getEarlyBeanReference(...)。这个方法会触发SmartInstantiationAwareBeanPostProcessor的getEarlyBeanReference回调而Spring AOP的AbstractAutoProxyCreator恰好实现了这个接口。什么意思呢当一个Bean需要被代理比如方法上有Transactional或自定义AOP切面并且它提前被其他人依赖了那么getEarlyBeanReference会在这里直接生成代理对象。举例说明假设OrderService加了Transactional它本身需要被代理。如果Spring只有一级二级缓存那么创建OrderService时直接实例化出原始对象放入二级缓存UserService在依赖它时拿到的就是原始对象。等OrderService真正走完BeanPostProcessor链、生成代理对象之后UserService里持有的仍然是原始对象事务、AOP全部失效。那有人会问能不能在实例化之后直接把代理对象生成出来放到二级缓存里理论上可以但代价很大。第一不是所有Bean都需要代理如果不管三七二十一都提前生成代理等于让所有参与者都白白多走一遍代理创建流程性能损耗非常明显。第二代理对象的创建大概率会触发AOP相关的Bean初始化这些Bean可能又依赖其他Bean搞不好会引出更深层的循环依赖处理起来极其棘手。三级缓存的聪明之处在于“懒”先只放一个工厂没人依赖就永远不调用一切照旧有人依赖了才通过工厂去生成精确的代理对象。用一句话总结就是按需创建代理前置性能最优。3. 源码视角看看Spring到底做了什么3.1 关键代码路径从getBean到doCreateBean理解了设计思路再去看源码会轻松很多。整个循环依赖处理涉及的类和调用链路大致是AbstractApplicationContext.finishBeanFactoryInitialization()容器刷新的最后一步开始创建所有非懒加载的单例Bean。AbstractBeanFactory.doGetBean()真正的getBean实现先尝试从缓存获取拿不到就执行创建流程。DefaultSingletonBeanRegistry.getSingleton(String beanName, boolean allowEarlyReference)核心方法三级缓存就在这里流转。AbstractAutowireCapableBeanFactory.doCreateBean()完成Bean的实例化、属性填充、初始化。AbstractAutowireCapableBeanFactory.getEarlyBeanReference()提前暴露时生成代理的方法。先看getSingleton这个入口方法。它干的事情是先从一级缓存拿拿不到再看这个Bean是不是正在创建中是的话再去二级缓存拿还拿不到就去三级缓存找工厂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; }这个方法逻辑很清晰三级缓存里拿到工厂之后立刻getObject()把结果放到二级缓存再从三级缓存移除。也就是说同一个Bean的提前引用只会被创建一次之后都从二级缓存直接拿不会每次重复生成。这个设计很重要否则会出现多个Bean依赖同一个正在创建的Bean时拿到不同的代理对象引用不一致。3.2 doCreateBean实例化后立刻放入三级缓存再看看doCreateBean里跟循环依赖相关的部分。Bean实例化完成之后有一段这样的逻辑boolean earlySingletonExposure (mbd.isSingleton() this.allowCircularReferences isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { addSingletonFactory(beanName, () - getEarlyBeanReference(beanName, mbd, bean)); }这里有个容易被忽略的开关allowCircularReferences。它默认是true但Spring Boot 2.6之后对外暴露了spring.main.allow-circular-references配置默认值改成了false。也就是说不是Spring本身不支持循环依赖了而是Spring Boot在默认配置层面把它关掉了。后面实战部分我会再细说这个变化会带来什么问题。再往下看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的getEarlyBeanReference会在早期引用阶段提前执行AOP代理public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey getCacheKey(bean.getClass(), beanName); this.earlyProxyReferences.put(cacheKey, bean); return wrapIfNecessary(bean, beanName, cacheKey); }所以当一个Bean同时满足两点——被其他Bean提前依赖、自身需要AOP代理——它会在提前暴露的那一刻就变成代理对象而不是等到初始化结束。这就解释了为什么B拿到的A早期引用和A最终的一级缓存对象是同一个。3.3 创建末尾的早期引用检查Spring在Bean完成初始化之后还会做一次检查防止某些极端情况下Bean被“抢跑”导致最终容器里放的对象和缓存里放的对象不一致if (earlySingletonExposure) { Object earlySingletonReference getSingleton(beanName, false); if (earlySingletonReference ! null) { if (exposedObject bean) { exposedObject earlySingletonReference; } // 如果exposedObject已经被BeanPostProcessor改成另一个对象 // 并且还有别的Bean依赖了earlySingletonReference // 直接抛出BeanCurrentlyInCreationException } }这段代码的意思是如果这个Bean曾经被提前暴露过那么容器最终保存的应该是早期引用多数情况下就是代理对象而不是原始Bean。如果早期引用和最终对象不一致且没有合理的处理方式就直接抛异常防止容器里的引用关系错乱。这段逻辑平时很难触发但一旦出现通常意味着项目中使用了比较“野”的自定义BeanPostProcessor把Bean在初始化阶段换成了另一个对象。遇到这种报错优先检查自己写的BeanPostProcessor逻辑。4. 这些循环依赖Spring是真解决不了4.1 构造器注入的循环依赖是死结前面说过Spring解决循环依赖的前提是Bean可以先实例化出来再填充属性。如果两个Bean都使用构造器注入那么创建A的时候必须先把B创建出来创建B的时候又必须先把A创建出来两边都被卡在“构造器调用”这一步谁都无法提前暴露对象。Spring没有魔法能让一个对象在构造器执行之前就拿到引用所以直接抛出BeanCurrentlyInCreationException。解决办法也很干脆别用构造器循环依赖。要么改成Setter/字段注入让Spring有“先实例化、后填充”的空间要么用Lazy对其中一个依赖做延迟代理打断循环链。关于Lazy值得一提它注入的是一个JdkDynamicAopProxy或Cglib代理真正调用时才去容器里找目标Bean这样能彻底绕开创建阶段的循环问题。但代价是每次调用多一层代理而且对有些场景下方法内this调用导致代理失效的问题需要额外注意。4.2 prototype作用域的Bean无法解决三级缓存只服务于单例Bean。prototype作用域的Bean不缓存每次都直接创建新对象所以Spring根本不会为它维护任何“正在创建”的记录也没有地方存放ObjectFactory。两个prototype Bean互相依赖时容器会直接报错。从设计上想也合理prototype本来就不强调共享每次通过getBean拿到的都是新对象为了创造一个周期很短的对象去搞一套复杂的缓存机制完全没有价值。4.3 Async和循环依赖同时出现时的坑这个坑比较隐蔽。当一个Bean既被其他Bean循环依赖本身又有Async方法时容易出现“注入的Bean没有异步效果”或者启动直接报错的情况。原因在于Async的代理是在初始化阶段通过AsyncAnnotationBeanPostProcessor生成的而这个代理和getEarlyBeanReference阶段生成的AOP代理不是同一个对象早期引用检查很容易发现不一致于是抛出异常。遇到这种情况我的建议是不要试图靠调整缓存配置硬扛直接重构。把Async注解挪到独立的Service里让异步方法与循环依赖完全解耦比改任何配置都省心。4.4 Spring Boot 2.6 为什么默认禁止循环引用Spring团队默认关掉循环引用的理由很直接循环依赖百分之九十以上都意味着设计上有问题依赖关系应该是清晰的单向流动而不是绕成一个环。老版本太宽容了导致很多人根本意识不到自己写出来的依赖关系已经腐化每次启动都靠三级缓存“掩盖”问题直到某次重构才彻底爆雷。如果你升级到Spring Boot 2.6以上项目里确实存在循环依赖且短期内不打算重构可以临时打开开关spring.main.allow-circular-referencestrue但我强烈建议把这个配置当作“救火”手段而不是长期方案。配置一开等于把整个项目的依赖纪律全部放松以后新加入的循环依赖不会被发现问题会越积累越深。5. 实战避坑与排查清单5.1 从根上避免循环依赖往往是设计信号我处理过很多循环依赖问题最大的体会是大部分循环依赖都不是“技术上绕不过去”而是代码划分职责时出了问题。举一个最常见的例子OrderService要调UserService查询用户信息UserService又要调OrderService查询用户最近订单。如果这两个类在一个模块里就会形成互相引用。但仔细想想“查询用户的最近订单”这个逻辑放在OrderService更合理UserService不应该反向依赖订单逻辑。把责任边界划清楚之后原来的循环依赖自然消失了。所以遇到循环依赖第一反应不应该是“怎么让Spring放行”而是先看看能不能通过以下方式拆解把公共逻辑抽到第三个Service或者独立的领域服务里。让依赖方向变成单向比如只允许UserService依赖OrderService反过来用事件机制解耦。使用Lazy在必要时打断循环。用ObjectProvider延迟获取依赖需要时才取。其中ObjectProvider是比较实用的替代方案它本身不会触发依赖Bean的立即创建需要使用时才调用getIfAvailable()等方法拿对象。某些实时性要求不高的场景比直接注入字段要优雅得多。5.2 常见报错信息对照与排查思路我整理了一些实际项目里最容易遇到的报错以及对应的定位方向方便你排查时对照场景典型报错排查方向Spring Boot 2.6启动失败The dependencies of some of the beans in the application context form a cycle查看allow-circular-references配置或直接重构构造器注入循环依赖BeanCurrentlyInCreationException: Error creating bean with name a改成Setter注入或对其中一个依赖加Lazyprototype作用域互相依赖Circular dependency and scope prototype调整作用域避免prototype互相引用初始化后Bean不满足早期引用检查BeanCurrentlyInCreationException且堆栈里有自定义BeanPostProcessor检查自定义BeanPostProcessor是否替换了Bean实例代理失效注入的对象没有事务/切面效果项目能启动但调用方法时AOP不生效确认是否存在循环依赖早期引用阶段是否生成了代理排查时有一个通用思路先在报错堆栈里找到第一个BeanCurrentlyInCreationException或BeanCreationException大概率那个Bean就是循环链上的关键点。然后顺着报错信息里的依赖关系打印画一个简单的依赖图马上就能看出环在哪里。5.3 最小复现Demo与配套验证方法如果你刚接触这个机制想真正跑一遍加深理解我建议做一个最小Demo。先搞两个类互相用字段注入对方Component public class OrderService { Autowired private UserService userService; } Component public class UserService { Autowired private OrderService orderService; }写一个启动类用Spring Boot 2.5版本跑一次默认能启动成功再用Spring Boot 2.7版本跑一次大概率报循环依赖错误。然后在application.properties里加上spring.main.allow-circular-referencestrue又能启动了。接着在OrderService里加一个有Transactional的方法通过Debug模式观察当UserService注入OrderService时拿到的对象是不是一个Cglib代理。你会发现这个代理不是初始化之后才创建的而是在getEarlyBeanReference阶段就已经生成了。这个Demo虽然简单但能直观验证两级缓存和三级缓存的区别如果把三级缓存删除、只留二级缓存光靠提前暴露原始对象是没法让Transactional生效的。这也是面试官最爱追问的细节。5.4 我的实操心得最后一次提醒还是那个观点能不用循环依赖就不用。三级缓存是Spring为了解决历史兼容性而保留的精巧设计不是鼓励你写绕来绕去的依赖关系。我在项目里比较推荐的做法是新代码从头就约定好依赖只允许单向流动构建检查阶段如果发现循环依赖直接报红老代码则定期梳理依赖图把循环依赖作为技术债务逐步清理。等到你的代码里完全没有循环依赖时Spring Boot升级版本、调整AOP配置、优化启动速度等等都会少掉一大半麻烦。对这个机制本身理解原理会排查能应急就够了。
返回列表