面试记录 2026-09-28
题目:什么是Spring的循环依赖问题?
难度:difficulty/low
标签:spring/core, general/knowledge, difficulty/low
评分:⭐⭐⭐⭐☆(4/5)
我的回答
Spring 的循环依赖指的是,由于 Spring 的 Bean 对象是由 Spring 自身管理的,这时候你要使用一个对象的时候,注入、管理、创建都是由 Spring 操作的。如果你创使用的 A 对象调用 B 对象,生成的时候会调用 B 对象,但是 B 对象生成的时候又调用 A 对象,这时候就产生了循环依赖。
嗯,对应的处理方式主要看你是怎么调用的。Spring 默认的处理的方式,循环依赖的方式是通过三级缓存去对单例对象进行循环依赖的处理。如果你是用的其他模式的对象创建模式,它是无法处理循环依赖的。另外,循环依赖的处理在 Spring 中默认只能处理 set 注入的那种模式的循环依赖。如果你是构造器模式的注入,这时候它是无法处理对应的循环依赖的。
而实质上,我们在开发过程中就不应该出现循环依赖的东西,它属于一种你设计过程中出现了问题,应该将循环依赖的部分摘出去,放到独立的位置单独引用。
点评
优点:
- 循环依赖的成因讲对了:A 要 B、B 要 A,创建时互相等待
- 点出 Spring 三级缓存是默认解法
- 关键细节抓到了:只有 setter 注入能解,构造器注入解不了 —— 这是很多人漏掉的核心点
- 有架构视角收尾:循环依赖本身就是设计问题,应该拆出去独立引用
不足:
- 三级缓存的三层结构(singletonObjects / earlySingletonObjects / singletonFactories)没展开
- 没有点出为什么需要第三级(ObjectFactory)—— 关键原因是 AOP 代理:需要提前暴露一个代理对象,让 B 能拿到 A 的代理,而不是等 A 完全初始化后再替换
- 没提到 Spring Boot 2.6+ / Spring 4.3+ 默认 allow-circular-references=false 会直接启动失败 —— 工程上非常关键
- 少了一种规避手段:@Lazy 注入代理对象也可解
补充:
- 三级缓存的分工:L1 成品、L2 半成品(已实例化未初始化,且可能是代理)、L3 生产半成品的工厂
- 触发链:A 实例化 → 放入 L3 → 注入 B → B 实例化 → 放入 L3 → 注入 A → 从 L3 拿到 A 的 ObjectFactory,调用 getIfAvailable 生成代理(若需要 AOP),A 挪到 L2 → B 拿到 A 的代理,B 初始化完成 → A 拿到 B,A 初始化完成 → A 挪到 L1,清 L2/L3
- 为什么构造器注入不行:构造器执行时对象还没实例化完成,无法提前暴露引用
- 规避手段:@Lazy 注入代理、拆方法/拆 Bean、事件解耦、单向依赖设计
完整讲解
第一部分:知识重点剖析
核心考点定位:考察对 Spring IoC 容器 Bean 创建流程的深度理解,特别是循环依赖场景下的三级缓存机制。
知识点拆解:
- 循环依赖的定义与判定:两个或多个 Bean 在创建过程中直接或间接地互相引用,导致 Spring 无法在任何一方完全初始化前完成注入。常见形式:A→B→A、A→B→C→A、构造器循环依赖。
- 三级缓存结构:
- singletonObjects(一级):已完全初始化、可直接注入的成品 Bean
- earlySingletonObjects(二级):已实例化但未初始化完成、可能已包装成代理的半成品 Bean
- singletonFactories(三级):ObjectFactory,用于按需在二级和三级之间按需生成代理对象
- 解决流程(以 A↔B、@Autowired setter、需要 AOP 为例):
- A 实例化(调用无参构造器)后,先把 A 的 ObjectFactory 放入 L3
- A 进入属性填充阶段,注入 B → 触发 B 的创建
- B 实例化后,把 B 的 ObjectFactory 放入 L3,B 进入属性填充,注入 A
- Spring 发现 A 已在 L3,调用 ObjectFactory.getObject():如需 AOP,返回 A 的代理对象;否则返回 A 的原始对象。结果放入 L2,并从 L3 移除
- B 拿到 A 的代理,B 完成初始化 → 挪到 L1,清 B 的 L2/L3
- 回到 A 的属性填充:从 L2 拿到 A 的代理(若 AOP 未处理,此处可能是原始对象,会引发问题——Spring 通过提前把代理放 L2 解决),A 完成初始化 → 挪到 L1,清 A 的 L2/L3
- 为什么需要第三级(ObjectFactory):核心是 AOP 场景——若只有两级,Spring 不知道 Bean 是否需要被代理;L3 用工厂延迟决定"暴露代理还是原始对象",避免提前创建代理对象
- Spring 的限制:
- 仅支持 singleton 作用域,prototype / request 作用域下无法解决
- 仅支持 setter / field 注入,构造器循环依赖 Spring 会直接抛 BeanCurrentlyInCreationException
- Spring Boot 2.6+ 默认
spring.main.allow-circular-references=false,启动即失败
- 最佳实践:
- 循环依赖本身就是设计异味,应通过拆方法、拆 Bean、事件解耦、单向依赖等手段规避
- 无法避免时:
@Lazy 注入代理对象、显式配置允许循环依赖、重构依赖方向
第二部分:考察方向与延伸递进
考察方向:
- 深度:三级缓存的结构与 AOP 代理的耦合关系(为什么必须三级)
- 广度:Bean 生命周期全流程、Spring AOP 代理机制、@Lazy 懒加载
- 应用:Spring Boot 2.6+ 启动失败排查、生产环境循环依赖治理
延伸递进面试题:
- 为什么三级缓存必须存在第三级?如果只用两级会发生什么?
- 解答要点:两级情况下无法区分"是否需要代理";如果提前创建代理,可能生成错误的代理链(原始对象 vs 代理对象混用),且无法复用 ObjectFactory 的懒生成语义。AOP 场景下会出现"B 拿到 A 的原始对象,而 A 自身持有 B 后又变成代理"的引用不一致问题
- 构造器循环依赖 Spring 一定解不了吗?有没有绕过方式?
- 解答要点:构造器循环依赖 Spring 默认抛异常,因为构造器执行时对象还没实例化完成,无法提前暴露引用;可通过
@Lazy 让注入端拿代理对象绕过(本质是不真实调用 A 的构造器),或改用 setter 注入、或重构为事件/回调
- 在 Spring Boot 3 中,遇到循环依赖应该先改什么?
- 解答要点:首选重构——分析两个 Bean 的职责边界,抽出第三个 Bean 承接公共逻辑;次选 @Lazy;最后才是
spring.main.allow-circular-references=true(不推荐,掩盖设计问题)
第三部分:面试者参考回答(300-600 字口语化)
面试官您好,Spring 的循环依赖指的是两个或多个 Bean 在创建过程中互相需要对方——A 要注入 B,B 又要注入 A,Spring 在创建 A 时调用 B 的构造函数去创建 B,B 又反向需要 A,此时 A 还没初始化完成,就会卡死或者抛出异常。
Spring 官方是默认支持处理循环依赖的,核心方案是三级缓存:一级 singletonObjects 存放完全初始化好的成品 Bean,二级 earlySingletonObjects 存放已实例化但还没走完初始化的半成品,三级 singletonFactories 存放 ObjectFactory,用来按需生成半成品——这里的关键是 AOP 场景。当 A 实例化后,Spring 先把 A 的工厂放到三级缓存,然后开始填充属性时触发 B 的创建;B 在填充属性需要 A 时,从三级拿到 A 的 ObjectFactory,判断是否需要 AOP 代理,如果需要的话就提前生成一个代理对象,放到二级;B 拿到这个代理完成初始化后,回到 A 的填充流程,A 也是从二级拿到自己的代理对象继续走初始化流程,最后 A、B 都会进入一级缓存。
为什么必须三级而不是两级? 因为 AOP 代理的生成是延迟决策的——如果只用两级,Spring 就无法区分"要不要提前暴露代理",会导致 B 持有 A 的原始对象而 A 持有 B 的代理对象,两边引用不一致。
Spring 的限制:只支持 singleton 作用域、只支持 setter 或 field 注入,构造器注入的循环依赖 Spring 会直接抛出 BeanCurrentlyInCreationException,因为它在构造器还没执行完时就无法拿到对象引用去暴露。另外 Spring Boot 2.6 之后默认 allow-circular-references=false,遇到循环依赖会启动直接失败,这是官方在推工程实践的收紧。
实际开发中我的做法是尽量不出现循环依赖——循环依赖本身就说明两个 Bean 的职责边界没切好,我一般通过拆 Bean、抽第三方 Bean 承接公共逻辑、或者用事件解耦(ApplicationEvent)来打破循环。实在绕不过去的话用 @Lazy 让注入端先拿代理对象,本质是把真正的构造调用延后到使用阶段。