循环依赖相关问题
面试笔记:Spring 循环依赖的“死穴”与 AOP 代理的底层逻辑
核心前置认知:
Spring 解决循环依赖的绝招是“半成品提前曝光”(通过三级缓存singletonFactories)。
只要 Bean 能先通过反射“实例化”(即在内存里把对象 new 出来),哪怕属性还没填充,也能把这个“半成品”的引用提前暴露给别人,从而打破死锁。
一、 构造器注入引发的循环依赖 —— “实例化死锁”
- 场景描述:A 和 B 互相依赖,且都是通过构造函数(Constructor)注入对方。
- 底层为什么无解?
- Spring 的第一步是“实例化”(调用构造函数 new 对象)。
- 要 new A,必须先传入一个完整的 B;转头去 new B,又必须先传入一个完整的 A。
- 因为连“半成品”都 new 不出来,根本走不到放入三级缓存曝光那一步,死结。
- 抛出异常:
BeanCurrentlyInCreationException - 大厂标准解法:
- 架构层面(首选):重新梳理业务边界,把公用逻辑抽离到第三个类 C,解除 A 和 B 的双向依赖。
- 代码层面(兜底):改用字段注入(
@Autowired)、Setter 注入,或者在构造器参数上加@Lazy(让 Spring 先塞个空壳代理对象进去骗过构造器)。
二、 @Async 引发的循环依赖 —— “脏引用与一致性校验失败”
- 场景描述:A 和 B 互相通过字段(
@Autowired)注入。原本应该没问题,但 A 内部有一个方法加了@Async注解开启异步。 - 底层为什么无解?(高阶核心点)
- 第 1 步(A 早期曝光):A 正常实例化,把“原始对象 A1”的工厂钩子放入三级缓存,开始填充属性。
- 第 2 步(B 获取 A):A 发现需要 B,去创建 B。B 发现需要 A,于是从三级缓存触发钩子,拿到了“早期对象 A1”,B 创建成功并注入到 A 中。
- 第 3 步(@Async 搞事):A 属性填充完毕,进入生命周期的最后一步
postProcessAfterInitialization。此时AsyncAnnotationBeanPostProcessor开始工作,它发现 A 有@Async注解,于是为 A 生成了一个全新的代理对象 A2。 - 第 4 步(致命校验):Spring 最后准备把 A 放入一级缓存(单例池)时,会做一个严苛的一致性校验:“我要放进单例池的最终版 A2,和之前提前暴露给 B 的早期版 A1,内存地址还一样吗?”
- 发现地址不一样了!Spring 判定 B 拿到了一个“脏引用”,为了防止线上出现玄学 Bug,直接阻断启动。
- 抛出异常:
BeanCurrentlyInCreationException(报错信息会明确提示:Bean with name '...' has been injected into other beans... but has been wrapped) - 大厂标准解法:
- 千万不要在处于循环依赖链条中的类里使用
@Async。把异步方法专门抽离到一个独立的AsyncTaskService中。
- 千万不要在处于循环依赖链条中的类里使用
三、 为什么 @Transactional 也在循环依赖里,就不会报错? —— “聪明的早期代理”
- 场景描述:同样是 A 和 B 互相字段注入,A 里面有方法加了
@Transactional(事务注解)。项目却能正常启动,没有因为“地址不一致”报错。 - 底层逻辑差异(对比 @Async):
@Transactional归属于 Spring 标准的 AOP 体系(由AnnotationAwareAspectJAutoProxyCreator处理)。这个处理器比处理@Async的处理器更聪明。- 在上面的第 2 步中,当 B 去三级缓存找 A 的时候,触发了三级缓存里的
ObjectFactory钩子(即getEarlyBeanReference方法)。 - 标准的 AOP 处理器在这个钩子里做了拦截:“等一下!A 是需要被事务代理的。既然 B 现在急着要 A,那我现在就立刻为 A 生成事务代理对象 A2,然后把 A2 提前暴露给 B。”
- 到了第 3 步(初始化后置处理)时,AOP 处理器记得自己之前已经为 A 生成过代理 A2 了,于是直接原样返回 A2,不再重复生成。
- 到了第 4 步(一致性校验):放进单例池的最终版是 A2,之前暴露给 B 的早期版也是 A2。地址一致,校验通过,完美启动!
- 一句话总结:普通 AOP(如事务)在“早期暴露”时就把代理对象生成了,保证了全局使用的是同一个代理对象;而
@Async默认比较轴,非要等到生命周期的最后一步才生成代理,导致前后对象不一致。
面试官视角的考察点总结:
当你能完整阐述以上三点时,你向面试官证明了:
- 你不光背了“三级缓存”,你还知道三级缓存里存的到底是什么(存的是
ObjectFactory,为了推迟代理对象的生成)。 - 你深刻理解 Spring Bean 生命周期的各个扩展点(
getEarlyBeanReferencevspostProcessAfterInitialization)。 - 你具备解决复杂工程级 Bug 的理论基础。
没问题,这两个场景是面试大厂 Spring 源码时极其容易被深挖的“送命题”。如果你只知道“三级缓存能解决循环依赖”,但不知道这两种“三级缓存也救不了”的场景,面试官就会认为你的源码理解只是背了八股文。
下面我以面试官的视角,为你拆解这两个异常的具体代码场景和底层逻辑:
场景一:构造器注入引发的循环依赖(实例化死锁)
1. 代码示例:
@Service
public class ServiceA {
private final ServiceB serviceB;
// 强依赖:通过构造器注入 ServiceB
@Autowired
public ServiceA(ServiceB serviceB) {
this.serviceB = serviceB;
}
}
@Service
public class ServiceB {
private final ServiceA serviceA;
// 强依赖:通过构造器注入 ServiceA
@Autowired
public ServiceB(ServiceA serviceA) {
this.serviceA = serviceA;
}
}
2. 为什么无解?(底层逻辑)
Spring 三级缓存解决循环依赖的核心前提是:能先通过无参构造函数把一个“半成品”的 Bean 实例化出来,提前暴露到缓存中。
而在构造器注入的场景下:
- Spring 准备实例化
ServiceA,发现需要通过构造器传入ServiceB。 - 转头去实例化
ServiceB,发现需要通过构造器传入ServiceA。 - 此时
ServiceA连个“半成品”的影子都没有(因为实例化这一步就被卡死了,没法提前暴露引用)。 - 结果: 陷入死锁,Spring 抛出
BeanCurrentlyInCreationException。
3. 如何规避?
- 方案 A(推荐): 重新审视架构设计,解除双向依赖。
- 方案 B: 改为字段注入(
@Autowired属性)或 Setter 注入。 - 方案 C: 在构造器参数上加上
@Lazy注解,Spring 会先注入一个代理对象,延迟真实对象的获取。
场景二:@Async 注解引发的循环依赖(代理对象地址不一致)
1. 代码示例:
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
// 这个注解是罪魁祸首
@Async
public void doAsyncWork() {
System.out.println("异步任务执行...");
}
}
@Service
public class ServiceB {
@Autowired
private ServiceA serviceA;
}
2. 为什么无解?(底层逻辑 - 这个是高阶八股)
这个场景用的是字段注入,按理说三级缓存应该能搞定,为什么加了 @Async 就会报错?这涉及 Spring Bean 生命周期的后置处理器机制。
- Spring 实例化
ServiceA(原始对象),将其早期引用(Early Reference)放入三级缓存,然后开始注入属性。 - 发现依赖
ServiceB,去创建ServiceB。ServiceB创建时发现依赖ServiceA,于是从三级缓存中拿到了ServiceA的早期引用,并注入到自己体内。ServiceB创建完成。 - 流程回到
ServiceA。ServiceA拿到创建好的ServiceB完成属性注入,接着执行到生命周期的最后一步:postProcessAfterInitialization(初始化后置处理)。 - 此时,处理
@Async的后置处理器(AsyncAnnotationBeanPostProcessor)开始工作,它为ServiceA生成了一个全新的 AOP 代理对象。 - 致命校验: Spring 容器最后会做一次一致性检查。它发现最终放入单例池的
ServiceA(刚生成的@Async代理对象),和刚才注入给ServiceB的ServiceA(早期暴露的原始对象或常规代理对象)内存地址不一致了! - 结果: Spring 认为出现了脏引用(
ServiceB拿到了一个不对的ServiceA),为了防止不可预知的 Bug,直接抛出BeanCurrentlyInCreationException。
注:为什么 @Transactional 在循环依赖里就不会报错?因为处理事务的 AOP 处理器聪明一些,它在早期暴露(getEarlyBeanReference)时就把代理对象生成了,保证了前后地址一致。而 @Async 默认是在最后一步才生成代理。
3. 如何规避?
- 方案 A(最稳妥): 把带有
@Async的方法抽离到一个单独的AsyncService中,不要把它放在处于循环依赖链条中的类里。 - 方案 B: 在注入点加上
@Lazy(如ServiceB中注入ServiceA时使用@Lazy @Autowired)。
如果你能在面试中把 @Async 导致的“早期暴露引用的对象与最终生成的代理对象不一致,未通过 Spring 最终的脏引用校验”这个逻辑说清楚,面试官会立刻觉得你对 Spring 生命周期的理解是源码级的,你的“个人溢价”就体现出来了。
希望这两个例子对你梳理思路有帮助!还有其他简历细节需要探讨吗?

浙公网安备 33010602011771号