问题描述
在本地开发中不会发生循环依赖问题,但是在容器场景下,制作成镜像启动后异常出现Bean的循环依赖。
问题原因
开发者在代码中使用构造函数注入来引用依赖的 Bean,这种方式可能导致循环依赖问题。虽然 Spring 框架具备循环依赖的处理机制,但它仅适用于通过 @Resource 或 @Autowired 注解进行的 Setter 方法注入或字段注入。如果开发者使用构造函数注入,当Bean的初始化未发生循环依赖,则启动没问题,对应日常开发中不会有循环依赖问题,但是在一些Docker容器场景,则会偶发抛出循环依赖异常。
深入剖析原理
进一步深入分析为什么本地没有循环依赖问题,但是容器里或服务器里偶发会出现循环依赖问题,问题的根源在于 Spring Boot 在扫描和实例化 Bean 时的顺序并非固定,而是可能受到 Jar 包中文件列表顺序 的影响。
- Jar 包文件列表的顺序
- 在 Java 中,Jar 文件实际上是一个压缩包,内部包含了许多类文件和资源文件。当应用程序运行时,可能需要遍历这些文件。例如,Spring Boot 在启动时需要扫描特定路径下的类和资源,以识别需要创建的 Bean。
- 在 Java 中,可以使用 JarFile.entries() 方法获取 Jar 包中的所有条目。然而,需要注意的是:JarFile.entries() 返回的并非是一个稳定有序的列表。根据 Java 官方文档,entries() 返回一个 Enumeration,但并未保证返回的顺序。
- 不同的平台或工具在打包 Jar 文件时,可能导致文件列表的顺序不同。例如,在 Windows 和 Linux 上生成的 Jar 文件,即使内容完全相同,但内部文件的排列顺序可能不同。
- Spring Boot 的资源匹配逻辑
Spring Boot 使用 PathMatchingResourcePatternResolver 类来处理资源的匹配和加载。其中,doFindPathMatchingJarResources() 方法负责在 Jar 文件中查找与指定模式匹配的资源。
protected Set<Resource> doFindPathMatchingJarResources(Resource rootDirResource, String subPattern) throws IOException {
...
JarFile jarFile = ((JarURLConnection) rootDirURL.openConnection()).getJarFile();
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryPath = entry.getName();
// 资源匹配逻辑
if (matcher.match(subPattern, entryPath)) {
// 处理匹配的资源
}
}
...
}
如上所示,jarFile.entries() 返回的文件列表顺序不定。这意味着,Spring Boot 在扫描资源并识别 Bean 时,可能因为文件顺序的不同而导致 Bean 的加载顺序发生变化。
- Bean 加载顺序对循环依赖的影响
在大多数情况下,Bean 的加载顺序并不会影响应用程序的启动。然而,当存在循环依赖时,Bean 的加载顺序可能决定了 Spring 能否成功地解决这种依赖关系。
举个例子,假设存在两个 Bean:BeanA 和 BeanB,它们互相依赖。如果 BeanA 先被加载,Spring 可能能够通过代理或其他机制解决依赖。然而,如果 BeanB 先被加载,可能就会导致无法解决的循环依赖,进而抛出异常。
由于 Jar 包中文件列表的顺序不定,导致 Bean 的加载顺序在不同的环境或不同的启动中可能有所不同,这解释了为什么循环依赖问题会 偶尔 发生。
参考链接
解决方案
方案1:干掉循环依赖
最根本的解决方案是 重新设计 Bean 的依赖关系,避免循环依赖的出现。
开发规范:默认禁用构造器注入的循环依赖
PS:在升级SpringBoot3.0后,对应Spring6.0 开始,默认情况下不再允许通过构造器注入的方式解决循环依赖。如果两个 Bean 之间通过构造器注入存在循环依赖,Spring 将会直接抛出 BeanCurrentlyInCreationException,而不再试图通过懒加载代理等方式来解决这个问题。
使用 @DependsOn 注解
Spring 提供了 @DependsOn 注解,允许我们显式地指定 Bean 的加载顺序。
@Component
@DependsOn("beanB")
public class BeanA {
// ...
}
@Component
public class BeanB {
// ...
}
通过这种方式,可以确保 BeanB 在 BeanA 之前被初始化。然而,需要谨慎使用该注解,避免引入新的依赖问题。
代码中定义某个Bean延迟加载
@Component
public class BeanA {
private final BeanB beanB;
public BeanA(@Lazy BeanB beanB) {
this.beanB = beanB;
}
// ...
}
方案3:启用延迟加载
spring.main.lazy-initialization=true 是 Spring Boot 应用中的一个配置选项,它用于启用 延迟初始化功能。
在 Spring Boot 2.2 及以上版本中,lazy-initialization 的默认值是 false。这意味着默认情况下,Spring Boot 应用中的所有 Bean 都是在应用启动时立即初始化的,而不是在第一次使用时才进行初始化。
延迟初始化 (Lazy Initialization) 的概念
在 Spring 应用中,默认情况下,所有的 @Bean 和组件(如 @Component, @Service, @Repository 等)在应用启动时都会被立即创建和初始化。这意味着在应用程序启动时,所有这些 Bean 都会被加载到 Spring 应用上下文中,无论它们何时在应用的生命周期中被使用。
启用延迟初始化 (lazy initialization) 后,**只有在第一次需要使用某个 Bean 的时候,该 Bean 才会被创建和初始化。**这可以加快应用启动的速度,尤其是在有许多不需要立即初始化的 Bean 时。
延迟初始化的优缺点
优点
- 减少应用启动时间:对于大型应用来说,减少不必要的 Bean 初始化可以显著提高启动速度。
- 资源节约:只有在需要时才会创建 Bean,节省了内存和 CPU 资源。
缺点
- 潜在的延迟:由于 Bean 在第一次使用时才会被创建,这可能导致在应用运行过程中首次调用某个服务时出现轻微的延迟。
- 调试复杂度:延迟初始化可能会导致某些问题(如配置错误、Bean 的依赖问题)直到运行时才暴露出来,这可能增加调试的复杂性。
注意事项
- 延迟初始化适用于不需要立即加载的服务和组件,但对于关键服务(如启动时需要立即使用的 Bean),你可能希望保持默认的非延迟加载方式。
- 延迟初始化可以通过在特定 Bean 上使用 @Lazy(false) 来排除那些需要立即初始化的 Bean。
启用延迟初始化的场景
开发环境:加快开发过程中应用的启动速度,减少等待时间。
测试环境:在单元测试时,只加载特定的 Bean,而不是所有的 Bean,减少测试的开销。
微服务:在某些微服务架构中,可能希望某些 Bean 仅在真正需要时才加载,以节省资源。
如何启用延迟初始化
可以在 application.properties 或 application.yml 中配置 spring.main.lazy-initialization=true 来启用延迟初始化。
application.properties 示例
spring.main.lazy-initialization=true
application.yml 示例
spring:
main:
lazy-initialization: true