Garbage First(简称 G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。G1 收集器是一款主要面向服务端应用的垃圾收集器,在 JDK 9 发布时,G1 收集器取代了 Parallel Scavenge 加 Parallel Old 的组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落至被声明为不推荐使用的收集器。可通过 -XX:+UseG1GC 参数启用 G1 收集器。

Mixed GC

G1 收集器作为 CMS 收集器的替代者,设计者们希望做出一款能够建立起停顿时间模型(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。

为了这个目标,首先要有一个思想上的改变。在 G1 收集器出现前的所有其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个 Java 堆(Full GC)。而 G1 收集器跳出了这个限制,它可以面向堆内存任何部分来进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

Mixed GC 就是在一次垃圾收集过程中,所有年轻代分区以及一部分老年代分区将会被回收。选择哪些分区是基于有多少空间可以被释放以及 G1 暂停时间目标。

内存布局

G1 收集器开创了基于 Region 的堆内存布局。虽然 G1 收集器也遵循了分代收集理论,但其堆内存的布局与其他收集器有非常明显的差异:G1 收集器把连续的 Java 堆划分为多个大小相等的独立 Region,取值范围为 1~32 MB,分区大小依据堆的尺寸而改变,但必须为 2 的 N 次幂,JVM 会尽量把堆划分为 2048 个左右、同等大小的 Region。此外,我们还可以通过 -XX:G1HeapRegionSize 参数进行设置。

每个分区都有一个关联的 记忆集(Remembered Set)用来记录跟踪分区外指向分区内的引用,这样就避免了对整个堆的扫描,使得各个分区的 GC 更加独立。RSet 总体大小有限,但也不容忽视,因此分区数量对 HotSpot 的内存空间占用有直接影响。RSet 最少时大概会占用 1% 左右的堆空间,最多时可能会达到 20%。

虽然 G1 收集器仍然保留新生代和老年代的概念,但新生代和老年代不是固定的了。年轻代就是一系列(不需要连续)的内存分区,这意味着不用再要求年轻代是一个连续的内存块。类似地,老年代同样也是由一系列的分区组成。这样也就不需要在 JVM 运行时考虑哪些分区是老年代,哪些是年轻代。事实上,G1 通常的运行状态是映射 G1 分区的虚拟内存随着时间推移在不同的代之间前后切换。
image.png
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 收集器认为只要一个对象的大小超过了一个 Region 容量一半的对象即可判定为大对象。对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 收集器会把其作为老年代的一部分来看待。

收集器能够对扮演不同角色的 Region 采用不同的策略去进行垃圾收集。G1 从整体上实际可看作是标记-整理算法,但在 Region 之间采用的是复制算法,这样可以有效地避免内存碎片,尤其是当堆非常大的时候,G1 的优势就会更加明显。

技术挑战

G1 收集器将堆内存化整为零的思路虽然不难理解,但其中的实现细节可是远远没有想象中那么简单,至少有以下这些关键的细节问题需要妥善解决:

1. 跨 Region 引用对象如何解决?

解决的思路我们已经知道:使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1 收集器的记忆集通过哈希表来实现,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号。这种双向的卡表结构(卡表是我指向谁,这种结构还记录了谁指向我)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1 收集器至少要耗费大约相当于 Java 堆容量的 10% 至 20% 的额外内存来维持收集器工作。
image.png

2. 并发标记阶段如何保证不干扰用户线程?

这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法前面已经提到过:G1 收集器是通过 原始快照(SATB)算法来实现的。SATB 算法创建了一个对象图,它是堆的一个逻辑快照,确保并发阶段开始时所有垃圾对象都能通过快照被鉴别出来。

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 收集器为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与 CMS 中的 “Concurrent Mode Failure” 失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 STW。

3. 如何建立可靠的停顿预测模型?

用户通过 -XX:MaxGCPauseMillis 参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但 G1 收集器要怎么做才能满足用户的期望呢?G1 收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础。

在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的衰减均值是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减均值更准确地代表最近的平均状态。换句话说,Region 的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。

垃圾收集过程

1. Young GC

G1 中的年轻代由两部分组成:指定的 Eden 分区和指定的 Survivor 分区。当 JVM 从 Eden 分区中分配内存失败,即 Eden 分区已经被完全占满时会触发一次年轻代收集。

G1 的 YGC 只负责清理新生代 Region。YGC 首先会把所有存活对象从 Eden 区复制到 Survivor 分区中,也会将一些已经达到了晋升阈值的存活对象晋升到老年代分区中,在这之后新生代所有存活对象都被移动到 Survivor
Region 或者晋升到 Old Region 中,之前的 Eden 空间可以被回收。另外,YGC 复制算法相当于做了一次堆碎片的清理工作,如整理 Eden Region 可能存在的碎片。

2. Mixed GC

在每次年轻代收集过程中,存活对象超过指定阈值(-XX:MaxTenuringThreshold,默认 15)后就会被晋升到老年代中。随着越来越多的对象被晋升到老年代或巨型对象被分配到巨型分区中,老年代和 Java 堆空间的占用也将越来越多。为避免耗尽堆的空间,JVM 需要启动一个混合的垃圾收集,它在覆盖年轻代分区的同时还覆盖了一部分老年代分区。

为了识别出垃圾最多的老年代分区,G1 GC 会发起一个全局的并发标记周期,在这个过程中,GC 将对根进行标记,识别出所有的存活对象,同时计算每个分区的活跃度。在一次混合收集中,G1 垃圾收集器不光会收集所有年轻代的分区,同时还会收集一部分回收收益较高的老年代分区,这样那些垃圾最多的分区就会被回收掉了。

相比于 YGC,Mixed GC 增加了全局并发标记过程,它能够回收部分 Old Region,不会再出现在 CMS GC 中老年代出现的碎片化问题,因为当老年代被加入回收集(CSet)后,G1 会使用和 YGC 一样的复制算法整理空间。Mixed GC 的回收过程大致可划分为以下四个步骤:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外停顿。


  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行,可通过 -XX:ConcGCThreads 设置并发数。当对象图扫描完成后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。


  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。


  • 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

image.png
从上述阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。

此外,G1 提供了 -XX:InitiatingHeapOccupancyPercent 参数用来指定当整个堆使用率达到多少时触发并发标记周期的执行,默认值是 45,即当整个堆占用率达到 45% 时,执行并发标记周期。该参数一旦设置,G1 收集器就不会试图改变这个值,来满足 MaxGCPauseMillis 的目标。因此如果该参数值设置偏大,会导致并发周期迟迟得不到启动,那么引起 Full GC 的可能性也大大增加;反之,设置的过小会使得并发周期非常频繁,大量 GC 线程抢占 CPU 会导致应用程序的性能有所下降。

3. Full GC

在设计 G1 时会极力避免 Full GC,但是总有一些特殊情况,如果当前并发回收的速度跟不上对象分配的速度,那么需要 G1 启动后备方案进行 FGC。

早期 G1 的 FGC 使用单线程的标记整理算法,后来为了充分发挥多核处理器的优势,为 G1 的 FGC 设计了多线程标记整理算法,每个步骤会提交任务给线程池,尽量减少 STW 时间。此时多线程的 FGC 的线程数可以由 -XX:ParallelGCThreads 参数控制。触发 FGC 的场景有很多,举例如下:

  • Mixed GC 中如果老年代回收的速度小于对象分配或晋升的速度,会触发 FGC
  • YGC 最后会移动存活对象到其他分区,如果此时发现没有能容纳存活对象的 Region 则会触发 FGC
  • 如果没有足够的 Region 容须下 Humongous 对象,会触发 FGC
  • 应用程序调用 System.gc() 也会触发 FGC

由于 FGC 的全局 STW 性,如果频繁发生 FGC 是比较糟糕的信号,它暗示应用程序的特性与当前的 G1 参数配置不能良好契合,需要开发者找到问题并进一步调优处理。

优缺点

与 CMS 收集器的标记-清除算法不同,G1 收集器从整体来看是基于标记-整理算法实现的,但从局部(比如在两个 Region 之间)上看又是基于标记-复制算法实现,但无论如何,这两种算法都意味着 G1 收集器在运作期间不会产生内存空间碎片,垃圾收集完成后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过 G1 的弱项也可以列举出不少,就内存占用来说,虽然两者都使用卡表来处理跨代指针,但 G1 收集器的卡表实现更为复杂,而且堆中的每个 Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致 G1 收集器的记忆集会占用较多的的内存空间;相比起来 CMS 收集器的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要。由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS 用写后屏障来更新维护卡表;而 G1 除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免 CMS 那样在最终标记阶段停顿时间过长的缺点,但同时要比 CMS 消耗更多的运算资源。

字符串去重

通常我们在分析堆转储快照时,会观察到 Java 堆中占比最大的通常是一些 byte[] 对象,这些 byte[] 对象又通常是 String 的成员,即字符串对象在 Java 堆中是占据极大比重的,如果能发现重复的字符串并消除它们,会节省很大一部分内存。可以手动调用 String.intern() 消除重复的字符串,但这需要开发者了解哪些字符串可能发生重复,我们也可以使用 G1 的新特性自动完成字符串去重。

G1 的 YGC 和 FGC 都可以触发字符串去重,只需要开启 -XX:+UseStringDeduplication,G1 如果发现开启了自动去重选项,则会自动去发现可以去重的字符串。