Advanced-Java.03a1.G1回收器详解

G1概述

G1(garbage first)垃圾回收器是在 Java7 update 4之后引入的一个新的垃圾回收器,Java9成为默认回收器。
G1依旧是一个分代的 GC、但与 CMS 不同的是,G1是带 compacting(压缩)的、同时可以进行新生代和老年代的 GC(CMS 只能用于老年代)。

它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

G1的内存划分

CMS 时期,JVM 内存分代将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间 Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:
../_images/Advanced-Java.03a1.G1回收器-2023-06-06-1.png

G1对 JVM 内存的划分:

../_images/Advanced-Java.03a1.G1回收器-2023-06-06-3.png

  • G1依然有 Eden、Survivor、Tenured 的分代概念,但是各代存储地址是不连续的,每个分代由多个 Region 组成;
  • 一个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围从1M 到32M,且是2的指数。如果不设定,那么 G1会根据 Heap 大小自动决定(2048)
  • 每个 Region 都可以作为 Eden、Survivor、Old 分代的一部分,此外还有 Humongous 类型的 Region,这些 Region 存储的是巨大对象(humongous object,H-obj),即大小大于等于 region 一半的对象。H-obj 有如下几个特征:
    • H-obj 直接分配到了 old gen,防止了反复拷贝移动。
    • H-obj 在 global concurrent marking 阶段的 cleanup 和 full GC 阶段回收。
    • 在分配 H-obj 之前先检查是否超过 initiating heap occupancy percent 和 the marking threshold, 如果超过的话,就启动 global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。
    • 为了减少连续 H-objs 分配对 GC 的影响,需要把大对象变为普通的对象,建议增大 Region size。

RSet:解决跨代引用

全称是 Remembered Set(记忆集),作用类似 CMS 中的卡表,用来处理跨代引用的问题:

  • G1中依然使用卡页(Card,大小512B),作为管理对象分配的最小单元;
  • 每个 Region 都有一个 RSet,记录了“谁引用了本 Region 中的对象”,这个 RSet 其实是一个 Hash Table,Key 是别的 Region 的起始地址,Value 是一个集合,里面的元素是“引用了它的对象”所在的 Card Table 的 Index。
  • 比较 RSet 与 “卡表”: RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象)。而 Card Table 则是一种 points-out(我引用了谁的对象)的结构;

下图表示了 RSet、Card 和 Region 的关系(出处):

../_images/Advanced-Java.03a1.G1回收器-2023-06-06-4.png

上图中有三个 Region,每个 Region 被分成了多个 Card,在不同 Region 中的 Card 会相互引用,
Region1中的 Card 中的对象引用了 Region2中的 Card 中的对象,蓝色实线表示的就是 points-out 的关系,
而在 Region2的 RSet 中,记录了 Region1的 Card,即红色虚线表示的关系,这就是 points-into。而维系 RSet 中的引用关系靠 write barrier 和 Concurrent refinement threads 来维护:

  • 这里的 write barrier 和 CMS 中的卡表标记置脏类似,通过在“更新引用”的字节码后加一个记录,这些记录被写入缓冲区 /注意区分这个 write barrier 和 Volatile 屏障是完全不同的概念/;
  • 当缓冲区满了,write barrier 就停止服务了,会由 Concurrent refinement threads 处理这些缓冲区日志

在做YGC的时候,只需要选定 young generation region 的 RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。
而mixed gc的时候(清理全部的 young generation + 部分选定的 old generation),old generation中记录了old->old的RSet,young->old的引用由扫描全部 young generation region得到,这样也不用扫描全部 Old Region。所以RSet的引入大大减少了GC的工作量。

CSet:收集集合

Collection Set(CSet),它记录了下次 GC 要收集哪些 Region,被选入 CSet 的 Region 将会被 GC 回收,
Region 能否进入 CSet的条件,见「Mixed GC」一节。

G1的日志中也有关于 CSet 的记录:

2014-11-14T17:57:23.654+0800: 27.884: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 11534336 bytes, new threshold 15 (max 15)
- age 1: 5011600 bytes, 5011600 total

# _pending_cards是关于RSet的Card Table。predicted base time是预测的扫描card table时间
27.884: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 1461, predicted base time: 35.25 ms, remaining time: 64.75 ms, target pause time: 100.00 ms]

# 添加Region到CSet,新生代一共159个Region,13个幸存区Region
27.884: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 159 regions, survivors: 13 regions, predicted young region time: 44.09 ms]

# 完成CSet的选择,统计数据
27.884: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 159 regions, survivors: 13 regions, old: 0 regions, predicted pause time: 79.34 ms, target pause time: 100.00 ms]

GC 流程

G1提供了两种 GC 模式,Minor GC 和 Mixed GC,两种都是有 Stop The World 的。

  • Minor GC:选定所有年轻代里的 Region。通过控制年轻代的 Region 个数,即年轻代内存大小,来控制 Minor GC 的时间开销。
  • Mixed GC:选定所有年轻代里的 Region,另外增加根据 global concurrent marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region。

Minor GC、Concurrent marking、Mixed GC 的执行流程也不是固定的。比如,可能 1 次 Minor GC 后,发生了一次 Concurrent marking,接着发生了 9 次 Mixed GC。

../_images/Advanced-Java.03a1.G1回收器-2023-06-06-7.png
图片来自https://c-guntur.github.io/java-gc/#/

① Minor GC

Minor GC 是全过程 STW 的,它的跨代引用使用 RSet 数据结构来追溯,会一次性回收掉年轻代的所有 Region。

触发条件:和过去的 Minor GC 一样,“当所有的 Eden Region 都无法再分配对象时”。

包括下面的回收阶段

  • (1)从 GC Roots 开始扫描,加上 RSet 记录的其他 Region 的外部引用;

  • (2)更新 RSet:处理 dirty card queue 中的卡页,加入到 Region的 RSet;

  • (3)从 RSet 开始扫描:对于从 Old Region → Eden 的跨代引用,作为存活的对象;

  • (4)复制对象:收集算法依然使用的是 Copy 算法。Eden 区内存段中存活的对象会被复制到 Survivor Region,如果需要晋升,则复制到 Old Region。

  • (5)处理引用,处理 Soft、Weak、Phantom、Final、JNI Weak 等引用队列。

② Concurrent Marking Cycle

并发标记周期(Concurrent Marking Cycle)触发条件: 当整个堆内存使用达到一定比例(默认是 45%),并发标记阶段就会被启动。这个比例也是可以调整的,通过参数 -XX:InitiatingHeapOccupancyPercent 进行配置。

Concurrent Marking 是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。这个过程和 CMS 垃圾回收器的回收过程非常类似,你可以类比 CMS 的回收过程看一下。具体标记过程如下:

(1)初始标记(Initial Mark):这个过程共用了 Minor GC 的暂停,这是因为它们可以复用 root scan 操作。虽然是 STW 的,但是时间通常非常短。

(2)并发标记( Concurrent Mark):这个阶段从 GC Roots 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。

(3)重新标记(Remaking):和 CMS 类似也是 STW 的,标记那些在并发标记阶段发生变化的对象。

(5)清理阶段(Cleanup):这个过程不需要 STW,这一阶段只会清理全都是垃圾的Region,不全是垃圾的 Region并不会被立马处理,它会在 Mixed GC 阶段进行收集。

保证并发标记的正确性

无论 CMS 还是 G1都存在并发标记阶段,这一阶段 Mutator 线程Garbage Collector 线程 同时对对象进行修改,那么并发阶段是如何保证标记的正确性呢? 主要需要解决2个问题:

  1. 漏标(存活的对象没有被标记,导致被回收)
  2. 多标(垃圾对象被标记为存活,导致不能在下次 GC 被回收)
三色标记法

我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

三色标记遍历过程:

  1. 初始时,所有对象都在 【白色集合】中;
  2. GC Roots 直接引用到的对象 挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
    3.1. 本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    3.2. 本对象 挪到 【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为 GC Roots 不可达,可以进行回收。

SATB

了解了三色标记法,然后继续看 G1如何解决并发标记的问题:

➤ G1使用了 “原始快照”(Snapshot-At-The-Beginning,SATB) 来处理漏标,由字面理解,是GC开始时活着的对象的一个快照。它是通过 Root Tracing得到的,作用是维持并发GC的正确性。

当赋值语句发生时,快照发生改变,JVM 会将这次改变记录到 SATB日志 中,每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来 更新RSet

下面看 SATB机制如何解决漏标的问题:
漏标可以理解为并发标记过程中,一个白对象被重新引用了,变成了存活对象,这时要防止该白对象被漏标导致错误的回收。

  • 对于并发标记期间 new 出来的白对象:Region 中有两个 top-at-mark-start(TAMS)指针,分别为 prevTAMS 和 nextTAMS。在 TAMS 以上的对象是新分配的,这是一种隐式的标记,这样就解决了此类对象被漏标的情况;
  • 对于标记期已经存在的白对象,假如出现了漏标,只能是下面2个条件同时发生时:
    • (1)断开了灰对象 -> 白对象的引用
    • (2)增加了黑对象 -> 白对象的引用

只有这2条同时发生,此白对象才是“漏标”对象,如果破坏上面任一条件,都可以防止漏标

  • G1的做法是,在(1)发生时,即 灰→白的引用消失,通过写屏障记录下这次改动(SATB),并发标记完成后,对该记录进行重新扫描;
  • CMS 的做法是在(2)发生时,即新增了黑 -> 白的引用,记录这次改动(incremental update),把黑色重新标记为灰色,下次会重新扫描灰色对象;

通过写屏障,将引用的变化记录下来,并发优化线程(Concurrence Refinement Threads)将这些记录更新到 RSet 中;

此外, Write Barrier (写屏障)有很多应用场景:除了上面的 STAB,还有 CMS 时期的卡表(Card_Table)。

➤ 对于多标(浮动垃圾),产生的条件是:

  1. 并发标记期间, new 出来的对象
  2. 对“灰色对象”的引用被断开

对于情况1,并发期间新建对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

对于情况2,G1似乎没有特殊处理,

③ Mixed GC

Mixed GC 触发条件:
通过 Concurrent Marking 阶段,我们已经统计了老年代的垃圾占比。在 Minor GC 之后,如果判断这个占比达到了某个阈值,下次就会触发 Mixed GC。这个阈值,由 -XX:G1HeapWastePercent 参数进行设置(默认是堆大小的 5%)。

虽然触发条件是老年代的垃圾占比,但 Mixed GC 同名字一样,会回收全部的 Young Region 外加一部分 Old Region,通过上一阶段 concurrent marking 的统计结果,选出收益高的 Old Region 进入 CSet,从而实现对 GC 暂停时间的控制;

Mixed GC 不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap;

其他 Mixed GC 相关的参数:

  • -xx:G1MixedGCLiveThresholdPercent:Old Region 中的存活对象的占比,低于这个值的 Region,才会被选入 CSet。
  • -xx:G1MixedGCCountTarget:一次 global concurrent marking 之后,最多执行 Mixed GC 的次数。
  • -xx:G1OldCSetRegionThresholdPercent:一次Mixed GC中能被选入CSet的最多Old Region数量。

停顿预测

Pause Prediction Model 即停顿预测模型。它在G1中的作用是: G1 uses a pause prediction model to meet a user-defined pause time target and selects the number of regions to collect based on the specified pause time target.

G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。那么G1怎么满足用户的期望呢?就需要这个停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。 停顿预测模型是以衰减标准偏差为理论基础实现的:

//  share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
return MAX2(seq->davg() + sigma() * seq->dsd(),
seq->davg() * confidence_factor(seq->num()));
}

在这个预测计算公式中:davg表示衰减均值,sigma()返回一个系数,表示信赖度,dsd表示衰减标准偏差,confidence_factor表示可信度相关系数。而方法的参数TruncateSeq,顾名思义,是一个截断的序列,它只跟踪了序列中的最新的n个元素。

在G1 GC过程中,每个可测量的步骤花费的时间都会记录到 TruncateSeq(继承了AbsSeq)中,用来计算衰减均值、衰减变量,衰减标准偏差等:

// src/share/vm/utilities/numberSeq.cpp

void AbsSeq::add(double val) {
if (_num == 0) {
// if the sequence is empty, the davg is the same as the value
_davg = val;
// and the variance is 0
_dvariance = 0.0;
} else {
// otherwise, calculate both
_davg = (1.0 - _alpha) * val + _alpha * _davg;
double diff = val - _davg;
_dvariance = (1.0 - _alpha) * diff * diff + _alpha * _dvariance;
}
}

比如要预测一次GC过程中,RSet的更新时间,这个操作主要是将Dirty Card加入到RSet中,具体原理参考前面的RSet。每个Dirty Card的时间花费通过_cost_per_card_ms_seq来记录,具体预测代码如下:

//  share/vm/gc_implementation/g1/g1CollectorPolicy.hpp

double predict_rs_update_time_ms(size_t pending_cards) {
return (double) pending_cards * predict_cost_per_card_ms();
}
double predict_cost_per_card_ms() {
return get_new_prediction(_cost_per_card_ms_seq);
}

参数汇总

@ref: Garbage-First Garbage Collector Tuning

-XX:G1HeapRegionSize=n 设置Region大小,并非最终值
-XX:MaxGCPauseMillis 设置停顿的目标时间,默认值200ms
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
-XX:ParallelGCThreads STW期间,并行GC线程数
-XX:ConcGCThreads=n 并发标记阶段,并行执行的线程数
-XX:InitiatingHeapOccupancyPercent 设置触发标记周期的 Java 堆占用率阈值。默认值是45%

日志解析

@ref: https://tech.meituan.com/2016/09/23/g1.html

Reference