什么是 Spring 的循环依赖问题?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
问题识别能力:面试官不仅仅想知道循环依赖的定义,更想看你能不能识别出实际项目中哪些写法会触发循环依赖,而不是只在面试时才想起来这个概念。
-
场景分类能力:考察你是否清楚不同注入方式(setter、构造器、字段注入)下的循环依赖表现不同,能否系统地分类讨论。
-
架构设计意识:能不能从设计层面提出避免循环依赖的方案,而不只是依赖 Spring 的三级缓存来兜底——这才是面试官最看重的。
核心答案
循环依赖就是 两个或多个 Bean 互相持有对方的引用,形成闭环,导致谁都创建不完。
上图中:
- 双向依赖是日常开发中最常见的,A 注入 B,B 又注入 A。
- 多链路依赖稍隐蔽一些,A → B → C → A,中间隔了好几层,排查的时候容易忽略。
- 自引用比较少见,但确实存在——一个 Bean 通过
@Autowired注入自身(通常是想拿代理对象),严格来说也算循环依赖。
深度解析
一、循环依赖是怎么发生的?
先看一段 "教科书级" 的循环依赖代码:
@Component
public class OrderService {
@Autowired
private UserService userService;
public void createOrder() {
userService.notify(); // 订单服务依赖用户服务
}
}
@Component
public class UserService {
@Autowired
private OrderService orderService; // 用户服务又反过来依赖订单服务
public void notify() {
// ...
}
}
Spring 启动时的创建流程是这样的:
上图清晰地展示了死循环的产生过程:
- Spring 创建
OrderService时,走到属性注入环节发现需要UserService。 - 于是暂停
OrderService的创建,转而去创建UserService。 - 结果
UserService属性注入时又需要OrderService,于是又想回头去创建。 - 这就形成了闭环,谁也创建不完。
Spring 通过三级缓存打破了这个死循环——让 OrderService 在实例化后、属性注入前就提前暴露一个引用,这样 UserService 就能拿到这个 "半成品" 完成自己的创建,具体机制上一题已经讲得很详细了。
二、不同注入方式的循环依赖表现
这个是面试重点,不同注入方式下循环依赖的表现完全不同:
| 注入方式 | 循环依赖能否解决 | 原因 |
|---|---|---|
setter / 字段注入(@Autowired) |
✅ 能解决 | 先实例化(new),再注入属性,实例化后就能提前暴露 |
| 构造器注入 | ❌ 无法解决 | 实例化和依赖注入绑在一起,没有 "半成品" 可以提前暴露 |
@Lazy 延迟注入 |
✅ 能解决 | 注入的是代理对象,使用时才真正创建 |
构造器注入为什么不行?因为构造器本身就需要依赖参数才能执行,连实例都 new 不出来,自然没法提前暴露。
// 构造器注入的循环依赖 —— 直接报错
@Component
public class A {
private final B b;
public A(B b) { // 构造 A 需要 B
this.b = b;
}
}
@Component
public class B {
private final A a;
public B(A a) { // 构造 B 需要 A
this.a = a;
}
}
// 启动报错:Requested bean is currently in creation: There is a circular reference
三、不同作用域的循环依赖表现
| 作用域 | 循环依赖能否解决 | 原因 |
|---|---|---|
singleton(默认) |
✅ 能解决 | 走三级缓存,提前暴露引用 |
prototype |
❌ 无法解决 | 原型 Bean 不缓存,每次 getBean() 都重新创建 |
request / session |
❌ 无法解决 | 同理,不走单例缓存池 |
@Scope("prototype")
@Component
public class A {
@Autowired
private B b;
}
@Scope("prototype")
@Component
public class B {
@Autowired
private A a;
}
// 启动报错:BeanCurrentlyInCreationException
原因很简单:三级缓存是 DefaultSingletonBeanRegistry 里的机制,只对单例 Bean 生效。原型 Bean 压根不走缓存,也就谈不上 "提前暴露"。
四、怎么解决 Spring 解决不了的循环依赖?
除了 Spring 自带的三级缓存能自动处理的场景之外,剩下的需要我们手动处理。实际开发中有以下几种方案:
方案一:@Lazy 延迟注入(最简单)
@Component
public class A {
private final B b;
@Autowired
public A(@Lazy B b) { // 注入的是 B 的代理对象,不是 B 本身
this.b = b;
}
}
@Lazy 让 Spring 注入一个代理对象,等到真正调用 B 的方法时才触发 B 的创建。此时 A 已经完全初始化好了,B 拿到 A 不再有问题。
方案二:setter 注入替代构造器注入
@Component
public class A {
private B b;
@Autowired
public void setB(B b) { // 改成 setter 注入
this.b = b;
}
}
setter 注入走的是 "先实例化,后注入" 的流程,三级缓存能兜住。
方案三:抽取公共逻辑到第三个 Bean(最推荐)
// 把 A 和 B 都依赖的逻辑抽出来
@Component
public class SharedService {
// 公共逻辑
}
@Component
public class A {
@Autowired
private SharedService sharedService; // 不再直接依赖 B
}
@Component
public class B {
@Autowired
private SharedService sharedService; // 不再直接依赖 A
}
从设计层面彻底消除循环依赖。这也是最 "正统" 的做法——循环依赖本身就是设计问题,重构才是治本。
方案四:用 ApplicationContext 手动获取
@Component
public class A {
@Autowired
private ApplicationContext context;
public void doSomething() {
// 需要的时候再拿,不在属性注入阶段形成循环
B b = context.getBean(B.class);
}
}
不推荐,因为这种方式脱离了 Spring 的依赖注入体系,增加了代码的耦合度。只在实在无法重构的遗留代码中使用。
五、Spring Boot 2.6 的态度
Spring Boot 2.6 开始,默认 禁止 了循环依赖:
# application.yml
spring:
main:
allow-circular-references: false # 默认就是 false
如果你的项目存在循环依赖,启动时会直接报错。如果想恢复旧版本的行为,需要手动设置为 true,但 Spring 官方强烈不建议这样做。
这个改动传递的信号很明确:循环依赖是代码设计上的坏味道,应该在架构层面消除,而不是依赖框架的兜底机制。
面试高频追问
-
三级缓存能解决构造器注入的循环依赖吗?
- 不能。构造器注入时,Bean 连实例都还没创建出来,没有 "半成品" 可以提前暴露到缓存中。三级缓存解决的是 setter/字段注入的循环依赖。
-
你在项目中遇到过循环依赖吗?怎么解决的?
- 这是一道开放题。最好是讲一个真实的重构经历:发现 A 和 B 循环依赖 → 分析发现是职责划分不合理 → 抽取公共逻辑到 C → 循环依赖消除。这样既展示了问题解决能力,又体现了架构设计意识。
-
@Lazy的原理是什么?@Lazy标注的依赖不会在属性注入时真正创建,而是生成一个 CGLIB 代理对象注入。只有第一次调用代理对象的方法时,才会触发真实 Bean 的创建。此时由于宿主 Bean 已经初始化完成,不会再有循环依赖的问题。
常见面试变体
- "Spring 是怎么解决循环依赖的?"
- "构造器注入的循环依赖能解决吗?"
- "Spring Boot 2.6 为什么默认禁止循环依赖?"
- "你在项目中如何避免循环依赖?"
记忆口诀
"setter 能解构造器不行,prototype 全都搞不定,最佳方案是重构,@Lazy 只是缓兵计。"
总结
循环依赖的本质是 Bean 之间形成了引用闭环,导致谁都创建不完。Spring 通过三级缓存解决了单例 Bean 的 setter/字段注入循环依赖,但构造器注入和 prototype 作用域的循环依赖解决不了。解决方案从临时到治本依次是:@Lazy → setter 注入 → 抽取公共逻辑。Spring Boot 2.6 默认禁止循环依赖,官方的态度很明确——从架构层面消除循环依赖,而不是依赖框架兜底。
