基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

Spring 循环依赖解析

知识点图片

Spring通过三级缓存解决单例循环依赖。核心是提前曝光一个“半成品”对象,其中第三级缓存用于支持AOP。但此机制无法解决构造器注入等场景的循环依赖。

这是一个非常经典且重要的 Spring 面试题。我们来深入、系统地讲解一下 Spring 的循环依赖。

我会按照以下结构来为你剖析:

  1. 什么是循环依赖? (What)
  2. 循环依赖有什么问题? (Why it's a problem)
  3. Spring 是如何解决的?(核心:三级缓存) (How Spring solves it)
  4. 为什么需要三级缓存,二级不行吗? (Why three levels, not two)
  5. 哪些场景下的循环依赖 Spring 无法解决? (When Spring can't solve it)
  6. 作为开发者,我们应该如何处理或避免循环依赖? (Best practices)

1. 什么是循环依赖?

循环依赖(Circular Dependencies)指的是两个或多个 Bean 之间相互依赖,形成一个闭环。

最简单的例子是 A 依赖 B,同时 B 又依赖 A。

java
@Component
public class A {
    @Autowired
    private B b;
}

@Component
public class B {
    @Autowired
    private A a;
}

也可以是更长的依赖链,比如 A -> B -> C -> A。


2. 循环依赖有什么问题?

想象一下 Spring 创建 Bean 的过程:

  1. Spring 容器要创建 A。
  2. 它发现 A 依赖 B,于是它需要先去获取 B。
  3. Spring 容器转而去创建 B。
  4. 它发现 B 又依赖 A,于是它又需要去获取 A。
  5. ... 这就形成了一个死循环,导致 Bean 无法创建成功,程序无法启动。

这是一个经典的“鸡生蛋还是蛋生鸡”的问题。如果没有特殊机制处理,这个过程会无限递归下去,最终导致栈溢出(StackOverflowError)。


3. Spring 是如何解决的?(核心:三级缓存)

Spring 为了解决单例(Singleton)作用域下的循环依赖,巧妙地使用了三级缓存(Three-Level Cache)

这个解决思路的核心是:将对象的创建过程分为两步:1. 实例化(Instantiation) 和 2. 属性填充(Population)。

  • 实例化:仅仅是调用构造函数,在堆内存中创建了一个“空壳”对象,此时它的依赖属性都还是 null。
  • 属性填充:通过 setter 方法或反射,为这个“空壳”对象的属性注入它所依赖的其他 Bean 实例。

Spring 的三级缓存正是围绕这个过程设计的。这三个缓存都位于 DefaultSingletonBeanRegistry 类中。

缓存级别 变量名 类型 作用
一级缓存 singletonObjects Map<String, Object> 单例池/成品缓存。存放已经完全初始化好的 Bean 实例。
二级缓存 earlySingletonObjects Map<String, Object> 提前曝光的单例对象缓存。存放已经实例化但未完成属性填充的 Bean 实例。
三级缓存 singletonFactories Map<String, ObjectFactory<?>> 单例工厂缓存。存放能创建早期 Bean 的工厂对象。主要用于解决 AOP 代理问题。

解决流程(以 A、B 循环依赖为例):

  1. 创建 A:

    • getBean("a") 被调用。
    • 首先检查一级缓存 singletonObjects,没有 A。
    • 检查二级缓存 earlySingletonObjects,没有 A。
    • 检查三级缓存 singletonFactories,没有 A。
    • 开始创建 A。Spring 记录下 "a" 正在创建中。
  2. 实例化 A,并提前曝光:

    • Spring 调用 A 的构造函数,创建了一个 A 的“空壳”实例(我们称之为 a_instance)。此时 a_instanceb 属性是 null
    • 关键一步:Spring 并不会立即去填充 a_instance 的属性。而是将一个能产生 a_instance 的工厂(ObjectFactory)放入三级缓存 singletonFactories 中。singletonFactories.put("a", () -> a_instance)。这一步就是“提前曝光”(Early Exposure)。
  3. 填充 A 的属性:

    • Spring 开始填充 a_instance 的属性。它发现 A 依赖 B (@Autowired private B b;)。
    • Spring 于是去调用 getBean("b") 来获取 B。
  4. 创建 B:

    • getBean("b") 被调用。
    • 同样,依次检查三级缓存,都没有 B。
    • 开始创建 B。Spring 记录下 "b" 正在创建中。
  5. 实例化 B,并提前曝光:

    • Spring 调用 B 的构造函数,创建了 B 的“空壳”实例 (b_instance)。
    • 和 A 一样,Spring 将 B 的工厂放入三级缓存 singletonFactories
  6. 填充 B 的属性:

    • Spring 开始填充 b_instance 的属性。它发现 B 依赖 A (@Autowired private A a;)。
    • Spring 再次去调用 getBean("a")
  7. 解决循环的关键点:

    • getBean("a") 再次被调用。
    • 检查一级缓存,没有 A(因为 A 还没完全创建好)。
    • 检查二级缓存,没有 A。
    • 检查三级缓存,命中了! 找到了之前为 A 放入的 ObjectFactory
    • Spring 通过这个工厂 getObject() 获取到了之前创建的 a_instance(那个“空壳”实例)。
    • 然后,Spring 会将这个从三级缓存中获取到的 a_instance 放入二级缓存 earlySingletonObjects 中,并从三级缓存中移除对应的工厂。
    • a_instance 返回给 B。
  8. 完成 B 的创建:

    • B 拿到了 A 的(早期)引用 a_instance,并设置到自己的 a 属性中。
    • B 的属性填充完成,B 初始化完毕。
    • 完整的 B 实例放入一级缓存 singletonObjects,并从二级和三级缓存中移除。
    • getBean("b") 调用结束,返回完整的 B 实例。
  9. 完成 A 的创建:

    • 回到第 3 步,A 终于等到了完整的 B 实例。
    • A 将 B 实例设置到自己的 b 属性中。
    • A 的属性填充完成,A 初始化完毕。
    • 完整的 A 实例放入一级缓存 singletonObjects,并从二级缓存中移除。
    • getBean("a") 调用结束。

至此,A 和 B 的循环依赖被成功解决,二者都拿到了对方的实例。


4. 为什么需要三级缓存,二级不行吗?

这是一个非常好的进阶问题。如果只是解决普通的循环依赖,二级缓存确实够了。

  • 二级缓存方案:实例化 A 后,直接把“空壳” A 放入二级缓存。当 B 需要 A 时,直接从二级缓存获取。

引入三级缓存的根本原因是为了支持 AOP。

如果 A 类被 AOP 代理了(比如有 @Transactional 注解),那么注入给 B 的不应该是原始的 A 实例,而应该是 A 的代理对象。

问题在于:代理对象的创建时机。我们希望代理对象尽可能晚地被创建,只有在真正需要时才创建。

  • 如果使用二级缓存,就必须在实例化 A 之后,立刻判断是否需要 AOP,如果需要就马上创建代理对象,然后放入二级缓存。这不符合“延迟创建”的原则。

  • 三级缓存 singletonFactories 的妙用就在于此。它存入的不是对象,而是一个 ObjectFactory(工厂)。当 B 需要 A 时,从三级缓存拿到这个工厂。此时,Spring 才会调用工厂的 getObject() 方法。在这个方法内部,Spring 才有机会去判断 A 是否需要被代理。如果需要,就返回代理对象;如果不需要,就返回原始对象。

总结:
三级缓存(singletonFactories)的主要作用是延迟一个 Bean 的代理对象的创建,只有当它被其他 Bean 依赖时,才通过 ObjectFactory 去真正地创建并返回(可能是原始对象,也可能是代理对象),然后将其放入二级缓存供后续使用。这遵循了 Spring AOP 的设计原则。


5. 哪些场景下的循环依赖 Spring 无法解决?

Spring 的三级缓存机制是有前提的:

  1. 必须是单例(Singleton)作用域的 Bean。因为 Prototype 作用域的 Bean 每次都会创建新的实例,不走缓存。
  2. 必须是通过setter或字段注入(@Autowired

Spring 无法解决以下场景的循环依赖:

a. 构造器注入(Constructor Injection)的循环依赖

java
@Component
public class A {
    private final B b;
    // 构造器注入
    public A(B b) { this.b = b; }
}

@Component
public class B {
    private final A a;
    // 构造器注入
    public B(A a) { this.a = a; }
}

原因:当 Spring 尝试创建 A 时,A 的构造函数需要 B。Spring 就会去创建 B。而 B 的构造函数又需要 A。此时 A 还在创建中(连实例化都未完成),无法提供给 B,形成了无法解决的死锁。Spring 在启动时会直接抛出 BeanCurrentlyInCreationException

b. Prototype 作用域的循环依赖

原因:Prototype Bean 不会被缓存。每次 getBean 都会创建一个新的实例。所以当 A 依赖 B,去创建 B,B 又依赖 A,又去创建一个新的 A... 这个过程会无限循环,直到栈溢出。


6. 作为开发者,我们应该如何处理或避免循环依赖?

循环依赖通常被认为是一种“代码坏味道”(Code Smell),它可能意味着你的类职责划分不清。

  1. 优化设计(最佳方案)

    • 重新审视你的类设计。是不是 A 和 B 的职责耦合太紧了?
    • 尝试引入第三个类 C,将 A 和 B 共享的逻辑或依赖抽取到 C 中,让 A 和 B 都依赖 C,从而打破循环。
    • 或者,将某个 Bean 的部分功能拆分出去,降低其职责范围。
  2. 使用 @Lazy 注解(针对构造器注入)

    • 这是解决构造器注入循环依赖的官方推荐方法。
    • 在其中一个构造器参数上加上 @Lazy
    java
    @Component
    public class A {
        private final B b;
        public A(@Lazy B b) { this.b = b; }
    }
    • 原理:加上 @Lazy 后,Spring 注入的不再是 B 的实例,而是一个 B 的代理对象。只有当你第一次调用这个代理对象的方法时,Spring 容器才会去真正地创建和初始化 B 实例。这就在时间上错开了依赖注入,打破了循环。
  3. 使用 Setter 注入或字段注入

    • 如果设计上无法避免,将构造器注入改为 setter 注入或字段注入,利用 Spring 的三级缓存机制来自动解决。
  4. 使用 @PostConstruct

    • 将其中一个依赖的注入逻辑,从构造器或字段注入,移到 @PostConstruct 初始化的方法中。此时,本 Bean 已经实例化完成,可以去容器中获取另一个 Bean。

希望这个详尽的解释能帮助你彻底理解 Spring 的循环依赖问题!

00:00
00:00