JVM堆大小的自适应能力

Published: 07 Nov 2014 Category: gc

在完善我们的测试台以便提高Plumbr排查GC故障能力的时候,我编写了一个小小的测试用例,我觉得应该会有不少人对它感兴趣。我的目标是测试JVM在不同的伊甸区(Eden), 存活区(Survivor)以及年老代空间的分配情况下的自适应能力。

这个测试用例就是在批量地生成对象。每秒会批量生成一批,每批大概是500KB的大小。这些对象的生命周期是5秒钟,之后它们的引用会被删除掉,然后就可以进行垃圾回收了。

本次测试是运行在Mac OS X的Oracle Hotspot 7 JVM上的,使用的是ParallelGC策略,堆的大小是30M。知道了运行的平台之后,我们可以断定出JVM会按下面的堆配置进行启动:

  • 年轻代大小10M,年老代20M,由于没有显式地指定堆的分配比例,JVM默认会按1:2的比例来划分年轻代和年老代的堆空间。
  • 在我的Mac OS X上,10MB的年轻代又会进一步划分为伊甸区和两个存活区,分别是8MB和2*1MB。再强调一遍,这些默认值都是和特定平台相关的。

启动测试用例并通过jstat查看了GC的详细情况后,事实证明我们刚才的预测是正确的:

My Precious:gc-pressure me$ jstat -gc 2533 1s
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
.0 1024.0  0.0    0.0    8192.0   5154.4   20480.0      0.0     21504.0 2718.9      0    0.000   0      0.000    0.000
.0 1024.0  0.0    0.0    8192.0   5502.1   20480.0      0.0     21504.0 2720.1      0    0.000   0      0.000    0.000
.0 1024.0  0.0    0.0    8192.0   6197.5   20480.0      0.0     21504.0 2721.0      0    0.000   0      0.000    0.000
.0 1024.0  0.0    0.0    8192.0   6545.2   20480.0      0.0     21504.0 2721.2      0    0.000   0      0.000    0.000
.0 1024.0  0.0    0.0    8192.0   7066.8   20480.0      0.0     21504.0 2721.6      0    0.000   0      0.000    0.000
.0 1024.0  0.0    0.0    8192.0   7588.3   20480.0      0.0     21504.0 2722.1      0    0.000   0      0.000    0.000

现在我们还可以继续预测一下接下来会发生什么:

  • 伊甸区的8MB会在16秒内填充满——记住,我们每秒会生成500KB的对象。
  • 任何时刻都会有正好2.5MB的存活对象——每秒生成500KB并持有这些对象5秒钟,最后就是这个数
  • 伊甸区占满的时候触发年轻代GC(Minor GC)——也就是说每16秒会出现一次年轻代的GC
  • 年轻代GC后,会出现过早提升(Premature promotion,注:这些对象过早地提升到了年老代)——存活区只有1MB的大小,而存活对象有2.5MB,1MB的存活区无法容纳这些对象。因此唯一的途径就是将存活区存放不下的1.5MB(2.5MB-1MB)的对象移动到年老代里。

查看下日志可以证实我的这个猜想:

My Precious:gc-pressure me$ jstat -gc -t 2575 1s
Time   S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
.6 1024.0 1024.0  0.0    0.0    8192.0   4117.9   20480.0      0.0     21504.0 2718.4      0    0.000   0      0.000    0.000
.6 1024.0 1024.0  0.0    0.0    8192.0   4639.4   20480.0      0.0     21504.0 2718.7      0    0.000   0      0.000    0.000
    ... cut for brevity ...
.7 1024.0 1024.0  0.0    0.0    8192.0   8192.0   20480.0      0.0     21504.0 2723.6      0    0.000   0      0.000    0.000
.6 1024.0 1024.0  0.0   1008.0  8192.0   963.4    20480.0     1858.7   21504.0 2726.5      1    0.003   0      0.000    0.003
.7 1024.0 1024.0  0.0   1008.0  8192.0   1475.6   20480.0     1858.7   21504.0 2728.4      1    0.003   0      0.000    0.003
    ... cut for brevity ...
.7 1024.0 1024.0  0.0   1008.0  8192.0   8163.4   20480.0     1858.7   21504.0 2732.3      1    0.003   0      0.000    0.003
.7 1024.0 1024.0 1008.0  0.0    8192.0   343.3    20480.0     3541.3   21504.0 2733.0      2    0.005   0      0.000    0.005
.8 1024.0 1024.0 1008.0  0.0    8192.0   952.1    20480.0     3541.3   21504.0 2733.0      2    0.005   0      0.000    0.005
    ... cut for brevity ...
.8 1024.0 1024.0 1008.0  0.0    8192.0   8013.5   20480.0     3541.3   21504.0 2745.5      2    0.005   0      0.000    0.005
.8 1024.0 1024.0  0.0   1024.0  8192.0   413.4    20480.0     5201.9   21504.0 2745.5      3    0.008   0      0.000    0.008
.8 1024.0 1024.0  0.0   1024.0  8192.0   961.3    20480.0     5201.9   21504.0 2745.5      3    0.008   0  

触发垃圾回收触发的时间是15秒左右,而不是16秒,它会清理掉伊甸区并将大约1MB的对象移动到存活区,同时将剩余的那些对象移动到年老代。

目前为止一切都和我们猜测的一样。JVM的确是按我们所理解的方式来运行的。有趣的是一旦JVM监控到GC的行为并知道发生的情况之后。在我们这个测试用例中,这个发生在90秒左右的时候:

My Precious:gc-pressure me$ jstat -gc -t 2575 1s
Time   S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
.0 1024.0 1024.0  0.0   1024.0  8192.0   8036.8   20480.0     8497.0   21504.0 2748.8      5    0.012   0      0.000    0.012
.0 1024.0 3072.0 1024.0  0.0    4096.0   353.3    20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   836.6    20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   1350.0   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   1883.5   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   2366.8   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   2890.2   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   3383.7   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 1024.0 3072.0 1024.0  0.0    4096.0   3909.7   20480.0    10149.6   21504.0 2748.8      6    0.014   0      0.000    0.014
.0 3072.0 3072.0  0.0   2720.0  4096.0   323.0    20480.0    10269.6   21504.0 2748.9      7    0.016   0      0.000    0.016

我们可以看到JVM的强大的适能力。一旦了解了应用的行为之后,JVM会将存活区调整为足够容纳下所有的存活对象。现在年轻代的配置变成了这样:

  • 伊甸区4MB
  • 存活区每个3MB

调整之后GC的频率会有所提高——伊甸区现在只有原来的一半了,因此现在是每8秒一次GC而不是原来的16秒。不过好处也是显而易见的,现在的存活区已经足以存放所有的存活对象了。考虑到已经没有对象能活过一次年轻代GC的周期(不过还得记住,任何时刻都仍有2.5MB的存活对象),因此也不用再将对象提升到年老代中了。

继续观察JVM后我们会发现年老代的使用率在调整后会一直保持不变。对象不会再移动到年老代中了,不过由于也不会再触发年老代GC,所以在JVM调整前提升进来的那10MB的垃圾对象就会一直存放在年老代里面了。

你也可以把JVM的这个“神奇的自适应性”的功能给屏蔽掉,如果你确定要这么做的话。在JVM的参数中指定-XX-UseAdaptiveSizingPolicy就会让JVM严格遵循启动时所指定的参数,而不会违背你的旨意。请谨慎使用这一选项,现代的JVM通常都能自动地给你预测出恰当的配置。

英文原文链接