一、开篇:为什么面试官总爱问循环依赖
在 Spring 面试中,循环依赖几乎是一个绕不开的高频考点。尤其是面 Java 后端、互联网大厂或业务中台岗位时,面试官往往会从一个简单的问题切入:「Spring 如何解决循环依赖?」。表面上看,这只是一个关于“两个 Bean 互相引用”的工程问题;但深入下去,它能把面试官真正关心的知识点全部串起来:Bean 的生命周期、BeanFactory 的缓存设计、依赖注入方式、Spring AOP 动态代理、单例与原型作用域、三级缓存机制,以及你对 Spring 容器底层源码的理解程度。
很多候选人背下了“三级缓存”这个名词,也能说出singletonObjects、earlySingletonObjects、singletonFactories三个 Map,但当面试官追问“为什么必须是三级,两级为什么不行”“构造器注入的循环依赖能不能解决”“加入 AOP 代理之后会发生什么”“@Async为什么会失效”时,往往就露馅了。本文将以面试场景为主线,从现象、原理、源码、设计权衡到实战踩坑,系统地把 Spring 循环依赖讲透,帮助你形成一套可以“组合回答”的知识体系。
本文默认读者已经了解 Spring IoC 容器、依赖注入的基本概念,并具备一定的 Spring Boot 使用经验。文章会结合源码片段、流程图和多套面试追问,尽量做到“知其然,也知其所以然”。建议阅读时重点关注三个层次:第一层是结论,即 Spring 能否解决循环依赖、在什么条件下解决;第二层是原理,即三级缓存各司其职的设计逻辑;第三层是边界,即哪些场景解决不了、哪些场景会踩坑、如何优雅规避。
二、什么是循环依赖
循环依赖(Circular Dependency),也叫循环引用,指的是两个或多个 Bean 在创建过程中互相直接或间接地依赖对方,形成一个引用闭环。最常见的形态如下:
@Component public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } } @Component public class UserService { private final OrderService orderService; public UserService(OrderService orderService) { this.orderService = orderService; } }在上面的代码中,OrderService依赖UserService,而UserService又依赖OrderService,形成了最简单的双向循环。如果把依赖关系放大,还可能形成 A 依赖 B、B 依赖 C、C 依赖 A 这种间接循环,有时还会牵涉三四个以上的 Bean,排查起来会更加隐蔽。
循环依赖本身并不是 Spring 独有的问题,而是所有“由容器统一管理对象生命周期”的框架都会面临的经典难题。在传统new对象的世界里,我们通常会手动拆解依赖,或者先把对象创建出来、再把引用的另一端“塞回去”,这其实就是 Spring 解决循环依赖的核心思路的朴素来源:先完成实例化,再完成属性填充,在对象尚未完全初始化时,先把它的引用“提前暴露”出去。
为了理解循环依赖,必须先分清 Spring Bean 创建过程中的两个阶段:实例化(Instantiation)和初始化(Initialization)。实例化指通过构造器或工厂方法创建出 Java 对象,调用构造函数;初始化则是在对象实例创建完成之后,进行属性填充(依赖注入)、执行@PostConstruct、afterPropertiesSet等回调的过程。
面试顺口溜:“实例化只是造壳,初始化才是填充。”Spring 解决循环依赖的窗口期,恰恰就发生在“实例化完成、属性尚未全部填充”这一段时间内。
三、Spring 循环依赖的典型场景与触发条件
并不是所有的循环依赖 Spring 都能解决。能否解决,取决于三个关键因素:作用域、注入方式、是否涉及 AOP 代理。理解这些条件,是回答面试题时不翻车的第一步。
3.1 能解决的场景
- 单例 Bean + Setter 注入 / 字段注入:这是最典型、最常见、也是 Spring 明确支持解决的场景。
- 单例 Bean + 构造器注入 + @Lazy:通过懒加载代理打破实例化阶段的强依赖,Spring 也能间接解决。
- 单例 Bean + 字段注入 + AOP 代理:Spring 通过三级缓存中的
ObjectFactory提前暴露代理对象,通常也能正确处理。
3.2 不能解决的场景
- 原型(prototype)作用域的循环依赖:因为原型 Bean 每次获取都是新对象,容器无法缓存其提前引用,Spring 默认直接抛异常,不负责解决。
- 构造器注入且未加 @Lazy 的循环依赖:两个 Bean 都在构造阶段互相要求对方实例化完成,形成死锁,容器会抛出
BeanCurrentlyInCreationException。 - 单例 Bean 的构造器循环依赖:本质上与上一条一致,构造器阶段的强依赖无法用三级缓存化解。
3.3 一张表快速对照
| 作用域 | 注入方式 | 是否加 @Lazy | 是否涉及 AOP | Spring 能否解决 |
|---|---|---|---|---|
| 单例 singleton | 字段 / Setter 注入 | 否 | 否 | 能,通过三级缓存解决 |
| 单例 singleton | 字段 / Setter 注入 | 否 | 是 | 通常能,依赖代理提前暴露 |
| 单例 singleton | 构造器注入 | 否 | 否 | 不能,抛 BeanCurrentlyInCreationException |
| 单例 singleton | 构造器注入 | 是 | 否 | 能,用代理对象打破强依赖 |
| 原型 prototype | 任意注入 | 否 | 否 | 不能,Spring 不处理原型循环依赖 |
这张表在面试中非常实用。当面试官问“Spring 一定都能解决循环依赖吗”,你可以先给出“不是”的结论,再用这张表的维度拆解:作用域、注入方式、是否懒加载、是否代理。这样回答既完整又结构化。
四、三级缓存的真面目
三级缓存是 Spring 解决循环依赖的核心机制,也是面试题最关键的得分点。所谓“缓存”,本质上是三个定义在DefaultSingletonBeanRegistry中的 Map。我们先看它们的名称和注释:
/** 一级缓存:存放完全初始化完成的单例 Bean */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** 二级缓存:存放提前暴露的早期单例 Bean,可能尚未完成属性填充 */ private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); /** 三级缓存:存放单例 Bean 的 ObjectFactory,用于延迟生成早期引用/代理 */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);为了便于记忆,可以把三级缓存分别理解为:
- 一级缓存 singletonObjects:成品仓库。里面存放的是已经完全创建、属性填充、初始化回调全部完成的 Bean。业务代码正常
getBean时优先从这里拿。 - 二级缓存 earlySingletonObjects:半成品仓库。里面存放的是已经实例化、但可能还没有完成属性填充和初始化的早期 Bean 引用。
- 三级缓存 singletonFactories:半成品“加工工厂”。里面存放的不是 Bean 本身,而是一个
ObjectFactory函数式接口,调用它可以拿到早期 Bean 引用,必要时还会生成并缓存其 AOP 代理对象。
为什么叫“三级”,是因为获取 Bean 时的查找顺序是从一级到三级依次降级:先从singletonObjects找成品,找不到就去earlySingletonObjects找半成品,再找不到就去singletonFactories找工厂并执行它。整个过程的核心逻辑在getSingleton(String beanName, boolean allowEarlyReference)中体现得淋漓尽致。
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; }从源码可以看出一个关键点:三级缓存只有在“当前 Bean 正在创建中”时才会启用。也就是说,Spring 并不是对任意 Bean 都无脑暴露半成品,而是通过singletonsCurrentlyInCreation这个 Set 记录正在创建的 Bean。只有当 A 已经进入创建流程、还没创建完,而 B 又反过来要 A 时,这个允许提前引用的逻辑才会触发。
这段代码还体现了三个细节:第一,读取一级缓存是常规路径,性能最高;第二,二级缓存和三级缓存的读取配合synchronized和双重检查,保证并发环境下的正确性;第三,一旦从三级缓存中取出工厂并成功生成对象,该对象会立即升级到二级缓存,同时把三级缓存中的工厂移除,避免重复生成。
五、循环依赖的完整解决流程拆解
光记住三个 Map 的名字远远不够,面试官更希望听到你把这套机制“讲成一部电影”。我们以最常见的 A、B 单例 Bean、字段注入为例,逐步推演 Spring 容器启动时的实际流程。
5.1 假设模型
@Component public class A { @Autowired private B b; } @Component public class B { @Autowired private A a; }5.2 逐步推演
第一步:容器准备创建 A。此时 A 不在任何缓存中。容器先将a加入singletonsCurrentlyInCreation,标记它“正在创建”。
第二步:实例化 A。Spring 通过无参构造器创建出 A 的对象外壳,此时 A 的b属性还是null。这个对象被称为“早期引用 early reference”。Spring 随即把 A 对应的ObjectFactory放入三级缓存singletonFactories,以备其他 Bean 提前引用。
第三步:填充 A 的属性。容器发现 A 依赖 B,于是需要从容器中获取 B。由于 B 尚不存在,容器转入创建 B 的流程。
第四步:容器创建 B。同样先把b加入singletonsCurrentlyInCreation,然后实例化出 B 的对象外壳,并把 B 的ObjectFactory放入三级缓存。
第五步:填充 B 的属性。容器发现 B 依赖 A,于是尝试获取 A。查一级缓存没有,但发现 A 正在创建中,于是查二级缓存;二级缓存也没有,最终允许早期引用,便从三级缓存中取出 A 的ObjectFactory,调用getObject()拿到 A 的早期引用,把它升级放入二级缓存,并移除三级缓存中的 A 工厂。
第六步:B 拿到 A 的早期引用后完成属性填充。随后执行 B 的初始化回调,B 变成完整 Bean,并被放入一级缓存singletonObjects。同时 B 的三级缓存、二级缓存相关记录被清理。
第七步:回到 A 的创建流程。A 拿到完整 B 并完成属性填充,再执行自己的初始化回调,A 也变成完整 Bean,放入一级缓存。
第八步:整个过程结束。最终一级缓存中同时存在完好的 A 和 B。注意,A 中持有的 B 是完整 Bean,而 B 中持有的 A 是从三级缓存提前暴露出来的早期引用,但两者最终指向的是同一个对象实例。
面试金句:Spring 不是“消除”了循环依赖,而是利用“先实例化、后填充”的时间差,把半成品对象提前放进缓存,让后创建的一方先拿到引用,从而打破创建顺序上的僵局。
这个过程可以用一句话总结:A 造壳进三级缓存,发现缺 B 去建 B;B 造壳后发现缺 A,回头从三级缓存里把 A 的半成品拎出来先用;B 完工回填给 A;A 补齐属性后也完工。如果能向面试官流畅地讲完这一条链路,基本已经超过大半候选人。
六、为什么必须是三级缓存,两级真的不行吗
这是面试中极容易被追问、也极能拉开差距的问题。很多人能背出“三级缓存”,却说不清“为什么不能删掉其中某一级”。下面我们从设计目标反推。
6.1 一级缓存为什么不够
如果只有一级缓存,意味着容器只能存放完全创建完成的 Bean。当 A 实例化完成、正在填充属性时,它还不能进入一级缓存,因此 B 在依赖 A 时根本拿不到任何引用,循环依赖自然无法解决。所以,要支持循环依赖,至少需要一种“能存放半成品”的机制,也就是二级缓存或三级缓存。
6.2 那为什么还需要三级缓存,只保留一级和二级不行吗
这是最关键的问题。要理解它,必须引入一个看似与循环依赖无关、实则密不可分的概念:AOP 动态代理。在 Spring 中,如果一个 Bean 被@Transactional、@Async、@Cacheable等切面增强,或者通过AopConfig生成代理,那么容器最终暴露出去的往往不是原始对象,而是它的代理对象。
如果没有三级缓存中的ObjectFactory,只有二级缓存存放“早期原始对象”,那么一旦 Bean 需要被代理,就会出现严重问题:B 从二级缓存拿到的 A 是原始对象,而最终放入一级缓存的 A 却是代理对象,导致同一个 A 在容器中出现了“原始对象”和“代理对象”两个不一致的版本,B 持有的引用不是最终 Bean,事务、异步等增强也会随之失效。
三级缓存解决这个问题的思路是“把代理的时机延后,把代理的生成封装成工厂”。当 B 需要提前获取 A 时,Spring 调用 A 的ObjectFactory.getObject(),在该工厂内部会执行getEarlyBeanReference。这个方法会检查 A 是否需要代理:如果需要,就提前创建并返回代理对象;如果不需要,就返回原始对象。这样,无论 A 最终是否被代理,B 拿到的早期引用和最终的一级缓存版本都能保持一致。
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; }七、从源码角度再看一遍关键链路
如果把前面的推演理解为「业务视角」,这一节就从AbstractAutowireCapableBeanFactory的源码视角把链路再压一层。能在面试中自然带出doCreateBean、populateBean、addSingletonFactory这几个方法,会明显比只背三个 Map 更耐打。
7.1 doCreateBean 的主干逻辑
单例 Bean 的创建主干集中在doCreateBean,它可以拆成四个关键动作:先createBeanInstance实例化,再通过addSingletonFactory提前暴露工厂,然后populateBean完成依赖注入,最后initializeBean执行初始化回调和代理创建。循环依赖正是依赖第二步和第三步之间的时间差。
protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args) { BeanWrapper instanceWrapper = createBeanInstance(beanName, mbd, args); Object bean = instanceWrapper.getWrappedInstance(); if (earlySingletonExposure) { addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); } Object exposedObject = bean; populateBean(beanName, mbd, instanceWrapper); exposedObject = initializeBean(beanName, bean, mbd); return exposedObject; }精简后的代码省略了异常处理和不必要的分支,但四段式结构非常清晰。注意addSingletonFactory发生在populateBean之前,这个顺序本身就是循环依赖解决方案能够成立的前提。
7.2 populateBean:循环依赖在这里被触发
populateBean负责把@Autowired、@Resource或 XML 配置的依赖注入到当前 Bean。当它发现 A 依赖 B 时,会调用getBean("b"),从而递归进入 B 的创建流程;B 又会反过来找 A。正因为 A 的工厂已经在上一阶段写入三级缓存,B 才能顺利拿到 A 的半成品引用。
如果注入方式换成构造器注入,那么依赖获取发生在createBeanInstance阶段。此时 A 还没有执行addSingletonFactory,三级缓存里没有 A 的工厂,B 自然无法提前拿到 A,最终只能抛出BeanCurrentlyInCreationException。这也是构造器循环依赖无法被三级缓存解决的根本原因。
7.3 addSingletonFactory:三级缓存写入的时机
addSingletonFactory内部会把一个ObjectFactory放入singletonFactories。该工厂通常包装了getEarlyBeanReference,这意味着它在被真正调用前,不会急于创建代理对象。正是这种「延迟到被需要时再加工」的设计,让 Spring 既可以支持循环依赖,又能保证最终 Bean 与提前暴露的引用一致。
同时,这一步会做完整性检查:如果容器不允许循环引用,例如通过setAllowCircularReferences(false)关闭了能力,那么容器会在发现循环时直接抛异常,帮助开发者尽早暴露设计问题。
八、循环依赖的实战规避方案
Spring 支持循环依赖,并不代表我们应该把循环依赖当成正常架构。大多数时候,循环依赖意味着两个类职责耦合过重,值得重新审视。下面按照「优先重构、其次破解、最后容错」的顺序给出常用手段。
8.1 优先重构,消除循环依赖
如果 A 和 B 互相依赖,通常可以抽出一个更小的 C 来承载共同职责。比如订单服务与用户服务互相调用,可以把「根据订单查用户」「根据用户查订单」这类查询逻辑抽到专门的查询组件或聚合服务中。重构方向有以下几种:
- 抽取中间类:将共同依赖的行为下沉到新的 Service 或 DomainService。
- 事件解耦:A 完成后发布事件,B 监听事件做出反应,避免同步调用。
- 接口隔离:让 B 只依赖 A 实现的最小接口,并把这个接口抽到公共模块。
这样做不仅解决循环依赖,通常还能降低单类复杂度,让模块边界更清晰。
8.2 用 @Lazy 化解构造器循环依赖
如果循环依赖来自构造器注入,最直接的补救是给其中一个构造器参数加上@Lazy。Spring 不会立即注入真实 Bean,而是先注入一个代理对象,当第一次真正调用代理方法时才从容器中获取目标 Bean。
@Component public class OrderService { private final UserService userService; public OrderService(@Lazy UserService userService) { this.userService = userService; } }这里的@Lazy起作用,是因为 Spring 在解析构造器参数时发现需要懒加载,就会先生成UserService的代理,不触发UserService的真实创建。等OrderService创建完成后,再去完成另外一边的创建,依赖环就被时间差打破了。
不过@Lazy也有副作用:代理会引入额外开销,且调试时栈信息会多出一层。它更适合治理历史遗留代码,而不是作为新代码的默认写法。
8.3 改用 Setter 注入代替构造器注入
构造器注入对不可变性和依赖完整性更友好,但如果确实存在难以拆解的循环,可以把其中一边改为字段注入或 Setter 注入。因为 Setter 注入发生在实例化之后,正好落在三级缓存能够覆盖的窗口期内。
@Component public class OrderService { private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } }需要提醒的是,这只是「能用」,不是「最佳」。字段注入会弱化对象不可变性,也更容易在测试中漏掉依赖。凡是能重构消除的循环,仍应优先重构。
8.4 通过 ApplicationContext 延迟获取
另一种常见做法是让 Bean 实现ApplicationContextAware,在真正需要对方时才从容器中拿引用,而不是在创建阶段强依赖。
@Component public class OrderService implements ApplicationContextAware { private ApplicationContext applicationContext; private UserService userService; @Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } public UserService getUserService() { if (this.userService == null) { this.userService = applicationContext.getBean(UserService.class); } return this.userService; } }这种方式思路简单,但会直接把容器 API 侵入业务代码,可测试性和可读性都会下降,通常只作为应急方案,不建议大范围使用。
九、循环依赖与 AOP 代理的那些坑
很多项目里循环依赖没有被察觉,直到某天加上@Async或事务注解后突然启动失败,或者某个增强莫名失效。这里单独把 AOP 相关的问题拎出来讲。
9.1 @Async 为什么会和循环依赖一起翻车
@Async生效通常需要代理对象。如果 A 和 B 形成循环依赖,且 A 同时被@Async增强,早期暴露阶段与最终初始化阶段生成的代理必须一致。Spring 通过getEarlyBeanReference尽量在早期就生成同一个代理,但经历过一些自定义 BeanPostProcessor 顺序错乱的场景后,仍可能出现启动期异常或增强失效。
@Component public class A { @Autowired private B b; @Async public void asyncJob() { System.out.println("async job running"); } } @Component public class B { @Autowired private A a; }排查这类问题时,首先要观察异常是否指向getEarlyBeanReference或代理创建阶段;其次检查是否有多个增强同时生效,导致生成代理的链路变得复杂。临时解法通常是削弱循环依赖,例如改 Setter 注入、加@Lazy,或者把@Async方法拆到独立组件中。
9.2 @Transactional 与循环依赖的增强一致性
事务注解最终也是靠代理实现。如果 A 被事务增强,B 依赖 A,Spring 会尽量让 B 拿到的早期引用也是代理对象,从而保证事务拦截器正常生效。这正是三级缓存ObjectFactory存在的核心价值之一:它把代理对象的生成封装起来,在需要时统一生成,避免原始对象和代理对象并存。
但要注意,如果代理是在自定义 BeanPostProcessor 中手动生成的,并且没有遵循 Spring 的早期引用规则,就可能出现「一级缓存是代理对象,但循环依赖方拿到的却是原始对象」的错位。结论很简单:除非你非常清楚自己在做什么,否则不要绕过 Spring 的 BeanPostProcessor 体系手搓代理。
9.3 线上快速定位循环依赖
启动阶段遇到循环依赖,通常会抛出BeanCurrentlyInCreationException,异常信息里会包含当前 Bean 名和正在创建的 Bean 名,这是最快的线索。你可以按下面思路排查:
- 看异常堆栈:业务代码位置往往就是触发循环的注入点。
- 看 Bean 定义:确认两个类之间的注入方式、作用域和切面配置。
- 看缓存快照:调试时查看
singletonsCurrentlyInCreation,判断到底哪些 Bean 正在创建。 - 做依赖图:对复杂项目可以使用依赖分析工具生成 Bean 依赖关系图,直观看到环的位置。
十、高频面试追问与回答思路
下面这些追问,都是循环依赖话题下常见的「送命题」。建议先把结论背熟,再用前面的源码细节补充论据。
10.1 Spring 一定能解决循环依赖吗
回答要点:不能。Spring 默认只解决单例 Bean 在实例化完成之后的依赖环;构造器注入的强依赖必须借助@Lazy,原型 Bean 的循环依赖则直接不支持。先把作用域、注入方式、是否代理三个维度摆出来,再给结论,会显得非常完整。
10.2 二级缓存到底还有没有用
回答要点:有用。二级缓存用于承接从三级缓存工厂中生成出来的早期引用,避免同一个半成品被工厂重复加工。同时,二级缓存可以把「三级缓存中已消费的工厂」和「真正的早期对象」分开,让getSingleton的逻辑边界更清晰。
10.3 为什么不直接取消循环依赖支持
回答要点:Spring 提供三级缓存解决循环依赖,是容器为了兼容大量历史 Bean 设计和提高易用性所做的让步。取消支持会让大量传统项目直接无法启动。Spring 的做法是默认支持,但保留关闭能力,鼓励开发者在合适场景下主动发现并重构依赖环。
十一、建议背诵的高分回答模板
最后给出一套可以直接用的回答框架,适合面试被问到「Spring 如何解决循环依赖」时使用。
- 先给结论:Spring 通过三级缓存解决单例 Bean 的循环依赖,但构造器注入和原型作用域无法直接解决。
- 再说前提:核心是利用「实例化」和「初始化」之间的时间差,提前暴露半成品引用。
- 描述三级缓存:一级存成品、二级存半成品、三级存生成半成品或代理的工厂。
- 串联流程:用 A 和 B 的例子讲清楚创建顺序和缓存升降级。
- 点出难点:为什么要三级而不是两级,因为要解决 AOP 代理一致性。
- 收尾给边界:说明构造器循环依赖需要
@Lazy,并延伸到工程上如何规避循环依赖。
记忆句:实例化造壳、工厂暴露、属性填充碰壁、半成品救场、成品回填、代理统一。
十二、总结
循环依赖看似只是一个面试八股,实际上把 Spring Bean 生命周期、缓存设计、依赖注入和 AOP 代理全部串了起来。掌握它,至少能让你在三个层面说清楚:
- 是什么:多个 Bean 互相等待对方创建完成,形成引用闭环。
- 怎么解:单例 Bean 借助三级缓存提前暴露半成品引用,打破创建顺序僵局。
- 有什么边界:构造器强依赖、原型作用域、复杂 AOP 场景都可能成为例外。
建议把本文的推演过程复述三遍,先能讲清 A、B 缓存的升降级,再补上 AOP 代理一致性的论证,面试基本就能稳稳拿分。工程实战中,与其沉迷于炫技式地让 Spring 解环,不如把循环依赖当作一次架构体检信号:出现环的地方,往往也是职责边界需要优化的地方。