什么是 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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 问题识别能力:面试官不仅仅想知道循环依赖的定义,更想看你能不能识别出实际项目中哪些写法会触发循环依赖,而不是只在面试时才想起来这个概念。

  2. 场景分类能力:考察你是否清楚不同注入方式(setter、构造器、字段注入)下的循环依赖表现不同,能否系统地分类讨论。

  3. 架构设计意识:能不能从设计层面提出避免循环依赖的方案,而不只是依赖 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 官方强烈不建议这样做。

这个改动传递的信号很明确:循环依赖是代码设计上的坏味道,应该在架构层面消除,而不是依赖框架的兜底机制

面试高频追问

  1. 三级缓存能解决构造器注入的循环依赖吗?

    • 不能。构造器注入时,Bean 连实例都还没创建出来,没有 "半成品" 可以提前暴露到缓存中。三级缓存解决的是 setter/字段注入的循环依赖。
  2. 你在项目中遇到过循环依赖吗?怎么解决的?

    • 这是一道开放题。最好是讲一个真实的重构经历:发现 A 和 B 循环依赖 → 分析发现是职责划分不合理 → 抽取公共逻辑到 C → 循环依赖消除。这样既展示了问题解决能力,又体现了架构设计意识。
  3. @Lazy 的原理是什么?

    • @Lazy 标注的依赖不会在属性注入时真正创建,而是生成一个 CGLIB 代理对象注入。只有第一次调用代理对象的方法时,才会触发真实 Bean 的创建。此时由于宿主 Bean 已经初始化完成,不会再有循环依赖的问题。

常见面试变体

  • "Spring 是怎么解决循环依赖的?"
  • "构造器注入的循环依赖能解决吗?"
  • "Spring Boot 2.6 为什么默认禁止循环依赖?"
  • "你在项目中如何避免循环依赖?"

记忆口诀

"setter 能解构造器不行,prototype 全都搞不定,最佳方案是重构,@Lazy 只是缓兵计。"

总结

循环依赖的本质是 Bean 之间形成了引用闭环,导致谁都创建不完。Spring 通过三级缓存解决了单例 Bean 的 setter/字段注入循环依赖,但构造器注入和 prototype 作用域的循环依赖解决不了。解决方案从临时到治本依次是:@Lazy → setter 注入 → 抽取公共逻辑。Spring Boot 2.6 默认禁止循环依赖,官方的态度很明确——从架构层面消除循环依赖,而不是依赖框架兜底。