我到现在还清楚记得那次发版现场:应用在本机启动一切正常,放到测试环境就一直卡在 Creating bean with name...,接着整个容器直接抛了 BeanCurrentlyInCreationException。排查到半夜才发现,两个 Service 互相注入了对方的实例,而 Spring Boot 默认的循环依赖处理,在这里根本没起作用。
“Spring Boot 解决循环依赖”是后端项目里绕不开的经典话题,也是面试官最爱问的“背题大户”。但它远不是面试题库里那段“三级缓存背出来就能过”那么简单:线上场景越来越多,Spring Boot 升级后在启动期就会直接拦截循环引用,AOP 代理还会让问题变本加厉。本文会从“循环依赖为什么报错”的基础讲起,把三级缓存的机制、构造器注入循环依赖的根因、五种常用解法,以及一次真实的排查手记全部拆开,给正在做 Java 后端、用过 Spring Boot、被依赖关系困扰过的开发者一份能直接拿去用的参考。
1. 循环依赖是从哪冒出来的——先还原事故现场
1.1 一段能直接复现的“最小事故代码”
要聊怎么解决,先得知道敌人长什么样。下面这段代码,是循环依赖的最基本形态:
java复制@Service
public class AService {
@Autowired
private BService bService;
}
java复制@Service
public class BService {
@Autowired
private AService aService;
}
只要把这两个类放进同一个 Spring Boot 项目,应用启动时大概率会直接失败。注意我说的是“大概率”,因为从 Spring Boot 2.6 开始,循环引用默认被禁用了;如果是老项目升级上来,控制台会明确告诉你问题出在哪一坨依赖链路上。
典型的报错长这样:
code复制The dependencies of some of the beans in the application context form a cycle:
aService -> bService -> aService
Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between the beans.
这段日志本身就给出了处理方向:不要在代码里保留这个环。但现实中,很多团队并不会第一时间去重构,而是先想着“怎么能让项目先跑起来”,于是各种绕过方案就出现了。
1.2 三种形态:直接循环、间接循环、自身循环
循环依赖不只 A 依赖 B、B 依赖 A 这一种。根据我这几年的排查经验,实际项目里常见的至少有三种:
| 形态 | 依赖关系 | 典型场景 |
|---|---|---|
| 直接循环 | A → B → A | 两个业务服务互相调用对方的核心方法 |
| 间接循环 | A → B → C → A | 一条调用链上 A、B、C 三个组件首尾相连 |
| 自身循环 | A → A(本身) | 一个类在内部注入了自己,配合 AOP 代理时偶发 |
直接循环最容易看出来,代码 review 时扫一眼就知道问题。间接循环最阴险,因为 A 和 C 之间可能隔着好几层,不把所有 Bean 的依赖关系画出来,你根本意识不到这里是个环。自身循环则比较少见,但在 Spring 4.3 之后自注入在某些代理场景下能跑过启动,然后在调用方法时会得到一层诡异的代理对象,排查起来特别费劲。
1.3 为什么很多循环依赖并不是“启动即炸”,而是潜伏在项目里
这里的坑在于:很多老项目在 Spring Boot 2.6 之前是可以正常启动的,因为有三级缓存兜底,字段注入和 Setter 注入下的循环依赖会被“悄悄放行”。于是团队以为项目很健康,直到配置中心统一改了某个开关,或者有人给其中一个 Bean 加了事务注解、AOP 切面,启动期才突然爆炸。
换句话说,循环依赖的问题不是“有没有”,而是“什么时候暴露”。这一点想清楚之后,你就能理解为什么我在后面会反复强调:任何绕过的方案都有代价,戒掉循环依赖才是长期最优解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三级缓存的完整链路——Spring默认凭什么能压住一半循环
2.1 三个缓存分别存放什么
要理解 Spring 为什么能部分处理循环依赖,得先认识所谓的“三级缓存”。它其实就是三个 Map,各管一段:
| 缓存 | 名字 | 存放内容 |
|---|---|---|
| 一级缓存 | singletonObjects | 完成初始化的单例 Bean 对象 |
| 二级缓存 | earlySingletonObjects | 提前暴露的“早期引用”,对象已 new 出来但还没走完初始化 |
| 三级缓存 | singletonFactories | 存放 ObjectFactory,用来生成某个 Bean 的早期引用 |
如果我画个流程图,你会发现运行过程大致是这样的:Spring 创建单例 Bean A 时,第一步是调用构造器 new 出实例;此时 A 还没完成属性填充、初始化方法,但 Spring 会先把这个实例包装成 ObjectFactory 放进三级缓存。这一步在源码里叫提前暴露,正是循环依赖能被解决的核心。
2.2 字段/Setter 注入的循环依赖为什么被默认放行
现在把前面那段 AService 和 BService 的例子重新走一遍。Spring 开始创建 AService,new 好实例之后丢进三级缓存;接着填充 AService 里的 @Autowired BService 字段,发现 BService 还没创建,于是转去创建 BService。BService 被 new 出来之后进入三级缓存,然后开始填充它的 @Autowired AService 字段。这时事情发生了转机:BService 发现自己需要的 AService 已经存在于三级缓存中,于是通过 ObjectFactory 拿到 AService 的早期引用,完成了注入。等 BService 整个初始化结束后,Spring 回到 AService,把刚建好的 BService 注入进去,再把 AService 放入一级缓存。
整个过程能够成立的前提是:字段注入和 Setter 注入都发生在对象被 new 出来之后。换句话说,AService 的实例在填充属性之前就已经躺在三级缓存里了,BService 才能拿到它的“半成品”引用。这就是为什么三级缓存能解决字段注入循环依赖。
2.3 只能救“一半”的原因:剩下的问题留给谁
注意我上面说的是“字段注入”和“Setter 注入”。到了构造器注入这儿,情况完全不一样。构造器注入要求所有依赖都先准备好,才能 new 出这个对象。也就是说,AService 还没出生,Spring 就得先替它找 BService;而 BService 又需要先找 AService。两个 Bean 都还没正式创建,三级缓存里什么都没有,于是直接报 BeanCurrentlyInCreationException。
这也是面试里最常见的追问:既然三级缓存能解决循环依赖,为什么构造器注入的循环解决不了?答案不是 Spring 不想救,而是它压根找不到可以提前暴露的对象。记住这句话,后面排查构造器循环依赖时你会少走很多弯路。
3. 构造器循环依赖救不活的机制性根因——以及规避姿势
3.1 对象还没诞生就互相索要
假设现在两个 Service 改成了构造器注入:
java复制@Service
public class AService {
private final BService bService;
public AService(BService bService) {
this.bService = bService;
}
}
java复制@Service
public class BService {
private final AService aService;
public BService(AService aService) {
this.aService = aService;
}
}
启动时 Spring 发现 AService 需要 BService,于是去创建 BService;又发现 BService 需要 AService,回来创建 AService。到这里,循环已经形成,但两个对象的实例都还停留在“构造器入参阶段”,谁也没被 new 出来,三级缓存必然为空,无法提前暴露。启动失败几乎是必然结局。
这个根因不仅会影响普通 Bean,还会影响写 @Configuration 的配置类。很多配置类之间互相调用对方的 Bean 方法,一旦形成构造器循环,启动会直接崩在你最想不到的地方。所以在有大段配置的项目里,我更建议大家先把配置类的依赖关系单独画一遍。
3.2 @Lazy 是如何“强行刺穿”这个死结的
Spring 提供了一个 @Lazy 注解,能把“在构造阶段就索取真实对象”变成“先给我一个代理对象,等真正调用时再解析”。用在构造器参数上长这样:
java复制@Service
public class AService {
private final BService bService;
public AService(@Lazy BService bService) {
this.bService = bService;
}
}
这种情况下,Spring 创建 AService 时发现构造器参数虽然标了 BService,但被 @Lazy 修饰,于是先塞一个 BService 的代理对象进来,AService 顺利创建;等 AService 真正调用 bService.someMethod() 时,代理才去容器里找真实的 BService。BService 也能正常创建自己的依赖,整个破解过程不需要真实的 AService 提前暴露。
但是这里有个小小的代价:你往容器里塞的其实是个代理,如果 BService 方法里又调用了 AService 的某个方法,而 AService 自己也依赖了代理相关的东西,调用链会多一层间接性,日志里看到的是 cglib proxy 之类的类名,排查问题时耐心得多一些。
3.3 为什么新代码仍然优先建议构造器注入
既然构造器注入会让循环依赖更加“难解”,那干脆都用字段注入?我在团队里是反着来的:新写的模块一律用构造器注入。理由不复杂——构造器注入能明确暴露 Bean 之间的强依赖,也让 Bean 在创建后天然不可变,不会出现运行到一半字段被替换的情况。更重要的是,如果代码里存在循环依赖,构造器注入在启动期就会给你一记迎头痛击,逼着你去重构;而字段注入会让循环被三级缓存默默吞掉,等哪天加了代理、切面才突然炸,反而不好定位。
4. 对症下药:5种可用方案的选型边界与真实代价
4.1 方案A:@Lazy 延迟代理——最省事但别滥用
前面已经看过 @Lazy 用在构造器参数上的写法。如果想全局一点,也可以直接放在字段上:
java复制@Service
public class AService {
@Lazy
@Autowired
private BService bService;
}
它最大的优点是改动小,一行注解就能让项目跑起来;最大的缺点是它并没有消除循环依赖,只是把故障推迟到“第一次调用”时才暴露。如果 AService 的某个方法在启动后很久才会被调用,问题可能延迟到生产环境才爆发,这种“定时炸弹”我一般只在老项目救火时用,新项目尽量避免。
4.2 方案B:重构第三方依赖——唯一治本但需要推动力
当我们认真看一个循环依赖,往往会发现它背后是“职责分配不均”。比如 AService 需要 BService 返回订单状态,BService 又需要 AService 获取用户信息,两者看着都有理由依赖对方,实际上这段公共逻辑完全应该抽出来放到第三个组件里。
假设我把订单状态相关的逻辑抽成一个 OrderStatusService 或者 TradeContextService,让 AService 和 BService 都依赖这个新组件,而不是互相依赖,环形结构就变成了一条松散的星形结构。这个方案没有特殊的 API,没有任何注解,它的难点只在人情世故:能不能说服团队接受一次小的重构,以及公共组件职责边界划得清不清楚。但一旦做完,收益是最持久的。
4.3 方案C:ObjectProvider 延迟取用
ObjectProvider 是 Spring 提供的一个延迟获取 Bean 的容器,它有点像一个“仓库管理员”,你打电话问它有没有货,它先记下来,等真要用时再帮你取。写法如下:
java复制@Service
public class AService {
private final ObjectProvider<BService> bServiceProvider;
public AService(ObjectProvider<BService> bServiceProvider) {
this.bServiceProvider = bServiceProvider;
}
public void doSomething() {
BService bService = bServiceProvider.getIfAvailable();
if (bService != null) {
bService.someMethod();
}
}
}
这种方案的优点是:构造器注入的强依赖变成了“可选依赖”,创建 AService 时不需要真的创建 BService;同时保留了构造器注入的风格,方便做单元测试。缺点是我需要在使用处自己处理空值,代码会比直接注入啰嗦一点。它特别适合那些“同一个接口可能有多份实现、或者某个依赖在启动阶段可以不初始化”的场景。
4.4 方案D:@DependsOn 绕道——能不用就不用
@DependsOn 注解原本的作用是控制 Bean 的创建顺序,声明某个 Bean 必须在另一个 Bean 之前初始化。它的典型用法长这样:
java复制@Service
@DependsOn("bService")
public class AService {
// ...
}
在循环依赖场景下,@DependsOn 并不能真正解开循环,它只是强行改变初始化顺序,让 A 等 B 先创建完。如果 B 反过来又依赖 A,你给它指定顺序往往会引发 BeanCurrentlyInCreationException,症状反而更重。我很少建议用它来解循环依赖,除非你确认只是初始化顺序造成的“假循环”,否则乱加只会引入新的时序问题。
4.5 方案对比:一张表看完各种策略的取舍
| 方案 | 核心原理 | 适用场景 | 主要风险 |
|---|---|---|---|
| @Lazy | 注入代理,延迟解析真实依赖 | 老项目救火、构造器循环 | 问题延迟到调用期才暴露 |
| 重构公共组件 | 打破环形结构 | 设计层面的真实循环 | 改动量较大,需要推动力 |
| ObjectProvider | 延迟取用 Bean | 可选依赖、多实现场景 | 调用处要处理空值 |
| @DependsOn | 控制初始化顺序 | 顺序冲突而非真实循环 | 可能引发更严重的创建异常 |
| 字段注入 | 依赖三级缓存自动绕行 | Spring Boot 2.6 之前的老项目 | 新版本默认禁止,依赖不明确 |
5. 一次真实排查手记:日志、根因和修复的全过程
5.1 事故描述:启动直接失败,错误信息指向“cycle”
前阵子处理过一个模拟订单项目的启动故障,现象是应用一启动就报 APPLICATION FAILED TO START,核心日志里明确出现了“form a cycle”,并给出了两个 Bean:userService 和 orderService。最开始我只看到名字,心想这俩不是明摆着相互调用吗,怎么会拖到现在才炸?
后来查了提交记录才发现,orderService 一直在构造器里依赖 userService,而 userService 很久以前是通过字段注入引用 orderService 的。也就是说,循环一直存在,只是在旧版本 Spring 的默认配置下,字段注入那份被三级缓存放行,所以启动一直正常。等到项目升级到 Spring Boot 2.6 以上,循环引用默认禁止,累积的问题才集中爆发。
5.2 日志层层锁定的两个核心类
排查时我先打开完整启动日志,像剥洋葱一样从错误片段向上找。第一层看到的是 BeanCurrentlyInCreationException;第二层看到 userService 和 orderService 的属性注入点;第三层通过 --debug 模式看到了两个类各自的 BeanDefinition,确认 orderService 的构造器参数是 userService,而 userService 的字段里又有 orderService。到这里,循环链路已经非常清晰。
当时我还做了一件事:把依赖注入点画在小本子上。左箭头是构造器依赖,右箭头是字段依赖,一眼就能看出这是一个典型的“一构造一字段”混合循环。这给后续选择修复方式提供了关键依据,因为如果两边都是构造器注入,就直接用 @Lazy 救火;如果是字段循环,老项目里先开 spring.main.allow-circular-references=true 也能先恢复。
5.3 动手修复:从绕过改为真正的重构
我最终没有选择给构造器参数加 @Lazy,因为评审时发现这个循环本身就不该存在:userService 只是为了查一下订单状态的方法,而这个状态在 orderService 内部已经计算过,完全可以抽到独立的 OrderStatusFacade 里。于是我把这段逻辑从 orderService 中拆出来,放进一个新的 Facade 组件,让 userService 和 orderService 都依赖 Facade,环形依赖被彻底切断。
改动完成之后,我还顺手把 userService 的字段注入改成构造器注入,让所有新依赖关系都显式暴露在构造器签名里。这一步不是为了炫技,而是为了防止下一次有人悄悄加回循环引用,改成一个一眼能看透的依赖结构。
5.4 修复之后的验证与回归
修复后启动一次通过。我没有停在“能启动”这一步,而是跑了完整的单元测试和核心链路的冒烟测试,确认拆出来的 Facade 行为与原来一致。因为这种重构容易在细节上影响事务边界,所以我还额外检查了事务注解是否保留在正确的位置。
最后回归上线,并给项目的启动配置里加了 spring.main.allow-circular-references=false,把循环引用在启动期就堵死。以后再有同事不小心写出循环依赖,CI 阶段就会直接失败,而不是拖到生产环境才爆炸。
6. 平时怎么写代码才能少踩循环依赖
经验攒到一定量之后,会发现循环依赖的“解法”反而不是核心,真正值钱的是怎么避免它出现。我现在的日常习惯有三条:第一,新模块的 Bean 一律用构造器注入,依赖关系全部显式化;第二,代码评审时优先看两个类之间有没有互相 import,看到一对“双向箭头”就当场提出来;第三,在分层设计上留出边界,service 层只依赖更下层的组件,不跨层反向调用。
如果哪天你真的在启动日志里又看到 form a cycle,我建议你按这个顺序处理:先确认是不是升级 Spring Boot 版本导致的默认策略变化,再画出完整的依赖链路看能否拆公共组件,实在紧急再用 @Lazy 或临时配置顶一下,但一定要在项目里记一笔记,催促后续重构。说到底,循环依赖不是 Spring 的缺陷,而是代码结构向我们发出的信号——它在提醒你,有些职责该重新归属了。
