问题很简单,JVM源码捡起来再看看~
每天凌晨我们会对集群中资源使用率较低的Pod
进行配额调整,此时导致Pod
重启,由于在凌晨没有多少业务流量,滚动更新对业务的影响也比较小。
但是,
这一天,
业务反馈出问题了。
问题现象
业务同学反馈,某个服务的部分容器异常,一直在重启,看了一下重启容器中JVM
监控,发现一直在进行FullGC
。
于是也同步分析了一下GC信息,就看到了下面的结果。
看完就愣住了,老年代64KB是什么鬼。
问题分析
看到这里,首先想到的是看看启动时是不是配置的JVM
参数不对。
启动时配置了xms
和xmx
,都是1GB。
但是结合前面的图就好像不对劲,GC日志分析的结果显示年轻代和老年大大小都对不上,于是实际登上容器jmap
了一把。
那么问题来了,xmx
配置了1GB,老年代+年轻代的大小总和也确实是1GB,但是两者的大小比例并不是我们熟知的2:1(NewRatio),也就意味着肯定还有地方配置了年轻代或老年代大小相关的参数。
于是顺手看一下进程启动的完整命令,就找到了问题点。
看上去在启动命令中,还有其他地方配置了xms
、xmx
及xmn
,而在最终执行的过程中,两次xms
、xmx
的配置会以后者为主,所以实际上的配置应该是:
1 |
|
那么问题来了,这种情况下的堆大小应该没有疑问的1024MB,但是年轻代和老年代大小是如何计算的呢?
年轻代取1500M,那不是超过了堆最大值?
年轻代如果不取1500M,到底应该设置多少?
那老年代会有多少空间?
要了解这些问题,需要我们顺着分代初始化的逻辑,一步一步透过源码来看看。
JVM堆分代初始化
分代初始化顺序
以常见的CMS
为例(主要是其它的GC实现偷懒没细看,大体偏差不会太大,使用方式上略有不同),默认两分代的代码调用及分代初始化顺序如下。
整体流程上,
(1)Universe
初始化JVM时,对堆进行初始化,调用不同GC策略的初始化过程;
(2)初始化过程中,initialize_flags
逐级优先向上调用,初始化HeapSize
、NewSize
、OldSize
的配置(最大、最小、当前值);
(3)再通过initialize_size_info
逐级优先向上调用,初始化Heap
、gen0
、gen1
的实际大小。
1 |
|
看完整体的分代初始化顺序,我们单个分代逐个细看。
年轻代大小初始化
年轻代的初始化过程,从全局角度看其实分为三个阶段。
(1)基于用户配置读取并设置年轻代相关的配置(比如xmn
),包含NewSize
、MaxNewSize
等;
(2)通过GenCollectorPolicy::initialize_flags()
,重新计算年轻代大小的配置(NewSize
、MaxNewSize
),优先以用户配置为准,如无用户配置,则根据默认规则(NewRatio
)进行大小配置的计算;
(3)通过GenCollectorPolicy::initialize_size_info()
,最终计算年轻大的初始化大小(_min_gen0_size
、_initial_gen0_size
、_max_gen0_size
)。
综上来看,年轻代的大小与xmn
、NewRatio
、NewSize
、最大堆大小等都可能有关,最终完成年轻代的初始化值、最大/最小值的初始化。
年轻代最大值,优先以自定义的xmn
为主,未设置时则以NewRatio
和maxHeapSize
来设置。
年轻代初始化值,分为两种情况,若xms
=xmn
,则年轻大的最大、最小和初始化值均计算出的max_new_size
;若xms
≠xmn
,则优先以NewSize
进行初始化,否则根据堆大小及NewRatio
按不同的情况进行配置。
老年代大小初始化
老年代初始化的逻辑与新生代差不多,不再赘述。
唯一有所不同的是,由于初始化老年代大小时,整体堆大小及新生代大小已经初始化完,如果老年代有通过启动参数OldSize
手动指定老年代大小,则会进行一轮大小适配,避免堆及分代大小之前的冲突。
问题分析
回到问题本身,通过上面的内容,我们可以知道JVM堆内存初始化的顺序是heap_size
、gen0_size
、gen1_size
,结合前面讨论的生效配置,我们逐步来看。
1 |
|
heap_size
首先初始化heap_size
,按配置看来,heap
的初始化、最大/最小值均为1024MB
。
gen0_size
其次是年轻代大小,按自定义配置为1500MB
,但由于该大小已经超过heap_size
,这种情况会怎么处理呢?
可以看看GenCollectorPolicy::initialize_size_info()
中的处理过程~
当MaxNewSize
非默认值时,以MaxNewSize
作为年轻代最大大小,且此时由于xms
= xmx
,所以init_gen0_size
、min_gen0_size
、max_gen0_size
都会设置为MaxNewSize
。
那MaxNewSize
在哪配置的呢?答案在GenCollectorPolicy::initialize_flags()
。
综上来看,最终init_gen0_size
、min_gen0_size
、max_gen0_size
最终都会设置为max_heap_size - {gen_aligenment}
。
gen1_size
那么问题来了,堆大小、年轻代大小都初始化完了,那老年代呢?
看看TwoGenerationCollectorPolicy::initialize_size_info()
中是如何设置的。
此时老年代的初始化及最小值,与堆的初始化及最小值有关,代码转换后的逻辑,简单理解就是:
- 当
heap_size
足够大时,以heap_size - gen0_size
作为gen1_size
; - 当
heap_size
不够大时,至少保证gen1_size
有一个分代对齐的大小{gen_alignment}
。
在我们的场景中会走到分支二,即gen1_size
= {gen_alignment}
,最终得到下图结果,一个64KB
的老年代。
1 |
|
问题总结
综上来看,在确定堆大小后,如果新生代配置占用了太多堆空间,那么至少会保证老年代有一个分代对齐大小,即极端情况下的最小老年代大小,因此最终老年代大小只有64KB
。
一些关联的有趣问题
分代对齐大小
可以看待源码中,很多地方用到了gen_alignment
做内存对齐,并在极端情况下,保证老年代大小不低于gen_alignment
。
关于gen_alignment
的值,通过上文我们可以推测是64KB
,那么它是在哪设置的呢?
兜兜转转是个圈,我们回到Universe
中的initialize_heap()
,CMS
垃圾收集器会调用initialize_all()
,该函数如下:
所以在最初Universe
初始化JVM堆及GC时,首先初始化了分代对齐大小,再更新flag
及size
。
而gen_alignment
的设置过程如下:
(分别在concurrentMarkSweepGeneration.hpp
及generation.hpp
中)
在非ARM平台下,GenGrain
是2^16
,即64KB
。
1 |
|
Ergonomics
算是一个不算题外话的题外话 - Ergonomics
在设置大小的时候,经常会看到这样的配置:
在设置时,会通过一个特殊的宏定义来进行属性值设置,并在一些场合判断该值是否为某些特殊场景的设置值,实际定义共有这些类型:
其中,
- DEFAULT,表示对应配置项为JVM默认设置;
- ERGO,即
Ergonomics
选项,表示JVM为了提供更好的用户体验而自动选择的设置; - CMDLINE,表示通过命令行参数传递给JVM的配置;
- MGMT,表示通过某种管理接口或工具进行设置的选项,例如
JMX
。
以上四种类型,字面上最不好理解的应该就是Ergonomics
。
Ergonomics的说明,可以参考HotSpot Virtual Machine Garbage Collection Tuning Guide,JDK8
和JDK11
的说明中略有不同,大体思路一致,目的是在更少的命令行配置和调试下,以不同平台下的默认配置来满足不同场景下应用需求。
1 |
|
其内容主要包含三大块,不作细讲,有兴趣可以看上面的官方原文,并不长。
大概的内容主要是以下两个部分:
(此处以JDK11
的Ergonomics
为例,原文还有一个part讲调优策略,就不展开了。)
- 垃圾收集器、堆及运行时编译器的默认选项
- 基于行为的调优策略
核心围绕两个目标:最大暂停时间、吞吐量。
一般情况下,满足了最大暂停时间,意味着更多次的GC,整体暂停时间会增加,带来的结果的一定吞吐量的下降;满足了吞吐量,意味着最大化GC的收益,单次GC可能会变成,但是整体暂停时间会减少。
这两个目标的值与MaxGCPauseMillis
、GCTimeRatio
这两个配置有关,JVM会在满足了其中一个倾向达成的目标后,尝试最大化的达成另一个目标。两个目标要同时达成不容易,如果最大暂停时间及吞吐量目标均已满足,则垃圾收集器会减小堆大小,知道两个目标中的一个不满足为止。