Java G1 GC学习

Posted by keys961 on November 9, 2019

1. 背景

最近给代码重构,压测的时候,没有配置GC的相关参数,结果发生了比较严重的Full GC停顿现象。

为了解决这个问题,我dump了一份堆文件,并使用jmap查看堆内存的实际占用,发现了一些现象:

  • 大部分的内存都被字符串占去,即Stringchar[](目前是JDK 8)
  • 分析得到的活跃对象很少

这提示了我:程序实际没有内存泄漏,只是GC配置错了

实际上,压测的时候,默认的GC是Parallel GC(ParNew + Parallel Old)。

总所周知,Java GC是分代的。当压力上来时,产生大量的字符串,有的字符串:

  • 可能因为年轻代空间不足(如Eden区不足、GC后Survivor区不足),而直接进入老年代
  • 可能因为多次新生代Minor GC后,到达一定年龄,进入老年代

因此会有大量字符串进入老年代,在Minor GC时无法得到回收,只能等到Full GC时才能被回收(而Parallel Old会导致STW)。

02_1_HeapStructure_CN.png

因此,我将GC算法换成G1(也是JDK 9以后的默认GC算法),问题得到解决,程序在高压测试下没有出现不可接收的停顿,GC回收占用的CPU比例也在0.5%以下。

G1是面向服务器的GC,不仅能高概率满足停顿要求,而且也具备高吞吐的特征。主要特征为:

  • 和CMS一样,GC能和应用线程并发执行
  • 内存区间紧凑
  • 可预测的GC停顿时间,且不长
  • 不会牺牲太多吞吐量

G1目标用于取代CMS,它相比CMS有下面的优势:

  • G1能整理内存分配,处理和解决内存碎片问题
  • G1的停顿时间更易估计,允许设置停顿时长限制

不过说明G1前,首先回顾CMS,然后再说其替换者G1。

2. 回顾:CMS

CMS用于老年代的回收,其堆内存的布局也和上图一样。它绝大部分GC工作都能和应用线程一起并发执行,以最小化暂停时间。但它不会进行内存整理,因此会有碎片问题。

年轻代通常还是Parallel GC的ParNew

下面说明一下CMS的具体步骤。

2.1. 初始标记

这一步会STW。

这一步会将存活的老年代对象进行”标记“,包括可以从年轻代对象里可达的。这一步停顿时间大约在一次Minor GC时长。

2.2. 并发标记

这一步,GC线程和应用线程一起执行,从第1步标记的对象开始,遍历整个老年代,构建一个可达对象图。

CMS还有一个调整器(Mutator),它在GC的第2、3、5步执行,任何在老年代被分配的对象(包括被提升成老年代的对象)都会被标记成”可达“/”存活“的。

2.3. 重新标记

这一步也会STW。

这一步是为了修正第2步的构建结果,对标记进行更新,避免遗漏掉对象。

2.4. 并发清理

这一步,GC也可以和应用线程一起执行。

它根据前3步得到的可达对象图,得到不可达的对象。这些不可达的对象(即”死掉“的对象)会被回收,具体的方法是将其分配到的空间重新添加到free list中。

而可达的对象是不会拷贝/移动,因此这里不可避免地产生碎片问题。

Parallel Old会整理整个堆

2.5. 重置

这一步进行一些数据结构的清理,准备下一轮的回收。

3. G1垃圾回收

3.1. 概述

G1回收的方式和CMS相似,不过也有一些不一样的地方:

  • 内存分配结构不同,G1基于等大小的分区,回收时优先回收垃圾多的分区
  • G1使用暂停预测模型来达到用户定义的暂停时间,并决定回收哪些分区,以及回收的数量
  • G1回收分区时,使用”转移“的方式回收,并整理内存碎片(可参考Survivor区的回收,很快)

3.2. G1的堆内存结构

不同于CMS和其它以前的GC,G1使用了不同的堆内存分配策略:

  • 它依旧对堆进行分代
  • 它将整个堆分成多个大小固定的区域,官方叫Region,范围可以是1~32MB
  • 它对巨型对象(对象超过分区一半大小)单独分区管理,直接分属老年代,且分区连续

02_2_G1HeapAllocation_CN.png

3.3. G1的内存占用(Footprint)

G1可能比Parallel Old或CMS更吃内存,这是因为G1需要维护额外的accounting数据结构,如:

  • Remember Sets (RSets):每个分区都维护一个,记录并跟踪其它Region指向该Region中对象的引用(影响小于5%)

    每个Region会被划分成多个Card。

    而RSet是一个散列表,键是其它Region的地址,值是该Region上的对象(持有本Region引用)所在的Card Index集合。

    例子:若Region 1的对象A和对象B,引用了Region 2的对象C,对象AB在第1和第2个Card上,那么Region是的RSet的值是:{ region_1: [1, 2] }

  • Collection Sets (CSets):一个集合,记录了需要执行回收的分区。GC时,只需遍历CSets里记录的分区的RSets,就能构建出跨分区的引用关系图

3.4. 年轻代GC

年轻代GC和之前的很像,它将存活的对象(Eden + Survivor)”转移“到Survivor区(这个区原本是未分配的,转移/拷贝完,将其映射成Survivor),”转移“即带来一次拷贝,也带来一次整理。若年龄超过阈值,则将对象转移到老年代。

年轻代GC使用多线程进行回收,不过它会STW。

另外,G1和其它算法不同的是,它会利用统计信息和配置信息,动态调整Eden区和Survivor区的大小,从而达到可控停顿和不牺牲吞吐的要求。

总结

  • 年轻代GC使用”转移“/拷贝的方式回收,将年轻代对象拷贝到Survivor/老年代,不会产生碎片
  • 年轻代GC使用多线程回收
  • 年轻代GC会STW
  • 年轻代GC可动态调整Eden和Survivor区的大小,以满足性能需要

3.5. 老年代GC

G1老年代的回收和CMS非常相似。

a) 初始化标记

这一步会STW,且它会稍带在一次年轻代GC里,日志会打 GC pause (young)(inital-mark)

这一步用于标记有哪些年轻代的分区引用了老年代对象。这些分区被称为根分区(Root Region)

由于该步骤在年轻代GC中稍带,因此只需扫描Survivor区。

b) 扫描根分区

这一步可以和应用线程一起运行。

该步骤从根分区(第一步找到的)开始,扫描这些分区中引用的老年代对象

注意,老年代GC可能伴随着年轻代GC,这一步必须在年轻代GC发生前完成。

c) 并发标记

这一步也可以和应用线程一起运行。

和CMS类似,它使用多线程扫描整个堆中存活的对象,构建可达对象图。

注意,这一步可被年轻代GC打断。

d) 再次标记

这一步会STW。

也和CMS类似,它用于修正第3步的结果,完成对堆内存活对象的标记。不过,这一步的修正使用了SATB算法,比CMS快非常多,具体可参考一些论文。

e) 拷贝(转移)和清理

拷贝(转移)就是执行回收内存的操作,即使用”转移“/拷贝的算法,把活跃对象拷贝到一个空闲分区中(清掉原分区放置在清理步骤进行)。这类方法整理了内存,不会产生碎片问题。

拷贝本身是多线程执行的,但是它会STW。

拷贝(转移)可同时对年轻代和老年代进行回收。若拷贝本身只来源于年轻代,日志会打[GC pause (young)];若拷贝涉及到老年代,那么日志会打[GC pause (mixed)]

G1会优先选择”活跃度“最低的几个区域进行回收,因为这类区域回收速度往往最快。

而活跃度会在程序运行时就会被计算出来。

而清理主要分为3步:

  • 执行一些统计操作,如活跃对象、完全空闲分区等等(STW)
  • 擦写RSets(STW)
  • 重置空分区,并将其归还到free-list(可和应用线程并发执行)

4. 关于G1的Best Practice

4.1. 不显式设置年轻代大小

不要通过-Xmn -XX:NewRatio显式指定年轻代大小。

因为G1在年轻代GC时,会通过统计信息动态调整年轻代大小,显式指定反而会干扰G1的默认行为,包括:

  • G1不再关心暂停时间指标
  • G1不会动态调整年轻代空间大小

4.2. 注意停顿时间指标值

可通过XX:MaxGCPauseMillis设置停顿时间的指标值,但是这个指标值不应该设置成平均响应时间,而最好设置成90%或更大的目标时间。

另外,这个暂停时间只是一个目标,并不能完全满足。

4.3. 避免拷贝/转移失败

G1的内存回收/对象晋升都是用拷贝算法,因此必须要有空闲的分区。

当没有空闲分区,且堆内存不能扩大(被限制死),那么日志会报如to-space overflowevacuation failure, to-space exhausted, promotion failure等字眼。最终,G1往往会回退触发一次Full GC(在JDK9及以前是单线程,10及以后是多线程),这会STW,开销非常大。

实际上,G1本身是不包含Full GC,只有young和mixed GC,它们的停顿都是可控的,只有在这2个GC都失败情况下,才会回退触发Full GC。

为了避免这类事情的出现,可以考虑:

  • 增加堆内存大小,如
    • 增加-Xmx的配置大小
    • 增加-XX:G1ReservePercent值,默认10,该设置作为空闲空间的预留内存百分比,增加或减少百分比时,请确保对总的 Java 堆调整相同的量
  • 更早地启动标记周期,如配置-XX:InitiatingHeapOccupancyPercent到更低的值(即堆达到某个更低的使用率,触发一次标记周期)
  • 增加标记线程的数量,可通过-XX:ConcGCThreads配置

若莫名奇妙发生Full GC,那么可以怀疑是巨型对象造成的,可通过dump文件来分析。

5. 总结与比较

比较:

  • Parallel GC: Parallel GC能整理空间,但是它把老年代当成整体看待;而G1通过分区,将整理和收集任务分散。G1在收集老年代时,有更低的停顿,吞吐也降低较少。
  • CMS: CMS和G1的老年代收集步骤很类似,但CMS不整理内存,有碎片问题,可能导致未来的Full GC,而G1不会出现。
  • G1由于是并发执行且需要维护更多的统计数据结构,开销可能比其它收集器来的更大,对吞吐量影响也会更大。

总之,G1为用户的应用程序的提供一个低GC延时和大内存GC的解决方案,并且在下面特征中,使用G1能带来很好的效果:

  • Full GC时间很长,或频繁
  • 对象分配很频繁,或对象升级到老年代很频繁(第1节的例子就是这个)
  • 期望有可控的GC暂停时间

不过使用CMS或Parallel GC没出现长时间GC,程序运行很顺畅,那么没必要切换到G1,正如上面所述,G1的特性会带来额外的开销。