循环依赖是 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 分三步:
- 实例化
createBeanInstance:调构造器,new 出对象 - 填充属性
populateBean:注入依赖 - 初始化
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 的整个能力。
正确的重构方向(按优先级):
- 抽公共依赖:把互相调用的那部分逻辑下沉到独立的 Service,两边都依赖它,环就断了
- 单向调用:审视业务,很多"互相调用"其实有一方是多余的
- 事件解耦:A 做完发 Spring Event / 用 MQ,B 监听处理,彻底不互相持有
// 重构后:A 只依赖抽出来的 CommonService,不再反向@ServicepublicclassA{privatefinalCommonServicecommonService;// 单向}@ServicepublicclassCommonService{// 谁都不回依赖 A,环断了}@Lazy是止痛药,重构才是根治。如果项目里循环依赖超过 2 处,先别急着@Lazy,想想是不是设计该调了。
总结
| 场景 | 结论 |
|---|---|
| 构造器注入循环依赖 | 启动即崩,用@Lazy或改字段注入 |
| setter/字段注入循环依赖 | 三级缓存能解,但慎用 |
| 循环依赖 + 事务/切面 | 可能静默失效,必须验证代理 |
| 多处循环依赖 | 重构边界,抽公共 Service 或事件解耦 |
你项目里遇到过最诡异的循环依赖是啥?是启动崩还是业务悄悄错?评论区聊聊。
相关阅读:Spring 事务失效的 11 个真实场景——和这篇的坑 2 正好能串起来,都是"代码看着对、运行出问题"的典型。
本文首发于公众号「今天写BUG了么」,关注看更多