Spring 循环依赖:三级缓存背得再熟,不如看这 3 个线上坑
2026/9/24 4:51:22 网站建设 项目流程

循环依赖是 Spring 项目里一不留神就会碰到的问题,尤其在多人开发的项目里:两个人各写各的 Service,一个要调 A,一个要调 B,合代码时谁也没注意互相引了——启动直接红字:

BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?

我最常碰到的还不是简单的 A 依赖 B、B 依赖 A 两两互指,而是链式的:A 注入 B,B 注入 C,C 又注入 A——三个类串成一个环,启动必崩。下面从原理到解法讲透,附能跑的代码。

一、先 30 秒理解:什么是循环依赖

就是 A 依赖 B,B 依赖 C,C 又依赖 A,对象创建时形成闭环:

@ServicepublicclassA{privatefinalBb;// 依赖 B}@ServicepublicclassB{privatefinalCc;// 依赖 C}@ServicepublicclassC{privatefinalAa;// 又依赖回 A,成环}

注意这不是函数递归调用(那种是死循环),是对象创建时的相互依赖。环上有几个 bean 都叫循环依赖,链式(三个以上)比两两互指更常见。

二、补充知识:Bean 的三种注入方式

要理解循环依赖为什么难解,先看 Spring 注入 Bean 的三种方式:

注入方式写法优点缺点
构造器注入(推荐)构造器参数 /@RequiredArgsConstructor依赖明确、final 不可变、对象创建即完整;官方推荐循环依赖时启动即崩(实例化阶段就卡住)
字段注入@Autowired private B b;代码最少依赖藏在字段里,难测试、难发现循环;官方不推荐
setter 注入@Autowired setB(B b)可选依赖友好创建后依赖可变,不如构造器稳

推荐:默认用构造器注入——依赖一目了然、不可变、好测试。只有遇到循环依赖时才用@Lazy破环(坑 1 讲),别为了省事全程字段注入。

三、3 分钟理论:Spring 靠"三级缓存"解决

Spring 创建单例 Bean 分三步:

  1. 实例化createBeanInstance:调构造器,new 出对象
  2. 填充属性populateBean:注入依赖
  3. 初始化initializeBean:AOP 代理、init 方法等

循环依赖卡在第 1、2 步之间。Spring 的处理是提前暴露:对象构造完、还没填充属性时,就先放进缓存让别人拿。

它有三层缓存:

缓存作用
singletonFactories(三级)存"对象工厂",提前暴露的关键
earlySingletonObjects(二级)存提前暴露的早期对象
singletonObjects(一级)存初始化完成的成品 Bean

流程是这样的:A 实例化完,把自己塞进三级缓存 → 填充属性发现要 B → 去创建 B → B 实例化完发现要 A → 一级没有、二级没有、三级有!B 从三级缓存拿到 A 的引用 → B 顺利初始化完进一级缓存 → A 拿到 B,也完成初始化。

关键结论:三级缓存的加入前提是"对象已经调过构造器了"——所以setter/字段注入能解决循环依赖,构造器注入解决不了,后者直接抛BeanCurrentlyInCreationException

理论就这么多,面试够用了。下面是真正折磨人的 3 个实战坑。

坑 1:构造器注入的链式循环,启动直接崩

就是最开始的报错。现在流行"构造器注入最规范",但多人开发时链式循环一来,启动必崩:

@ServicepublicclassA{privatefinalBb;publicA(Bb){this.b=b;}}@ServicepublicclassB{privatefinalCc;publicB(Cc){this.c=c;}}@ServicepublicclassC{privatefinalAa;// 依赖回 A,成环publicC(Aa){this.a=a;}}

启动即报BeanCurrentlyInCreationException

解法 1:@Lazy(推荐,改动最小)——打破环上的任意一条边:

@ServicepublicclassC{privatefinalAa;publicC(@LazyAa){this.a=a;}// 延迟解析,环被打破}

@Lazy让 Spring 先注入一个代理对象,真正调用时才去拿目标 Bean。

解法 2:换成字段注入(不推荐用它掩盖问题)

@ServicepublicclassC{@AutowiredprivateAa;// 字段注入走 setter 流程,能过}

字段注入本质是填充属性阶段注入,走了三级缓存路径,所以能解。但正如第二节说的,字段注入是官方不推荐的方式——用它破环属于"能跑但丑",根治看坑 3。

坑 2:循环依赖 + 事务/切面,方法静默失效

这个坑最阴,启动不报错,业务悄悄出错

场景:A 有@Transactional方法,B 依赖 A。因为循环依赖,B 在早期阶段拿到的 A不是最终完成 AOP 代理的成品,而是提前暴露的早期对象:

@ServicepublicclassA{@AutowiredprivateBb;@TransactionalpublicvoiddoSth(){}}@ServicepublicclassB{@AutowiredprivateAa;// 拿到的可能是早期对象publicvoidcall(){a.doSth();// 事务可能没生效!}}

结果就是:doSth()里要么事务边界不对,要么依赖切面的逻辑(如@Async、自定义 AOP)表现诡异,排查起来极其费时——因为你单测、正常调用都对,只有走这条循环链路时才坏。

解法:还是@Lazy,让 B 真正调用 A 时才解析,保证拿到的是完整代理:

@ServicepublicclassB{@Autowired@LazyprivateAa;// 真正调用时才解析,拿完整代理}

教训:看到循环依赖 + 事务/切面组合,别"能跑就行",必须把代理问题一起验证,否则上线就是事故。

坑 3:循环依赖往往是设计坏味道

这是我最想强调的一点。两个(或多个)Service 互相注入,八成是职责边界划错了。最常见的案例:A 其实只需要 B 的某个动作,却直接注入了 B 的整个能力。

正确的重构方向(按优先级)

  1. 抽公共依赖:把互相调用的那部分逻辑下沉到独立的 Service,两边都依赖它,环就断了
  2. 单向调用:审视业务,很多"互相调用"其实有一方是多余的
  3. 事件解耦:A 做完发 Spring Event / 用 MQ,B 监听处理,彻底不互相持有
// 重构后:A 只依赖抽出来的 CommonService,不再反向@ServicepublicclassA{privatefinalCommonServicecommonService;// 单向}@ServicepublicclassCommonService{// 谁都不回依赖 A,环断了}

@Lazy是止痛药,重构才是根治。如果项目里循环依赖超过 2 处,先别急着@Lazy,想想是不是设计该调了。

总结

场景结论
构造器注入循环依赖启动即崩,用@Lazy或改字段注入
setter/字段注入循环依赖三级缓存能解,但慎用
循环依赖 + 事务/切面可能静默失效,必须验证代理
多处循环依赖重构边界,抽公共 Service 或事件解耦

你项目里遇到过最诡异的循环依赖是啥?是启动崩还是业务悄悄错?评论区聊聊。

相关阅读:Spring 事务失效的 11 个真实场景——和这篇的坑 2 正好能串起来,都是"代码看着对、运行出问题"的典型。

本文首发于公众号「今天写BUG了么」,关注看更多

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询