啥?64KB?JVM老年代大小之谜

问题很简单,JVM源码捡起来再看看~

每天凌晨我们会对集群中资源使用率较低的Pod进行配额调整,此时导致Pod重启,由于在凌晨没有多少业务流量,滚动更新对业务的影响也比较小。

但是,
这一天,
业务反馈出问题了。

问题现象

业务同学反馈,某个服务的部分容器异常,一直在重启,看了一下重启容器中JVM监控,发现一直在进行FullGC

于是也同步分析了一下GC信息,就看到了下面的结果。

看完就愣住了,老年代64KB是什么鬼。

问题分析

看到这里,首先想到的是看看启动时是不是配置的JVM参数不对。

启动时配置了xmsxmx,都是1GB。

但是结合前面的图就好像不对劲,GC日志分析的结果显示年轻代和老年大大小都对不上,于是实际登上容器jmap了一把。

那么问题来了,xmx配置了1GB,老年代+年轻代的大小总和也确实是1GB,但是两者的大小比例并不是我们熟知的2:1(NewRatio),也就意味着肯定还有地方配置了年轻代或老年代大小相关的参数。

于是顺手看一下进程启动的完整命令,就找到了问题点。

看上去在启动命令中,还有其他地方配置了xmsxmxxmn,而在最终执行的过程中,两次xmsxmx的配置会以后者为主,所以实际上的配置应该是:

1
1
-Xms1024M -Xmx1024M -Xmn1500M

那么问题来了,这种情况下的堆大小应该没有疑问的1024MB,但是年轻代和老年代大小是如何计算的呢?

年轻代取1500M,那不是超过了堆最大值?
年轻代如果不取1500M,到底应该设置多少?
那老年代会有多少空间?

要了解这些问题,需要我们顺着分代初始化的逻辑,一步一步透过源码来看看。

JVM堆分代初始化

分代初始化顺序

以常见的CMS为例(主要是其它的GC实现偷懒没细看,大体偏差不会太大,使用方式上略有不同),默认两分代的代码调用及分代初始化顺序如下。

整体流程上,
(1)Universe初始化JVM时,对堆进行初始化,调用不同GC策略的初始化过程;
(2)初始化过程中,initialize_flags逐级优先向上调用,初始化HeapSizeNewSizeOldSize的配置(最大、最小、当前值);
(3)再通过initialize_size_info逐级优先向上调用,初始化Heapgen0gen1的实际大小。

1
这里的分代初始化顺序,单个分代的flag、size初始化顺序都很重要,直接影响最终的大小

看完整体的分代初始化顺序,我们单个分代逐个细看。

年轻代大小初始化

年轻代的初始化过程,从全局角度看其实分为三个阶段。

(1)基于用户配置读取并设置年轻代相关的配置(比如xmn),包含NewSizeMaxNewSize等;
(2)通过GenCollectorPolicy::initialize_flags(),重新计算年轻代大小的配置(NewSizeMaxNewSize),优先以用户配置为准,如无用户配置,则根据默认规则(NewRatio)进行大小配置的计算;
(3)通过GenCollectorPolicy::initialize_size_info(),最终计算年轻大的初始化大小(_min_gen0_size_initial_gen0_size_max_gen0_size)。

综上来看,年轻代的大小与xmnNewRatioNewSize、最大堆大小等都可能有关,最终完成年轻代的初始化值、最大/最小值的初始化。

年轻代最大值,优先以自定义的xmn为主,未设置时则以NewRatiomaxHeapSize来设置。
年轻代初始化值,分为两种情况,若xms=xmn,则年轻大的最大、最小和初始化值均计算出的max_new_size;若xmsxmn,则优先以NewSize进行初始化,否则根据堆大小及NewRatio按不同的情况进行配置。

老年代大小初始化

老年代初始化的逻辑与新生代差不多,不再赘述。

唯一有所不同的是,由于初始化老年代大小时,整体堆大小及新生代大小已经初始化完,如果老年代有通过启动参数OldSize手动指定老年代大小,则会进行一轮大小适配,避免堆及分代大小之前的冲突。

问题分析

回到问题本身,通过上面的内容,我们可以知道JVM堆内存初始化的顺序是heap_sizegen0_sizegen1_size,结合前面讨论的生效配置,我们逐步来看。

1
1
-Xms1024M -Xmx1024M -Xmn1500M

heap_size

首先初始化heap_size,按配置看来,heap的初始化、最大/最小值均为1024MB

gen0_size

其次是年轻代大小,按自定义配置为1500MB,但由于该大小已经超过heap_size,这种情况会怎么处理呢?

可以看看GenCollectorPolicy::initialize_size_info()中的处理过程~


MaxNewSize非默认值时,以MaxNewSize作为年轻代最大大小,且此时由于xms = xmx,所以init_gen0_sizemin_gen0_sizemax_gen0_size都会设置为MaxNewSize

MaxNewSize在哪配置的呢?答案在GenCollectorPolicy::initialize_flags()

综上来看,最终init_gen0_sizemin_gen0_sizemax_gen0_size最终都会设置为max_heap_size - {gen_aligenment}

gen1_size

那么问题来了,堆大小、年轻代大小都初始化完了,那老年代呢?

看看TwoGenerationCollectorPolicy::initialize_size_info()中是如何设置的。

此时老年代的初始化及最小值,与堆的初始化及最小值有关,代码转换后的逻辑,简单理解就是:

  1. heap_size足够大时,以heap_size - gen0_size作为gen1_size
  2. heap_size不够大时,至少保证gen1_size有一个分代对齐的大小{gen_alignment}

在我们的场景中会走到分支二,即gen1_size = {gen_alignment},最终得到下图结果,一个64KB的老年代。

1
2
3
由于在计算gen0_size时,是取了heap_size - gen_alignment,此时gen1_size又设置为了gen_alignment,所以整体堆大小保持我们配置的xmx不变。

(只有老年代受伤的世界达成了「手动狗头」)

问题总结

综上来看,在确定堆大小后,如果新生代配置占用了太多堆空间,那么至少会保证老年代有一个分代对齐大小,即极端情况下的最小老年代大小,因此最终老年代大小只有64KB

一些关联的有趣问题

分代对齐大小

可以看待源码中,很多地方用到了gen_alignment做内存对齐,并在极端情况下,保证老年代大小不低于gen_alignment

关于gen_alignment的值,通过上文我们可以推测是64KB,那么它是在哪设置的呢?

兜兜转转是个圈,我们回到Universe中的initialize_heap()CMS垃圾收集器会调用initialize_all(),该函数如下:

所以在最初Universe初始化JVM堆及GC时,首先初始化了分代对齐大小,再更新flagsize

gen_alignment的设置过程如下:
(分别在concurrentMarkSweepGeneration.hppgeneration.hpp中)


在非ARM平台下,GenGrain2^16,即64KB

1
2
重点!!!
以上内容为CMS中的实现,其他垃圾收集器可能不同,在Universe初始化堆的实现也拆分出了不同垃圾收集器的初始化逻辑。

Ergonomics

算是一个不算题外话的题外话 - Ergonomics

在设置大小的时候,经常会看到这样的配置:


在设置时,会通过一个特殊的宏定义来进行属性值设置,并在一些场合判断该值是否为某些特殊场景的设置值,实际定义共有这些类型:

其中,

  • DEFAULT,表示对应配置项为JVM默认设置;
  • ERGO,即Ergonomics选项,表示JVM为了提供更好的用户体验而自动选择的设置;
  • CMDLINE,表示通过命令行参数传递给JVM的配置;
  • MGMT,表示通过某种管理接口或工具进行设置的选项,例如JMX

以上四种类型,字面上最不好理解的应该就是Ergonomics

Ergonomics的说明,可以参考HotSpot Virtual Machine Garbage Collection Tuning GuideJDK8JDK11的说明中略有不同,大体思路一致,目的是在更少的命令行配置和调试下,以不同平台下的默认配置来满足不同场景下应用需求。

1
Ergonomics是JVM和垃圾回收调优时的一种过程,用于提升应用性能,其提供与平台相关的垃圾收集器、堆大小、运行时编译器的默认配置。

其内容主要包含三大块,不作细讲,有兴趣可以看上面的官方原文,并不长。

大概的内容主要是以下两个部分:
(此处以JDK11Ergonomics为例,原文还有一个part讲调优策略,就不展开了。)

  • 垃圾收集器、堆及运行时编译器的默认选项

  • 基于行为的调优策略

核心围绕两个目标:最大暂停时间、吞吐量。

一般情况下,满足了最大暂停时间,意味着更多次的GC,整体暂停时间会增加,带来的结果的一定吞吐量的下降;满足了吞吐量,意味着最大化GC的收益,单次GC可能会变成,但是整体暂停时间会减少。

这两个目标的值与MaxGCPauseMillisGCTimeRatio这两个配置有关,JVM会在满足了其中一个倾向达成的目标后,尝试最大化的达成另一个目标。两个目标要同时达成不容易,如果最大暂停时间及吞吐量目标均已满足,则垃圾收集器会减小堆大小,知道两个目标中的一个不满足为止。