Spring 循环依赖怎么解决:三种常用方式一次讲清

编程狮(w3cschool.cn) 2026-09-07 18:15:54 浏览数 (28)
反馈

你启动 Spring 项目,控制台突然抛出 BeanCurrentlyInCreationException,堆栈里还出现"A 依赖 B,B 又依赖 A"——这就是典型的循环依赖。核心答案一句话:Spring 默认能处理"字段/setter 注入"形成的这种互赖(靠三级缓存提前暴露半成品对象),但"构造器注入"的互赖会直接报错;最干净的治本办法是重构代码、抽公共接口解耦。本文讲清三种解法,并解释为什么构造器注入会炸、三级缓存大概怎么运作。编程狮用大白话把这块讲透,初学者也能看懂。

Spring 循环依赖怎么解决:三种常用方式一次讲清封面图

一、什么是 Spring 循环依赖

循环依赖,简单说就是两个或多个 Bean 互相盯着对方:A 的创建需要 B,B 的创建又需要 A,谁都等不到对方先就绪。Spring 在实例化 Bean 时按依赖图一个个注入,一旦撞上这种"你等我、我等你"的环,容器就不知道该先 new 谁。

打个比方:循环依赖像两个人互相等对方先挂电话,结果谁都不挂,通话一直占线。Spring 的 Bean 工厂也一样,A 没 B 造不出来,B 没 A 也造不出来,流程就卡在环里。

@Service
public class A {
    private final B b;
    public A(B b) { this.b = b; }
}

@Service
public class B {
    private final A a;
    public B(A a) { this.a = a; }
}

上面 A 的构造器要 B,B 的构造器要 A,这就是教科书式的互赖环。默认 Spring 启动就会因为这层环抛异常。理解"环"是前提,后面三种解法都是在"打破这个环"或"让环能被容忍"。想先补 Spring 容器基础,可以过一遍 Spring 教程 的 IoC 与 Bean 生命周期章节,把依赖注入是怎么发生的弄明白,再看本文会顺。

二、构造器注入为何直接报 BeanCurrentlyInCreationException

为什么构造器注入的互赖一定炸?因为构造器注入要求"创建 A 的瞬间就必须拿到完整的 B",而 B 自己又要求瞬间拿到完整的 A。Spring 还没把任何一个造完,就无法满足对方的构造参数,于是抛出 BeanCurrentlyInCreationException,意思是"这个 Bean 正在创建中、又被要求创建"。

这种互赖在构造器场景无解,是因为半成品对象还没产生就被索要。Spring 的三级缓存要"提前暴露"一个还没注入完的引用,但构造器注入连对象骨架都还没立起来,缓存无从谈起。这一点和下面的 setter 注入正好相反。

// 构造器注入:A 与 B 互相等完整对象,必然失败
@Service
public class A {
    public A(B b) {}
}

这段代码里 A 的构造器依赖 B,B 又反过来依赖 A,启动即报错。如果你是新手,记住一条铁律:循环依赖加构造器注入等于启动失败,这是 Spring 的硬限制,不是配置问题。

⚠️ 注意:不要把"启动报错"当成 Spring 没配置好。构造器注入的互赖是结构性死锁,调配置没用,只能改写法或改结构。

三、改成 setter 注入借助三级缓存化解循环依赖

字段注入和 setter 注入之所以能被 Spring 容忍,是因为它们允许"先造出空壳对象,再回头补依赖"。Spring 用三级缓存存下"半成品对象"的引用:A 刚 new 出来(还没注入 B)就先暴露引用,B 拿到这个半成品继续创建,B 好了再回填给 A,环就解开了。

这种互赖在 setter 场景能被化解,靠的就是"提前暴露"这个机制。三级缓存大致分三层:一级放成品、二级放早期引用、三级放工厂对象,容器在需要时把半成品提前亮出来,让对方先拿着用。不用深究每层细节,记住"半成品可共享"即可。

@Service
public class A {
    @Autowired
    private B b; // 字段注入,允许先暴露半成品
}

@Service
public class B {
    @Autowired
    private A a;
}

字段注入让 A 先以半成品形态存在,B 拿到它继续初始化,最后双方都完成。注意这能跑通,但只是"绕开"循环依赖,并不是"消除"它,代码里依然藏着环。想查 Spring 注解与配置的完整清单,Spring 速查手册 能随时回查 @Autowired 等写法。

💡 小提示:能跑不等于健康。setter 或字段注入只是掩盖了设计问题,大型项目里还是建议从结构上解开环,见第五节。

四、@Lazy 延迟加载打破循环依赖

如果不想改注入方式,又想让构造器注入的循环依赖启动通过,可以加 @Lazy。它的作用是:注入时先不真正去拿对方,而是放一个"懒加载代理",等第一次真正用到那个 Bean 时才去初始化。

这个环被 @Lazy 打破,是因为双方拿到的都不是彼此的真实对象,而是一个延迟代理,启动期的"互相等待"被推迟到了运行期,那时环早就该就绪了。代价是第一次调用会多一次初始化开销,且代理在某些场景(如类型强转)要小心。

@Service
public class A {
    public A(@Lazy B b) { /* 先拿到代理,不立即初始化 B */ }
}

给构造器参数加 @Lazy,A 启动时只持有一个 B 的代理,不再强行等待 B 完整就绪,循环依赖的死锁就此让路。它适合临时救火或第三方组件不得不互赖的场景,别把它当成默认写法。

五、抽公共接口从根上消除循环依赖

真正治本的办法,是重新审视 A 和 B 的关系:它们互相依赖,往往是因为把本该共享的逻辑放到了彼此身上。把这部分公共能力抽成一个独立的 C(接口或公共服务),让 A、B 都去依赖 C,而不是互赖,环就自然消失了。

这个环从结构上被消除后,三种写法(构造器、setter、@Lazy)随便用都不炸,因为已经不存在环。这是最推荐的解法,虽然改动最大,但换来的是可测试、低耦合的代码。安全相关模块也常遇到互赖,Spring Security 教程 的组件初始化章节能帮你理解容器如何管理互相引用的 Bean。

💡 小提示:当你发现两个类反复互赖,先问"它们是不是都该依赖第三个东西"。多数情况下抽一层就能解耦,互赖也就跟着消失。

根据重构空间选择循环依赖解决方式的决策树

总结

Spring 循环依赖的本质是两个 Bean 互相等待对方先就绪。构造器注入要求瞬间拿到完整对象,所以必然抛 BeanCurrentlyInCreationException;setter 或字段注入借助三级缓存提前暴露半成品,能被容器容忍;@Lazy 用延迟代理把死锁推到运行期;而抽公共接口解耦,才是从结构上消除循环依赖的治本之法。

你应当带走的要点:构造器注入的循环依赖是硬限制、调配置没用;setter 注入能跑但只是绕开、没消除环;追求健壮就重构解耦。下一步建议把这几种写法接到你自己的项目里逐个试一遍,尤其制造一次构造器注入的启动报错再分别用 @Lazy 和抽接口修掉它,印象会深刻得多。

延伸学习

想把这块知识系统补齐,可以按这个顺序来:

  1. 先跟着 TRAE AI实战:从零开发Spring Boot完整系统 在真实项目里把 Bean 注入与解耦练熟;
  2. 想理解容器底层,回读这篇 Spring 依赖注入详解笔记 补充 IoC 与依赖注入机制;
  3. 准备面试,参考 Spring 依赖注入面试笔记 的常考问答梳理思路。

常见问题

Q:为什么构造器注入的循环依赖一定会报错?

因为构造器注入要求创建 A 的瞬间就拿到完整的 B,而 B 又要求瞬间拿到完整的 A。Spring 连一个半成品都还没产生,无法满足对方的构造参数,于是抛 BeanCurrentlyInCreationException。这是结构性死锁,不是配置问题。

Q:三级缓存到底解决了什么?

三级缓存让 Spring 在对象刚 new 出来、还没注入完依赖时,就提前把"半成品引用"暴露出去。这样 B 能先拿到 A 的半成品继续初始化,B 好了再回填给 A,环就解开了。它只适用于 setter/字段注入,对构造器注入无效。

Q:@Lazy 和抽接口该选哪个?

@Lazy 是缓兵之计,适合临时救火或第三方组件不得不互赖的场景,会带来一次延迟初始化的开销。抽公共接口是从结构上消除循环依赖,最推荐,代价是改动较大。能重构就重构,不能重构再用 @Lazy。

0 人点赞