记一次线上偶现的循环依赖问题
前情回顾
一探
Spring 的循环依赖,源码详细分析 → 真的非要三级缓存吗 中讲到了循环依赖问题
同样说明了 Spring 只能解决 setter 方式的循环依赖,不能解决构造方法的循环依赖
重点介绍了 Spring 是如何解决 setter 方式的循环依赖,感兴趣的可以去看下
二探
既然 Spring 不能解决构造方法的循环依赖,那么它是如何甄别构造方法循环依赖的了?
所以进行了二探:再探循环依赖 → Spring 是如何判定原型循环依赖和构造方法循环依赖的?
从源码的角度讲述了 Spring 是如何判定构造方法循环依赖、原型循环依赖的
感兴趣的可以去看下
大家跟源码的时候,一定要注意版本!!!
项目模拟
自认为经过了前两探,对 Spring 循环依赖的问题已了若指掌,可面对线上突如其来的循环依赖问题,楼主竟然没能一眼看出来!!!
这楼主能忍?于是楼主又跟起了 Spring 源码,看看问题到底出在哪?
SpringBoot 版本是 2.0.3.RELEASE
线上服务采用 k8s 部署,本地环境未采用 k8s 部署
本地启动从未出现循环依赖问题,线上环境也只是偶发的 pod 启动失败(提示信息直指循环依赖)
问题偶发,而非必现,很是头疼,但问题还是得解决,从提示信息着手呗
根据错误提示信息,楼主模拟出了一个简化的工程,方便我们进行问题排查
非常简单,完整地址:spring-other-circular-reference
我们来看下类图
MyListener 、 MyService 、 MyManager 很常规,特殊的是 MyConfig 和 MySender
问题复现
如果按上述工程结构,本地很难复现问题 ,反正楼主是没复现出来
我们稍做调整,将 MySender 前置,如下
启动失败,错误信息如下:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'myConfig': Unsatisfied dependency expressed through field 'myListener'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'myListener': Unsatisfied dependency expressed through field 'myService'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'myServiceImpl': Unsatisfied dependency expressed through field 'myManager'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'myManager': Unsatisfied dependency expressed through field 'mySender'; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'mySender': Requested bean is currently in creation: Is there an unresolvable circular reference?
此刻的 Is there an unresolvable circular reference? 让楼主感到了陌生
问题分析
我们从以下几个方面来分析
BeanDefinition 扫描
目前 XML 方式的 Bean 定义越来越少,除了一些遗留的老项目,基本看不到 XML 方式的 Bean 定义了
所以我们只关注注解方式的 Bean 定义的扫描
文件夹的扫描顺序与文件夹名字的升序一致,文件的顺序与文件名的升序一致,如下所示
有兴趣的可以去跟下 ConfigurationClassParser 类中 doProcessConfigurationClass 方法;楼主做了下简单的总结
@ComponentScan 的处理早于 @Bean
BeanDefinition 扫描过程中,会按扫描顺序会往 DefaultListableBeanFactory 的 beanDefinitionMap 中添加 BeanDefinition ,往 beanDefinitionNames 添加 BeanName
我们来跟下源码,看是不是如上所说
先被扫描的 BeanDefinition 的 BeanName 会被先添加到 beanDefinitionNames
BeanDefinition 覆盖
MyConfig 中通过 @Bean 定义了 MySender ,而 MySender 类上又用了 @Component 进行修饰
那创建 MySender 实例的时候到底调用的哪个构造方法?(有参还是无参?)
关于 Spring Boot 中创建对象的疑虑 → @Bean 与 @Component 同时作用同一个类,会怎么样?从源码的角度分析了这个问题
结论是:SpringBoot 2.0.3.RELEASE 中, @Configuration + @Bean 修饰的 BeanDefinition 会覆盖掉 @Component 修饰的 BeanDefinition
也就说 MySender 类上的 @Component 其实没用,加不加效果是一样的,这里说的 没用、效果 仅仅指的是 MySender 的 BeanDefinition
Bean 实例化顺序
BeanDefinition 用来构建实例,那么 MySender 上的 @Component 就有作用了,它决定了 MySender 的实例化顺序
是先于 MyConfig 、 MyListener 、 MyServiceImpl 、 MyManager 实例化的
我们来看下 Bean 的实例化顺序
理论上来讲,先被扫描的 Bean 会先被实例化;Bean 实例化的过程中会填充属性,可能会导致后被扫描的 Bean 提前被实例化
如果 Bean 之间没有依赖,那么会严格按照 Bean 的扫描顺序实例化
再看问题
我们再回到前面的问题
这种情况下,我们分析下 Is there an unresolvable circular reference? 是如何产生的
相较于 MyConfig 、 MyListener 、 MyManager 、 MyServiceImpl , MySender 是先被扫描到的,所以它先被实例化
因为 MyConfig 中通过 @Bean 修饰了 MySender 的 BeanDefinition
会覆盖掉 MySender 自身的无参 BeanDefinition
所以会通过 MySender 的有参构造方法来创建 MySender 实例
因为有参构造方法依赖 myListener ,所以去 Spring 容器中找 MyListener 实例,没有找到则创建,然后填充 MyListener 实例的属性
以此类推,实例的创建过程如下所示:
Is there an unresolvable circular reference?就此产生
相当于是变种的构造方法循环依赖
初状态
我们还原 MySender 位置
此时先实例化的是 MyConfig ,实例化过程如下
对象是都可以正常实例化、初始化的
这种情况理论上来讲是不会出现 Is there an unresolvable circular reference?
线上问题
一通分析下来,还是没能找到线上 Is there an unresolvable circular reference?的原因
很是尴尬,但是我萌生了这样的想法:是不是在 k8s 部署过程中, BeanDefinition 的扫描会有偶发的随机性?
问题修复
虽然我们没能找到线上问题的确切原因,但还是有办法去根治这个问题的
Spring 不能处理构造方法循环依赖,那我们就去规避它
删掉 MyConfig , MySender 改成
或 MySender 改成
还有 @PostConstruct 等,方式有很多,只要不产生构造方法循环依赖就好
总结
1、 BeanDefinition 扫描顺序
如果我们去跟源代码就会发现,以启动类为起点,扫描启动类同级目录下的所有文件夹
按文件夹名升序顺序进行扫描,会递归扫描每个文件夹
文件扫描也是按文件名升序顺序进行
从线上问题来看,对这个扫描顺序,楼主是持怀疑态度的:是 Spring 会偶发的随机扫描,还是 pod 会导致偶发的随机扫描
2、 BeanDefinition 覆盖
只要我们读了源码,了解 Spring 对各个注解的扫描顺序,就清楚它们的替换关系了
BeanDefinition 覆盖并不会影响 BeanDefinition 的扫描顺序
也就是不会改变 BeanName 在 beanDefinitionNames 中的位置,即不会影响 Bean 的示例化顺序
3、 Bean 实例化顺序
理论上来讲,先被扫描到的就先被实例化,但实例化过程中的属性填充会打乱这个顺序,会将被依赖的对象提前实例化
4、 Spring 版本
一定要结合版本来看问题
版本不同,底层实现可能会不同
6 垃圾收集算法
ZGC 采用标记 - 整理算法,算法的思想是把所有存活对象移动到堆的一侧,移动完成后回收掉边界以外的对象。如下图:
6.1 JDK 16 之前
在 JDK 16 之前,ZGC 会预留(Reserve)一块儿堆内存,这个预留内存不能用于 Java 线程的内存分配。即使从 Java 线程的角度看堆内存已经满了也不能使用 Reserve,只有 GC 过程中搬移存活对象的时候才可以使用。如下图:
这样做的好处是算法简单,非常适合并行收集。但这样做有几个问题:
因为有预留内存,能给 Java 线程分配的堆内存小于 JVM 声明的堆内存。 Reserve 仅仅用于存放 GC 过程中搬移的对象,有点内存浪费。 因为 Reserve 不能给 GC 过程中搬移对象的 Java 线程使用,搬移线程可能会因为申请不到足够内存而不能完成对象搬移,这返回过来又会导致应用程序的 OOM。
6.2 JDK 16 改进
JDK 16 发布后,ZGC 支持就地搬移对象(G1 在 Full GC 的时候也是就地搬移)。这样做的好处是不用预留空闲内存了。如下图:
不过就地搬移也有一定的挑战。比如:必须考虑搬移对象的顺序,否则可能会覆盖尚未移动的对象。这就需要 GC 线程之间更好的进行协作,不利于并发收集,同时也会导致搬移对象的 Java 线程需要考虑什么可以做什么不可以做。
为了获得更好的 GC 表现,JDK 16 在支持就地搬移的同时,也支持预留(Reserve)堆内存的方式,并且 ZGC 不需要真的预留空闲的堆内存。默认情况下,只要有空闲的 region,ZGC 就会使用预留堆内存的方式,如果没有空闲的 region,否则 ZGC 就会启用就地搬移。如果有了空闲的 region, ZGC 又会切换到预留堆内存的搬移方式。
7 总结
内存多重映射和染色指针的引入,使 ZGC 的并发性能大幅度提升。
ZGC 只有 3 个需要 STW 的阶段,其中初始标记和初始转移只需要扫描所有 GC Roots,STW 时间 GC Roots 的数量成正比,不会耗费太多时间。再标记过程主要处理并发标记引用地址发生变化的对象,这些对象数量比较少,耗时非常短。可见整个 ZGC 的 STW 时间几乎只跟 GC Roots 数量有关系,不会随着堆大小和对象数量的变化而变化。
ZGC 也有一个缺点,就是浮动垃圾。因为 ZGC 没有分代概念,虽然 ZGC 的 STW 时间在 1ms 以内,但是 ZGC 的整个执行过程耗时还是挺长的。在这个过程中 Java 线程可能会创建大量的新对象,这些对象会成为浮动垃圾,只能等下次 GC 的时候进行回收。
参考
1.https://wiki.openjdk.java.net/display/zgc 2.https://openjdk.java.net/jeps/304 3.https://openjdk.java.net/jeps/376 4.https://malloc.se/blog/zgc-jdk16 5.https://mp.weixin.qq.com/s/ag5u2EPObx7bZr7hkcrOTg 6.https://mp.weixin.qq.com/s/FIr6r2dcrm1pqZj5Bubbmw 7.https://www.jianshu.com/p/664e4da05b2c 8.https://www.cnblogs.com/jimoer/p/13170249.html 9.https://www.jianshu.com/p/12544c0ad5c1
转自:青石路
链接: https://www.cnblogs.com/youzhibing/p/15835048.html
相关文章