Spring 循环依赖详解

什么是循环依赖

循环依赖(Circular Dependency)是指两个或多个模块(组件、类、服务等)之间互相依赖,从而形成一个循环的依赖关系。

这种情况在软件开发中是比较常见的一个问题,尤其是在复杂的系统中。


为什么会出现循环依赖

循环依赖通常是由于以下几个原因导致的:

  • 模块间的紧密耦合:模块之间的耦合度过高,彼此依赖过于密切。
  • 不合理的设计:系统设计时没有充分考虑模块的独立性和复用性。
  • 单一职责原则的违反:模块的职责过于复杂,导致需要依赖多个其他模块来完成其功能。

循环依赖的问题

  • 难以维护:模块之间的耦合度过高,修改一个模块可能需要修改多个相关模块。
  • 难以测试:由于模块之间的依赖关系复杂,独立测试某个模块变得困难。
  • 可能导致运行时错误:在某些情况下,循环依赖可能导致程序在运行时崩溃或出现异常,有时候甚至连编译的无法通过。

如何解决循环依赖

使用构造器注入与Setter注入的结合

使用构造器注入时,循环依赖会导致两个或多个bean无法实例化,因为每个bean在实例化时都需要另一个尚未实例化的bean。

Setter注入可以部分缓解这个问题,因为Spring可以在bean实例化之后再注入依赖。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Class A
public class A {
private B b;
// Constructor
public A() {}

public void setB(B b) {
this.b = b;
}
}

// Class B
public class B {
private A a;
// Constructor
public B(A a) {
this.a = a;
}
}

使用@PostConstruct注解

在Spring中,可以使用@PostConstruct注解在bean完全初始化之后进行依赖注入

这可以确保bean在相互依赖时能够正确初始化。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Class A
public class A {
private B b;

public A() {}

@Autowired
public void setB(B b) {
this.b = b;
}

@PostConstruct
public void init() {
// Initialize A after all dependencies are injected
}
}

// Class B
public class B {
private A a;

public B(A a) {
this.a = a;
}
}

使用代理(Proxy)

代理是一种常见的设计模式,它可以用于解决循环依赖问题。

创建懒加载的代理对象,当A依赖B时,会变成A依赖代理对象的延迟初始化,从而打破了循环依赖

Spring提供了@Lazy注解来创建懒加载的代理对象。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Class A
public class A {
private B b;

@Autowired
public A(@Lazy B b) {
this.b = b;
}
}

// Class B
public class B {
private A a;

@Autowired
public B(@Lazy A a) {
this.a = a;
}
}

使用接口和实现类解耦

通过引入接口,可以减少实现类之间的直接依赖,从而降低耦合度。

接口是定义功能而不涉及具体实现的抽象层。当组件 A 依赖于接口 IB 而不是具体实现 B,以及组件 B 依赖于接口 IA 而不是具体实现 A 时,A 和 B 之间的实际依赖关系被抽象化了。这样,A 和 B 之间的循环依赖关系被解耦,从而避免了直接的循环依赖。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Interface IA
public interface IA {
void doSomething();
}

// Class A
public class A implements IA {
private IB b;

@Autowired
public A(IB b) {
this.b = b;
}

@Override
public void doSomething() {
// Implementation
}
}

// Interface IB
public interface IB {
void performAction();
}

// Class B
public class B implements IB {
private IA a;

@Autowired
public B(IA a) {
this.a = a;
}

@Override
public void performAction() {
// Implementation
}
}

解决循环依赖的原理

原理

以单例模式展开

Spring在创建Bean时,使用了BeanFactoryApplicationContext来管理bean的生命周期。以下是Spring处理循环依赖的主要原理:

  1. 三级缓存:Spring在实例化Bean时(默认Bean是单例的),使用了三级缓存来解决循环依赖问题。
  • **一级缓存(singletonObjects)**:存储完全初始化的bean(完整Bean)。
  • **二级缓存(earlySingletonObjects)**:存储提前暴露的原始bean(未进行属性填充)。
  • 三级缓存(singletonFactories):存储带有代理对象的bean,用于处理AOP相关的依赖。
  1. 提前暴露bean:当Spring发现某个bean的依赖尚未注入时,会将这个bean的引用提前暴露在二级缓存中,以便其他bean能够引用它。

三级缓存是如何被使用的呢?

  1. 获取单例Bean时会通过Bean的名称去一级缓存查找完整的Bean,找到就返回,否则进入第2步
  2. 如果对应的Bean不是“创建中”的状态,返回 null,如果是“创建中”的状态,进二级缓存找Bean,找到就返回,没找到进入第3步
  3. 去三级缓存,通过Bean的名称查找对应的工厂,如果存在这个工厂,通过工厂创建Bean并加入三级缓存,否则,返回 null

创建一个Bean需要依次经过:实例化、属性注入、初始化 三个阶段,这三个阶段如何和三级缓存对应起来呢?

  1. 当我们实例化一个Bean后,会往三级缓存里加入该Bean的工厂,Bean就是调用这个工厂的createBean方法来创建的(实际上创建Bean是调用doCreateBean方法,这个方法会依次完成实例化、属性注入、初始化三个阶段)
  2. 实例化后,Spring会将这个工厂加入到三级缓存中,这个过程被称为提前暴露
  3. 提前暴露后,会开始执行属性注入,假设Bean A依赖Bean B,现在创建A时需要注入B(getBean(B)),这时候就会去走三级缓存,最终在第三级缓存找到工厂并得到不完整的B(假设B已经提前暴露了,存在这个工厂),随后在三级缓存里删掉这个工厂,并将A加入到二级缓存。最终,A属性注入B时成功,且A被加入到二级缓存
  4. A被加入了二级缓存,紧接着B就可以调用initializeBean方法初始化,此时B会被加入到一级缓存,然后再回到A的属性注入,将完整的B注入,然后执行初始化,最后A也会被加入到一级缓存并删除二级缓存的A。至此,循环依赖就被解决了

简单描述的话,Spring在处理时,会按照以下步骤进行:

  1. 实例化A,但尚未注入B(使用了提前暴露的不完整的B)。
  2. 将A的引用放入二级缓存。
  3. 实例化B,并从二级缓存中获取A的引用注入B,然后B马上初始化。
  4. 完成A的依赖注入和初始化,将A从二级缓存移到一级缓存。

扩展1:不是单例模式怎么解决

非单例模式:Spring 无法通过缓存机制处理循环依赖,需使用 @Lazy 注解、Setter 注入或工厂方法来解决。

我们可以将缓存机制理解为单例模式Bean的特定解决方案,对于非单例模式,我们可以借鉴单例模式的解决方案来解决,或是对单例模式进行扩展

扩展2:一定需要三级缓存吗?

从解决循环依赖的原理的角度来看,不需要三级缓存,两级就够用了

那么,为什么设计之初,会考虑设计三级缓存呢?

可以这么理解:

三级缓存标记了三种状态:

  • 已经初始化的
  • 创建中-已经实例化,等待属性注入
  • 创建中-等待实例化

二级缓存的限制:

  1. 破坏了状态转移的完整性:二级缓存中的 Bean 实例是部分初始化的。这意味着它们还没有完成所有属性的注入和初始化,无法提供完全可用的 Bean 状态。这会影响到 Bean 的正常使用,特别是在依赖注入过程中。

  2. 无法处理代理:某些 Bean 可能需要通过代理(如 AOP)来处理特定功能。二级缓存中的 Bean 还没有完全创建(等待属性注入,代理对象通常在被代理对象的初始化后期生成),Spring 无法在二级缓存中创建和维护这些代理对象。

为什么三级缓存就没有这种(代理对象)限制?

  1. 创建 Bean A,Spring 将 A 的部分初始化引用放入二级缓存,并在三级缓存中存储对 A 的代理对象。

  2. 创建 Bean B,Bean B 从三级缓存中获取 A 的代理对象,并注入到 B 中。此时,B 能够使用 A 的代理对象,而不必等待 A 完全初始化。

补充:

属性注入可能发生在代理对象被创建后:

代理创建时的属性注入

  • 在单例 Bean 中:如果被代理的对象是单例 Bean,那么在 Bean 被创建和初始化(包括属性注入)后,代理对象会被创建并替代实际的 Bean。
  • 在原型 Bean 中:如果被代理的对象是原型 Bean,代理对象通常会在每次 Bean 创建时被重新创建,因此属性注入可能在代理对象创建后进行。

总结

站在实战的角度看,解决循环依赖有这么四种方法:

  1. Setter + 构造器注入结合
  2. @PostContruct,在初始化后再进行依赖注入
  3. @Lazy,创建懒加载的代理对象,当A依赖B时,会变成A依赖代理对象的延迟初始化,从而打破了循环依赖
  4. 接口和实现类的解耦

实践中:

单例更多地使用:@PostContruct,在初始化后再进行依赖注入

非单例更多地使用:@Lazy,因为在原型Bean中,每个代理都会重新创建自己的Bean。

非单例为什么不用@PostContruct?

原型 Bean:每次请求都会创建一个新的实例。@PostConstruct 方法会在每个新的 Bean 实例初始化后执行。因此,对于每个原型 Bean 实例,@PostConstruct 方法都会被调用一次。

在非单例模式下,如果 Bean 依赖于其他 Bean,这些依赖 Bean 可能已经被初始化并可能被重新创建,因此要确保在 @PostConstruct 方法中使用的依赖 Bean 已经完全初始化。

显然这样是有风险的,不如选择基于代理思想的@Lazy,那么会创建更多的Bean,增加管理压力,也好过前者这种有风险的行为