循环依赖相关问题

面试笔记:Spring 循环依赖的“死穴”与 AOP 代理的底层逻辑

核心前置认知:
Spring 解决循环依赖的绝招是“半成品提前曝光”(通过三级缓存 singletonFactories)。
只要 Bean 能先通过反射“实例化”(即在内存里把对象 new 出来),哪怕属性还没填充,也能把这个“半成品”的引用提前暴露给别人,从而打破死锁。

一、 构造器注入引发的循环依赖 —— “实例化死锁”

  • 场景描述:A 和 B 互相依赖,且都是通过构造函数(Constructor)注入对方。
  • 底层为什么无解?
    • Spring 的第一步是“实例化”(调用构造函数 new 对象)。
    • 要 new A,必须先传入一个完整的 B;转头去 new B,又必须先传入一个完整的 A。
    • 因为连“半成品”都 new 不出来,根本走不到放入三级缓存曝光那一步,死结。
  • 抛出异常BeanCurrentlyInCreationException
  • 大厂标准解法
    1. 架构层面(首选):重新梳理业务边界,把公用逻辑抽离到第三个类 C,解除 A 和 B 的双向依赖。
    2. 代码层面(兜底):改用字段注入(@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 默认比较轴,非要等到生命周期的最后一步才生成代理,导致前后对象不一致。

面试官视角的考察点总结:
当你能完整阐述以上三点时,你向面试官证明了:

  1. 你不光背了“三级缓存”,你还知道三级缓存里存的到底是什么(存的是 ObjectFactory,为了推迟代理对象的生成)。
  2. 你深刻理解 Spring Bean 生命周期的各个扩展点(getEarlyBeanReference vs postProcessAfterInitialization)。
  3. 你具备解决复杂工程级 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 实例化出来,提前暴露到缓存中。

而在构造器注入的场景下:

  1. Spring 准备实例化 ServiceA,发现需要通过构造器传入 ServiceB
  2. 转头去实例化 ServiceB,发现需要通过构造器传入 ServiceA
  3. 此时 ServiceA 连个“半成品”的影子都没有(因为实例化这一步就被卡死了,没法提前暴露引用)。
  4. 结果: 陷入死锁,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 生命周期的后置处理器机制。

  1. Spring 实例化 ServiceA(原始对象),将其早期引用(Early Reference)放入三级缓存,然后开始注入属性。
  2. 发现依赖 ServiceB,去创建 ServiceBServiceB 创建时发现依赖 ServiceA,于是从三级缓存中拿到了 ServiceA 的早期引用,并注入到自己体内。ServiceB 创建完成。
  3. 流程回到 ServiceAServiceA 拿到创建好的 ServiceB 完成属性注入,接着执行到生命周期的最后一步:postProcessAfterInitialization(初始化后置处理)。
  4. 此时,处理 @Async 的后置处理器(AsyncAnnotationBeanPostProcessor)开始工作,它为 ServiceA 生成了一个全新的 AOP 代理对象
  5. 致命校验: Spring 容器最后会做一次一致性检查。它发现最终放入单例池的 ServiceA(刚生成的 @Async 代理对象),和刚才注入给 ServiceBServiceA(早期暴露的原始对象或常规代理对象)内存地址不一致了
  6. 结果: Spring 认为出现了脏引用(ServiceB 拿到了一个不对的 ServiceA),为了防止不可预知的 Bug,直接抛出 BeanCurrentlyInCreationException

注:为什么 @Transactional 在循环依赖里就不会报错?因为处理事务的 AOP 处理器聪明一些,它在早期暴露(getEarlyBeanReference)时就把代理对象生成了,保证了前后地址一致。而 @Async 默认是在最后一步才生成代理。

3. 如何规避?

  • 方案 A(最稳妥): 把带有 @Async 的方法抽离到一个单独的 AsyncService 中,不要把它放在处于循环依赖链条中的类里。
  • 方案 B: 在注入点加上 @Lazy(如 ServiceB 中注入 ServiceA 时使用 @Lazy @Autowired)。

如果你能在面试中把 @Async 导致的“早期暴露引用的对象与最终生成的代理对象不一致,未通过 Spring 最终的脏引用校验”这个逻辑说清楚,面试官会立刻觉得你对 Spring 生命周期的理解是源码级的,你的“个人溢价”就体现出来了。

希望这两个例子对你梳理思路有帮助!还有其他简历细节需要探讨吗?

posted @ 2026-03-28 12:19  Nickey103  阅读(2)  评论(0)    收藏  举报