Spring 循环依赖解析
Spring通过三级缓存解决单例循环依赖。核心是提前曝光一个“半成品”对象,其中第三级缓存用于支持AOP。但此机制无法解决构造器注入等场景的循环依赖。
这是一个非常经典且重要的 Spring 面试题。我们来深入、系统地讲解一下 Spring 的循环依赖。
我会按照以下结构来为你剖析:
- 什么是循环依赖? (What)
- 循环依赖有什么问题? (Why it's a problem)
- Spring 是如何解决的?(核心:三级缓存) (How Spring solves it)
- 为什么需要三级缓存,二级不行吗? (Why three levels, not two)
- 哪些场景下的循环依赖 Spring 无法解决? (When Spring can't solve it)
- 作为开发者,我们应该如何处理或避免循环依赖? (Best practices)
1. 什么是循环依赖?
循环依赖(Circular Dependencies)指的是两个或多个 Bean 之间相互依赖,形成一个闭环。
最简单的例子是 A 依赖 B,同时 B 又依赖 A。
@Component
public class A {
@Autowired
private B b;
}
@Component
public class B {
@Autowired
private A a;
}
也可以是更长的依赖链,比如 A -> B -> C -> A。
2. 循环依赖有什么问题?
想象一下 Spring 创建 Bean 的过程:
- Spring 容器要创建 A。
- 它发现 A 依赖 B,于是它需要先去获取 B。
- Spring 容器转而去创建 B。
- 它发现 B 又依赖 A,于是它又需要去获取 A。
- ... 这就形成了一个死循环,导致 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 循环依赖为例):
创建 A:
getBean("a")被调用。- 首先检查一级缓存
singletonObjects,没有 A。 - 检查二级缓存
earlySingletonObjects,没有 A。 - 检查三级缓存
singletonFactories,没有 A。 - 开始创建 A。Spring 记录下 "a" 正在创建中。
实例化 A,并提前曝光:
- Spring 调用 A 的构造函数,创建了一个 A 的“空壳”实例(我们称之为
a_instance)。此时a_instance的b属性是null。 - 关键一步:Spring 并不会立即去填充
a_instance的属性。而是将一个能产生a_instance的工厂(ObjectFactory)放入三级缓存singletonFactories中。singletonFactories.put("a", () -> a_instance)。这一步就是“提前曝光”(Early Exposure)。
- Spring 调用 A 的构造函数,创建了一个 A 的“空壳”实例(我们称之为
填充 A 的属性:
- Spring 开始填充
a_instance的属性。它发现 A 依赖 B (@Autowired private B b;)。 - Spring 于是去调用
getBean("b")来获取 B。
- Spring 开始填充
创建 B:
getBean("b")被调用。- 同样,依次检查三级缓存,都没有 B。
- 开始创建 B。Spring 记录下 "b" 正在创建中。
实例化 B,并提前曝光:
- Spring 调用 B 的构造函数,创建了 B 的“空壳”实例 (
b_instance)。 - 和 A 一样,Spring 将 B 的工厂放入三级缓存
singletonFactories。
- Spring 调用 B 的构造函数,创建了 B 的“空壳”实例 (
填充 B 的属性:
- Spring 开始填充
b_instance的属性。它发现 B 依赖 A (@Autowired private A a;)。 - Spring 再次去调用
getBean("a")。
- Spring 开始填充
解决循环的关键点:
getBean("a")再次被调用。- 检查一级缓存,没有 A(因为 A 还没完全创建好)。
- 检查二级缓存,没有 A。
- 检查三级缓存,命中了! 找到了之前为 A 放入的
ObjectFactory。 - Spring 通过这个工厂
getObject()获取到了之前创建的a_instance(那个“空壳”实例)。 - 然后,Spring 会将这个从三级缓存中获取到的
a_instance放入二级缓存earlySingletonObjects中,并从三级缓存中移除对应的工厂。 - 将
a_instance返回给 B。
完成 B 的创建:
- B 拿到了 A 的(早期)引用
a_instance,并设置到自己的a属性中。 - B 的属性填充完成,B 初始化完毕。
- 将完整的 B 实例放入一级缓存
singletonObjects,并从二级和三级缓存中移除。 getBean("b")调用结束,返回完整的 B 实例。
- B 拿到了 A 的(早期)引用
完成 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 的三级缓存机制是有前提的:
- 必须是单例(Singleton)作用域的 Bean。因为 Prototype 作用域的 Bean 每次都会创建新的实例,不走缓存。
- 必须是通过setter或字段注入(
@Autowired)。
Spring 无法解决以下场景的循环依赖:
a. 构造器注入(Constructor Injection)的循环依赖
@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),它可能意味着你的类职责划分不清。
优化设计(最佳方案):
- 重新审视你的类设计。是不是 A 和 B 的职责耦合太紧了?
- 尝试引入第三个类 C,将 A 和 B 共享的逻辑或依赖抽取到 C 中,让 A 和 B 都依赖 C,从而打破循环。
- 或者,将某个 Bean 的部分功能拆分出去,降低其职责范围。
使用
@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 实例。这就在时间上错开了依赖注入,打破了循环。
使用
Setter注入或字段注入:- 如果设计上无法避免,将构造器注入改为
setter注入或字段注入,利用 Spring 的三级缓存机制来自动解决。
- 如果设计上无法避免,将构造器注入改为
使用
@PostConstruct:- 将其中一个依赖的注入逻辑,从构造器或字段注入,移到
@PostConstruct初始化的方法中。此时,本 Bean 已经实例化完成,可以去容器中获取另一个 Bean。
- 将其中一个依赖的注入逻辑,从构造器或字段注入,移到
希望这个详尽的解释能帮助你彻底理解 Spring 的循环依赖问题!