Spring 循环依赖详解
Spring 循环依赖详解
什么是循环依赖
循环依赖(Circular Dependency)是指两个或多个模块(组件、类、服务等)之间互相依赖,从而形成一个循环的依赖关系。
这种情况在软件开发中是比较常见的一个问题,尤其是在复杂的系统中。
为什么会出现循环依赖
循环依赖通常是由于以下几个原因导致的:
- 模块间的紧密耦合:模块之间的耦合度过高,彼此依赖过于密切。
- 不合理的设计:系统设计时没有充分考虑模块的独立性和复用性。
- 单一职责原则的违反:模块的职责过于复杂,导致需要依赖多个其他模块来完成其功能。
循环依赖的问题
- 难以维护:模块之间的耦合度过高,修改一个模块可能需要修改多个相关模块。
- 难以测试:由于模块之间的依赖关系复杂,独立测试某个模块变得困难。
- 可能导致运行时错误:在某些情况下,循环依赖可能导致程序在运行时崩溃或出现异常,有时候甚至连编译的无法通过。
如何解决循环依赖
使用构造器注入与Setter注入的结合
使用构造器注入时,循环依赖会导致两个或多个bean无法实例化,因为每个bean在实例化时都需要另一个尚未实例化的bean。
Setter注入可以部分缓解这个问题,因为Spring可以在bean实例化之后再注入依赖。
示例:
1 | // Class A |
使用@PostConstruct
注解
在Spring中,可以使用@PostConstruct
注解在bean完全初始化之后进行依赖注入。
这可以确保bean在相互依赖时能够正确初始化。
示例:
1 | // Class A |
使用代理(Proxy)
代理是一种常见的设计模式,它可以用于解决循环依赖问题。
创建懒加载的代理对象,当A依赖B时,会变成A依赖代理对象的延迟初始化,从而打破了循环依赖
Spring提供了@Lazy
注解来创建懒加载的代理对象。
示例:
1 | // Class A |
使用接口和实现类解耦
通过引入接口,可以减少实现类之间的直接依赖,从而降低耦合度。
接口是定义功能而不涉及具体实现的抽象层。当组件 A 依赖于接口 IB
而不是具体实现 B
,以及组件 B 依赖于接口 IA
而不是具体实现 A
时,A 和 B 之间的实际依赖关系被抽象化了。这样,A 和 B 之间的循环依赖关系被解耦,从而避免了直接的循环依赖。
示例:
1 | // Interface IA |
解决循环依赖的原理
原理
以单例模式展开
Spring在创建Bean时,使用了
BeanFactory
和ApplicationContext
来管理bean的生命周期。以下是Spring处理循环依赖的主要原理:
- 三级缓存:Spring在实例化Bean时(默认Bean是单例的),使用了三级缓存来解决循环依赖问题。
- **一级缓存(singletonObjects)**:存储完全初始化的bean(完整Bean)。
- **二级缓存(earlySingletonObjects)**:存储提前暴露的原始bean(未进行属性填充)。
- 三级缓存(singletonFactories):存储带有代理对象的bean,用于处理AOP相关的依赖。
- 提前暴露bean:当Spring发现某个bean的依赖尚未注入时,会将这个bean的引用提前暴露在二级缓存中,以便其他bean能够引用它。
三级缓存是如何被使用的呢?
- 获取单例Bean时会通过Bean的名称去一级缓存查找完整的Bean,找到就返回,否则进入第2步
- 如果对应的Bean不是“创建中”的状态,返回
null
,如果是“创建中”的状态,进二级缓存找Bean,找到就返回,没找到进入第3步- 去三级缓存,通过Bean的名称查找对应的工厂,如果存在这个工厂,通过工厂创建Bean并加入三级缓存,否则,返回
null
创建一个Bean需要依次经过:实例化、属性注入、初始化 三个阶段,这三个阶段如何和三级缓存对应起来呢?
- 当我们实例化一个Bean后,会往三级缓存里加入该Bean的工厂,Bean就是调用这个工厂的createBean方法来创建的(实际上创建Bean是调用doCreateBean方法,这个方法会依次完成实例化、属性注入、初始化三个阶段)
- 实例化后,Spring会将这个工厂加入到三级缓存中,这个过程被称为提前暴露
- 提前暴露后,会开始执行属性注入,假设Bean A依赖Bean B,现在创建A时需要注入B(
getBean(B)
),这时候就会去走三级缓存,最终在第三级缓存找到工厂并得到不完整的B(假设B已经提前暴露了,存在这个工厂),随后在三级缓存里删掉这个工厂,并将A加入到二级缓存。最终,A属性注入B时成功,且A被加入到二级缓存- A被加入了二级缓存,紧接着B就可以调用
initializeBean
方法初始化,此时B会被加入到一级缓存,然后再回到A的属性注入,将完整的B注入,然后执行初始化,最后A也会被加入到一级缓存并删除二级缓存的A。至此,循环依赖就被解决了
简单描述的话,Spring在处理时,会按照以下步骤进行:
- 实例化A,但尚未注入B(使用了提前暴露的不完整的B)。
- 将A的引用放入二级缓存。
- 实例化B,并从二级缓存中获取A的引用注入B,然后B马上初始化。
- 完成A的依赖注入和初始化,将A从二级缓存移到一级缓存。
扩展1:不是单例模式怎么解决
非单例模式:Spring 无法通过缓存机制处理循环依赖,需使用
@Lazy
注解、Setter 注入或工厂方法来解决。我们可以将缓存机制理解为单例模式Bean的特定解决方案,对于非单例模式,我们可以借鉴单例模式的解决方案来解决,或是对单例模式进行扩展
扩展2:一定需要三级缓存吗?
从解决循环依赖的原理的角度来看,不需要三级缓存,两级就够用了
那么,为什么设计之初,会考虑设计三级缓存呢?
可以这么理解:
三级缓存标记了三种状态:
- 已经初始化的
- 创建中-已经实例化,等待属性注入
- 创建中-等待实例化
二级缓存的限制:
破坏了状态转移的完整性:二级缓存中的 Bean 实例是部分初始化的。这意味着它们还没有完成所有属性的注入和初始化,无法提供完全可用的 Bean 状态。这会影响到 Bean 的正常使用,特别是在依赖注入过程中。
无法处理代理:某些 Bean 可能需要通过代理(如 AOP)来处理特定功能。二级缓存中的 Bean 还没有完全创建(等待属性注入,代理对象通常在被代理对象的初始化后期生成),Spring 无法在二级缓存中创建和维护这些代理对象。
为什么三级缓存就没有这种(代理对象)限制?
创建 Bean A,Spring 将 A 的部分初始化引用放入二级缓存,并在三级缓存中存储对 A 的代理对象。
创建 Bean B,Bean B 从三级缓存中获取 A 的代理对象,并注入到 B 中。此时,B 能够使用 A 的代理对象,而不必等待 A 完全初始化。
补充:
属性注入可能发生在代理对象被创建后:
代理创建时的属性注入:
- 在单例 Bean 中:如果被代理的对象是单例 Bean,那么在 Bean 被创建和初始化(包括属性注入)后,代理对象会被创建并替代实际的 Bean。
- 在原型 Bean 中:如果被代理的对象是原型 Bean,代理对象通常会在每次 Bean 创建时被重新创建,因此属性注入可能在代理对象创建后进行。
总结
站在实战的角度看,解决循环依赖有这么四种方法:
- Setter + 构造器注入结合
@PostContruct
,在初始化后再进行依赖注入@Lazy
,创建懒加载的代理对象,当A依赖B时,会变成A依赖代理对象的延迟初始化,从而打破了循环依赖- 接口和实现类的解耦
实践中:
单例更多地使用:@PostContruct
,在初始化后再进行依赖注入
非单例更多地使用:@Lazy
,因为在原型Bean中,每个代理都会重新创建自己的Bean。
非单例为什么不用@PostContruct?
原型 Bean:每次请求都会创建一个新的实例。
@PostConstruct
方法会在每个新的 Bean 实例初始化后执行。因此,对于每个原型 Bean 实例,@PostConstruct
方法都会被调用一次。在非单例模式下,如果 Bean 依赖于其他 Bean,这些依赖 Bean 可能已经被初始化并可能被重新创建,因此要确保在
@PostConstruct
方法中使用的依赖 Bean 已经完全初始化。显然这样是有风险的,不如选择基于代理思想的
@Lazy
,那么会创建更多的Bean,增加管理压力,也好过前者这种有风险的行为