写在前面

本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和文献引用请见100个问题搞定Java虚拟机

解答

在这里插入图片描述

G1(Garbage First)是一个横跨新生代和老年代的垃圾收集器。
实际上,它已经打乱了新生代和老年代的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。
它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。
G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。

补充

G1 GC的内部结构

从内存区域的角度,G1 同样存在着年代的概念,但是与前面在堆内存划分中讲的很不一样,其内部是类似棋盘状的一个个 region 组成,请参考下面的示意图。

G1-GC的内部结构

region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右、同等大小的 region。

当然这个数字既可以手动调整,G1 也会根据堆大小自动进行调整。

在 G1 实现中,年代是个逻辑概念,具体体现在,一部分 region 是作为 Eden,一部分作为 Survivor。

除了Old region,G1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。

逻辑上,Humongous region 算是老年代的一部分,因为复制这样的大对象是很昂贵的操作,并不适合新生代 GC 的复制算法。

region 设计有什么缺陷?

region 大小和大对象很难保证一致,这会导致空间的浪费。

上面示意图中有的区域是 Humongous 颜色,但没有用名称标记,这是为了表示,特别大的对象是可能占用超过一个 region 的。

并且,region 太小不合适,会令你在分配大对象时更难找到连续空间,这是一个长久存在的情况。

这本质也可以看作是 JVM 的 bug,尽管解决办法也非常简单,直接设置较大的 region 大小,参数如下:

-XX:G1HeapRegionSize=<N, 例如16>M

垃圾回收算法

G1 选择的是复合算法,可以简化理解为:

  1. 在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
  2. 在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。

G1 GC中的分代收集

习惯上人们喜欢把新生代 GC(Young GC)叫作 Minor GC,老年代 GC 叫作 Major GC,区别于整体性的 Full GC。

但是现代 GC 中,这种概念已经不再准确,对于 G1 来说:

  1. Minor GC 仍然存在,虽然具体过程会有区别,会涉及 Remembered Set 等相关处理。
  2. 老年代回收,则是依靠 Mixed GC。并发标记结束后,JVM 就有足够的信息进行垃圾收集,Mixed GC 不仅同时会清理 Eden、Survivor 区域,而且还会清理部分 Old 区域。
    可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次 Mixed GC 中的 region 比例。
–XX:G1MixedGCLiveThresholdPercent
–XX:G1OldCSetRegionThresholdPercent

Remembered Set(记忆集)

Remembered Set,用于记录和维护 region 之间对象的引用关系

为什么需要这么做呢?

试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。

在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。

RememberedSet

G1 的很多开销都是源自 Remembered Set,例如,它通常约占用 Heap 大小的 20% 或更高,这可是非常庞大的比例。

并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。

Card Table(卡表)

前面提到的记忆集其实是一种"抽象"的数据结构,而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度,与堆内存的映射关系等。

GC 最早引入卡表的目的是为了对内存的引用关系做标记,从而根据引用关系快速遍历活跃对象。

该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。

这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。

当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。

因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。

这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

如何优化 G1 GC?

  1. 建议尽量升级到较新的 JDK 版本.

  2. 掌握 GC 调优信息收集途径。掌握尽量全面、详细、准确的信息,是各种调优的基础,不仅仅是 GC 调优。
    我们来看看打开 GC 日志。

和 G1 GC 相关的日志配置

除了常用的两个选项,

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps

还有一些非常有用的日志选项,很多特定问题的诊断都是要依赖这些选项:

-XX:+PrintAdaptiveSizePolicy // 打印G1 Ergonomics相关信息

我们知道 GC 内部一些行为是适应性的触发的,利用 PrintAdaptiveSizePolicy,我们就可以知道为什么 JVM 做出了一些可能我们不希望发生的动作。

例如,G1 调优的一个基本建议就是避免进行大量的 Humongous 对象分配,如果 Ergonomics 信息说明发生了这一点,那么就可以考虑要么增大堆的大小,要么直接将 region 大小提高。

如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。

-XX:+PrintReferenceGC

另外,建议开启选项下面的选项进行并行引用处理。

-XX:+ParallelRefProcEnabled

需要注意的一点是,JDK 9 中 JVM 和 GC 日志机构进行了重构,其实我前面提到的 PrintGCDetails 已经被标记为废弃,而 PrintGCDateStamps 已经被移除,指定它会导致 JVM 无法启动。

可以使用下面的命令查询新的配置参数。

java -Xlog:help

通用实践

  1. 如果发现 Young GC 非常耗时,这很可能就是因为新生代太大了,我们可以考虑减小新生代的最小比例。
-XX:G1NewSizePercent
  1. 降低新生代最大值同样对降低 Young GC延迟有帮助
-XX:G1MaxNewSizePercent
  1. 如果我们直接为 G1 设置较小的延迟目标值,也会起到减小新生代的效果,虽然会影响吞吐量。

如果是 Mixed GC 延迟较长,我们应该怎么做呢?

部分 Old region 会被包含进 Mixed GC,减少一次处理的 region 个数,就是一个直接的选择之一。

上面已经介绍了 G1OldCSetRegionThresholdPercent 控制其最大值,

–XX:G1OldCSetRegionThresholdPercent

还可以利用下面参数提高 Mixed GC 的个数,当前默认值是 8,Mixed GC 数量增多,意味着每次被包含的 region 减少。

-XX:G1MixedGCCountTarget

注意避免过度调优,G1 对大堆非常友好,其运行机制也需要浪费一定的空间,有时候稍微多给堆一些空间,比进行苛刻的调优更加实用。

总的来说,G1 的调优相对简单、直观,因为可以直接设定暂停时间等目标,并且其内部引入了各种智能的自适应机制。

上一篇 下一篇