Spring循环依赖与三级缓存源码深度解析
2026/9/16 7:16:30 网站建设 项目流程

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依赖B,B又依赖A。
  • 间接循环:A依赖B,B依赖C,C又依赖A。

在Spring容器里,这种“依赖”的落地方式主要有三种:构造器注入、Setter注入、字段注入(@Autowired直接打在字段上)。

这里的关键结论是:Spring能解决的循环依赖,仅限于Setter注入和字段注入这两种;构造器注入导致的循环依赖,Spring是真的一点办法都没有。原因后面会从源码角度讲清楚,你先记住这个结论,下面的一切分析都围绕它展开。

2. 三级缓存机制,Spring这套设计的核心

2.1 三个缓存分别放了什么

Spring解决单例Bean循环依赖的底层实现,在DefaultSingletonBeanRegistry这个类里。这个类维护了三组Map,也就是所谓的“三级缓存”。先看代码:

/** 一级缓存:完整创建好的单例Bean */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** 二级缓存:提前暴露的早期Bean引用,还没有完成属性填充和初始化 */ private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); /** 三级缓存:存放BeanName到ObjectFactory的映射 */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

三者的差别,我整理了一张表,面试或者自己复习的时候看这个就够了:

缓存层级数据结构存放内容什么时候放入
一级缓存singletonObjects完整创建结束、属性填充完成、初始化方法执行完的单例BeandoCreateBean执行完之后
二级缓存earlySingletonObjects已经实例化但尚未属性填充/初始化的早期Bean对象有人从三级缓存拿到工厂并调用后放入
三级缓存singletonFactories存放ObjectFactory工厂对象,用于按需生成早期引用Bean刚完成实例化就立刻放入

这里要注意一个细节:一级缓存是ConcurrentHashMap,二级也是,但三级缓存用的是普通的HashMap。原因在于三级缓存的读写都发生在doCreateBeangetSingleton的同步块里,不需要高并发支持。

很多人在网上看到“三级缓存解决循环依赖”这句话就背下来了,但你要是问他“二级缓存里存的是什么”,他可能随口就说“存的是半成品Bean”。这种说法其实不准确。二级缓存里存的不是ObjectFactory,而是ObjectFactory.getObject()调用后的结果,也就是真正被提前暴露出去的那个对象引用。

2.2 一次创建过程里,三级缓存是怎么流转的

用一个最典型的场景:OrderServiceUserService互相通过字段注入依赖对方,两个类都是单例,都走Setter/字段注入。Spring创建这两个Bean的过程大致如下:

第一步,创建OrderService。Spring先调用构造器实例化出OrderService对象,此时对象已经存在,但里面的属性全是null。紧接着,Spring把OrderService对应的ObjectFactory放入三级缓存singletonFactories,key是beanName,value是() -> getEarlyBeanReference(...)这样的lambda表达式。

第二步,填充OrderService的属性。Spring发现OrderService里需要注入UserService,于是触发getBean("userService")

第三步,创建UserService。同样先实例化对象,然后把UserServiceObjectFactory放入三级缓存。

第四步,填充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(...)。这个方法会触发SmartInstantiationAwareBeanPostProcessorgetEarlyBeanReference回调,而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; }

AbstractAutoProxyCreatorgetEarlyBeanReference会在早期引用阶段提前执行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-references=true

但我强烈建议把这个配置当作“救火”手段,而不是长期方案。配置一开,等于把整个项目的依赖纪律全部放松,以后新加入的循环依赖不会被发现,问题会越积累越深。

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注入,或对其中一个依赖加@Lazy
prototype作用域互相依赖Circular dependency and scope prototype调整作用域,避免prototype互相引用
初始化后Bean不满足早期引用检查BeanCurrentlyInCreationException,且堆栈里有自定义BeanPostProcessor检查自定义BeanPostProcessor是否替换了Bean实例
代理失效,注入的对象没有事务/切面效果项目能启动,但调用方法时AOP不生效确认是否存在循环依赖,早期引用阶段是否生成了代理

排查时有一个通用思路:先在报错堆栈里找到第一个BeanCurrentlyInCreationExceptionBeanCreationException,大概率那个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-references=true,又能启动了。

接着在OrderService里加一个有@Transactional的方法,通过Debug模式观察:当UserService注入OrderService时,拿到的对象是不是一个Cglib代理。你会发现,这个代理不是初始化之后才创建的,而是在getEarlyBeanReference阶段就已经生成了。

这个Demo虽然简单,但能直观验证两级缓存和三级缓存的区别:如果把三级缓存删除、只留二级缓存,光靠提前暴露原始对象是没法让@Transactional生效的。这也是面试官最爱追问的细节。

5.4 我的实操心得

最后一次提醒还是那个观点:能不用循环依赖就不用。三级缓存是Spring为了解决历史兼容性而保留的精巧设计,不是鼓励你写绕来绕去的依赖关系。

我在项目里比较推荐的做法是:新代码从头就约定好依赖只允许单向流动,构建检查阶段如果发现循环依赖直接报红;老代码则定期梳理依赖图,把循环依赖作为技术债务逐步清理。等到你的代码里完全没有循环依赖时,Spring Boot升级版本、调整AOP配置、优化启动速度等等都会少掉一大半麻烦。对这个机制本身,理解原理,会排查,能应急,就够了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询