此前,我们介绍了 Serial GCParallel GC 以及 CMS GC,本篇将为你介绍另一种高效 GC —— Garbage-First (G1)。

概述

Garbage-First (G1) 是一款面向服务端的垃圾收集器,支持新生代和老年代空间的垃圾收集,主要针对配备多核处理器及大容量内存的机器。在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。G1 最主要的设计目标是: 实现可预期及可配置的 STW 停顿时间。G1 为具有以下需求的应用而设计:

  • 能够像 CMS 收集器一样,能与应用程序线程并发运行
  • 不需要较长的 GC 停顿时间来整理内存空间
  • 可预测 GC 的停顿时间
  • 不想要牺牲大量的吞吐性能
  • 不需要更大的 Java 堆内存

诞生背景

那为什么要重新设计一个 G1 垃圾收集器呢?设计者们希望做出一款能够建立起 停顿时间模型(Pause Prediction Model)的收集器,来实现实时 Java(RTSJ)中软实时(Soft Real-time)垃圾收集器的特征。停顿时间模型 的意思是能够支持指定在一个长度为 N 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。

实时 :能够可靠地可预测地推测和控制程序逻辑的时间行为的能力

##G1 内存布局

那如何才能建立可预测的停顿时间模型呢?首先要有一个思想上改变,在 G1 收集器出现之前的所有其他收集器,包括 CMS 在内,垃圾收集的目标要么是整个新生代(Minor GC),要是整个老年代(Major GC),在要么是整个 Java 堆(Full GC)。而 G1 则跳出了这个樊笼,它可以面向 Java 堆内存任何部分来组成回收集(Collection Set,简称 CSet)进行回收,衡量的标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收的效益最大。

Region(分区)

过去的收集器将堆内存分割成几个大小都是预先设定好的区域 —— Eden、Survivor、Old。

hotspot-legacy-heap-structure

G1 采用了完全不同于过去所有收集器的内存布局方式,不再坚持固定大小以及固定数量的分代区域划分思路,而是开创了基于 Region(分区)的堆内存布局方式,将整个堆空间分成若干个大小相等的内存区域,这些区域物理上不一定连续,但逻辑上构成连续的堆地址空间。

每个分区不会固定地为某个代服务,而是按需在 EdenSurvivorOld 之间进行切换。虽然 G1 仍然保留了新生代和老年代的概念,但新生代和老年代不再是固定的了,他们都是一系列 Region 的动态集合。

G1 收集器之所以能够建立起可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,G1 会去跟踪各个 Region 里面的垃圾回收的价值,价值指回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次依据用户设定的收集停顿时间(-XX:G1HeapRegionSize,默认 200ms)优先处理回收价值最大的那些 Region,保证 G1 在有限的时间内回收最多的垃圾。

Humongous(巨型对象)

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。对于 G1 而言,任何超过一半 Region 大小的对象都会被认作是巨型对象 (Humongous Object,H-Obj)。

H-Obj 会直接在老年代分配,所占用的连续空间称为 Humongous Region (巨型分区),其中第一个 Region 被标记为开始巨型区 (StartsHumongous),相邻连续分区被标记为连续巨型区 (ContinuesHumongous)。

H-Obj 在分配前,会检查整个堆内存的占用率是否超过 -XX:InitiatingHeapOccupancyPercent ,如果超过了,就触发 global concurrent marking,提早回收堆内存,防止转移失败 (Evacuation Failure) 和 full GC。

在 JDK 8u40 之前,巨型对象的回收只能在并发收集周期的清除阶段或 Full GC 过程中过程中被回收。在 JDK 8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。

Card(卡片)

每个 Region 内部又被分成了若干个大小为 512 Byte 的 Card,表示堆内存最小可用粒度。所有 Region 的 Card 将会记录在全局卡片表 (Global Card Table) 中,分配的对象会占用物理上连续的若干个 Card。每次对内存的回收,都是对指定 Region 的 Card 进行处理。

G1 Region Card

停顿预测模型(Pause Prediction Model)

G1 是一个响应时间优先的 GC 算法,用户可以设定整个 GC 过程的期望停顿时间,由参数 -XX:MaxGCPauseMillis 控制,默认 200ms。不过它不是硬性条件,只是期望值,G1 会努力在这个目标停顿时间内完成垃圾回收的工作,但是它不保证完成,也就是可能完不成(比如停顿时间设置过小,新生代大太等等)。

那么 G1 怎么满足用户的期望呢?就需要这个停顿预测模型了。G1 根据这个模型统计计算出来的历史数据来预测本次收集需要选择的 Region 数量,从而尽量满足用户设定的目标停顿时间。比如使用过去 10 次垃圾回收的时间和回收空间的关系,根据目前垃圾回收的目标停顿时间来预测可以收集多少内存空间。比如最简单的办法是使用算术平均值建立一个线性关系来预测。假如过去 10 次一共收集了 10GB 的内存,耗时 1s,那么在 200ms 的停顿时间要求下,最多可以收集 2GB 的内存空间。G1 的预测逻辑基于衰减平均值和衰减标准差。

衰减平均(Decaying Average)是一种简单的数学方法,用来计算一个数列的平均值,核心是给近期的数据更高的权重,即强调近期数据对结果的影响。衰减平均计算公式如下所示:

g1-gc-decaying-average

式中 α 为历史数据权值, 1-α 为最近一次数据权值。即 α 越小,最新的数据对结果的影响越大,最近一次的数据对结果影响越大。不难看出,其实传统的平均值就是 α 取值为 (n-1)/n 的情况。

同理,衰减方差的定义如下:

g1-gc-decaying-average

停顿预测模型是以衰减标准偏差为理论基础实现的,代码如下:

1
2
3
4
5
//  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() 返回一个系数,来自 G1ConfidencePercent(默认值为 50,sigma 为 0.5)的配置, 表示信赖度。
  • dsd() 表示衰减标准偏差。
  • confidence_factor 表示可信度相关系数,confidence_factor 当样本数据不足时(< 5 个)取一个大于 1 的值,并且样本数据越少该值越大。当样本数据大于 5 时 confidence_factor 取值为 1。这是为了弥补样本数据不足,起到补偿作用。
  • 方法的参数 TruncateSeq,是一个截断的序列,它只跟踪了序列中的最新的 n 个元素。在 G1 GC 过程中,每个可测量的步骤花费的时间都会记录到 TruncateSeq(继承了 AbsSeq)中,用来计算衰减均值、衰减变量,衰减标准偏差等,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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;
}
}

这个 add 方法就是上面两个衰减公式的实现代码。其中 _davg 为衰减均值,_dvariance 为衰减方差,_alpha 默认值为 0.7。G1 的软实时停顿就是通过这样的预测模型来实现的。

Remember Set(RSet)

RSet 维护

SATB

G1 GC 模式

G1 提供了两种 GC 模式,Young GC 和 Mixed GC,两种都是完全 Stop The World 的。 * Young GC:选定所有年轻代里的 Region。通过控制年轻代的 region 个数,即年轻代内存大小,来控制 young GC 的时间开销。 * Mixed GC:选定所有年轻代里的 Region,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region。

由上面的描述可知,Mixed GC 不是 full GC,它只能回收部分老年代的 Region,如果 mixed GC 实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行 Mixed GC,就会使用 serial old GC(full GC)来收集整个 GC heap。所以我们可以知道,G1 是不提供 full GC 的。

重要的默认参数

G1GC 是一款自适应的垃圾收集器,无须修改默认的参数即可高效运行。

-XX:G1HeapRegionSize=n

设置 G1 Region 大小。该值为 2 的幂,范围为 1MB ~32MB。在最小堆大小的基础上划分大约 2048 个 region 区域。

-XX:MaxGCPauseMillis=200

设置一个暂停时间期望目标,这是一个软目标,JVM 会近可能的保证这个目标。默认为 200ms。

-XX:G1NewSizePercent=5

设置新生代所占堆大小百分比的最小值。默认值为 5%

-XX:G1MaxNewSizePercent=60

设置新生代所占堆大小百分比的最大值。默认值为 60%

-XX:ParallelGCThreads=n

STW 期间,并行 GC 线程数。一般设置为处理器的数量,最大为 8。如果处理器的数量大于 8,则 n 值大约为 5/8。大多数情况下, 8 值是可行的。但是对于大型的 SPARC(Scalable Processor Architecture,可扩展处理器)系统,n 值一般设置为 5/16

-XX:ConcGCThreads=n

并发标记阶段,并行执行的线程数。n 值大约为 ParallelGCThreads1/4.

-XX:InitiatingHeapOccupancyPercent=45

内存占用达到整个堆百分之多少的时候开启一个 GC 周期,G1 GC 会根据整个堆的占用,而不是某个代的占用情况去触发一个并发 GC 周期,0 表示一直在 GC,默认值是 45。

这里的 java 堆占比指的是 non_young_capacity_bytes ,包括 old + humongous

如果将这个参数调小,G1 就会更早得触发并发垃圾收集周期。这个值需要谨慎设置。如果这个参数设置得太高,会导致 FULL GC 出现得频繁;如果这个值设置得过小,又会导致 G1 频繁得进行并发收集,白白浪费 CPU 资源。通过 GC 日志可以通过一个点来判断 GC 是否正常 —— 在一轮并发周期结束后,需要确保堆剩下的空间小于 InitiatingHeapOccupancyPercent 的值。

-XX:G1MixedGCLiveThresholdPercent=65

表示 mix gc 时,老年代中存活对象的比例不能超过该值,默认 65%。

这是一个实验参数,需要与 -XX:+UnlockExperimentalVMOptions 配合使用

-XX:G1HeapWastePercent=10

设置您愿意浪费的堆百分比。当可回收百分比小于该阈值,Java HotSpot VM 不会启动混合垃圾回收周期。默认值为 10%

-XX:G1MixedGCCountTarget=8

一次 global concurrent marking 之后,最多执行 Mixed GC 的次数,老年区的存活对象比例不得超过 G1MixedGCLIveThresholdPercent 。默认值为 8.

-XX:G1OldCSetRegionThresholdPercent=10

一次 Mixed GC 中能被选入 CSet 的最多 old generation region 数量。默认值为堆内存的 10%。

-XX:G1ReservePercent=10

设置保留内存的百分比以保持可用,以减少空间溢出的风险。默认值为 10%。当增加或减少百分比时,请确保将总 Java 堆调整为相同的数量。

调优 & 最佳实践

总结对比

这几款垃圾收集器都有以下几个共同点:

  • 年轻代、老年代是独立且连续的内存块;
  • 年轻代收集使用单 eden、双 survivor 进行复制算法;
  • 老年代收集必须扫描整个老年代区域;
  • 都是以尽可能少而快地执行 GC 为设计原则。

Serial GCParallel GC 以及 CMS GC

G1 收集器与前面三种收集器的不同之处:

虽然 G1 也有类似 CMS 的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1 收集与以上三组收集器有很大不同:

  • G1 的设计原则是” 首先收集尽可能多的垃圾 (Garbage First)”。因此,G1 并不会等内存耗尽 (串行、并行) 或者快耗尽 (CMS) 的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时 G1 可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  • G1 采用内存分区 (Region) 的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此 G1 天然就是一种压缩方案 (局部压缩);
  • G1 虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的 survivor (to space) 堆做复制准备。G1 只有逻辑上的分代概念,或者说每个分区都可能随 G1 的运行在不同代之间前后切换;
  • G1 的收集都是 STW 的,但年轻代和老年代的收集界限比较模糊,采用了混合 (mixed) 收集的方式。即每次收集既可能只收集年轻代分区 (年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区 (混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

参考资料