这可能是最清晰易懂的 G1 GC 资料

2,174 阅读26分钟

概述

G1 (Garbage-First) 于 JDK 6u14 版本发布,JDK 7u4 版本发行时被正式推出,在 JDK9 时已经成了默认的垃圾回收器,算是 CMS 回收器的替代 方案(CMS 在 JDK9 以后已经废弃)

G1 是一款分代的 (generational),增量的 (incremental),并行的 (parallel),移动式(evacuating)的,软实时的垃圾回收器。其最大特点是暂停时间可配置,我们可以配置一个最大暂停时间,G1 就会尽可能的在回收的同时保证程序的暂停时间在允许范围内,而且在大内存环境下表现更好。

在正式介绍 G1 细节之前,先简单说说垃圾回收的一些基本知识:

垃圾回收的一些基础知识

mutator

在垃圾回收的里,mutator 指应用程序。至于为什么叫这个奇怪的名字?

mutator 是 Edsger Dijkstra 琢磨出来的词,有 “改变某物” 的意思。说到要改变什么,那
就是 GC 对象间的引用关系。不过光这么说可能大家还是不能理解,其实用一句话概括的话, 它的实体就是 “应用程序”。这样说就容易理解了吧。GC 就是在这个 mutator 内部精神饱满地 工作着。

增量垃圾回收

增量式垃圾回收(Incremental GC)是一种通过逐渐推进垃圾回收来控制 mutator 最 大暂停时间的方法。

像一些早期的年轻代垃圾回收器,都是完全暂停的,比如 Serial GC
简单的说,增量垃圾回收,就是让 GC 程序和 Mutator 交替运行的办法,交替执行时,实际上垃圾是一点点回收的,所以叫 “增量(Incremental)”

G1 就属于一款增量垃圾回收器,它通过和 mutator 交替运行的方式来降低因 GC 导致的程序暂停时间

并行 GC 和并发 GC

并行 GC(Parallel) 和并发(concurrent)GC 是垃圾回收里的一个基本概念。这两个词很模糊,容易弄混淆,不过在 GC 的领域里,就用 GC 里的解释吧。

一般来说,以多线程执行的 GC 被称为并行 / 并发 GC,不过这两个词在 GC 里意思完全不同。

并行的 GC 会先暂停 mutator,然后开启多个线程并行的执行 GC,如下图所示:

而并发 GC 是在不暂停 Mutator 运行的同时,开启 GC 线程并行的执行 GC,如下图所示:

并行 GC 的目的是提升 GC 效率,缩短暂停时间,而并发 GC 的目的是彻底干掉暂停时间

可预测

G1 的执行流程

G1 GC 中的堆结构和其他回收器的有所不同,在 G1 中,堆被划分为 N 个大小的相等的区域(Region),每个区域占用一段连续的地址空间,以区域为单位进行垃圾回收,而且这个区域的大小是可配置的。在分配时,如果选择的区域已经满了,会自动寻找下一个空闲的区域来执行分配。

G1 是一个分代的垃圾回收器,同样的它将堆分为年轻代(young)和老年代(old),将划分的区域又分为年轻代区域和老年代区域。不过和其他垃圾回收器不同,G1 中不同代的区域空间并不是连续的。

这里解释一下,为什么 G1 中不同代使用不连续的区域。因为 G1 Heap 中初始时只划分了区域,并没有给区域分类,在对象分配时,只需要从空闲区域集(free-list)中选取一个存储对象即可,这样区域分配更灵活。

当发生 GC 时,EDEN 区被清空,然后会作为一个空闲的区域,这个区域待会可能会作为老年代,也可能会被作为 Survivor 区域。不过在 G1 在分配时还是会检查新生代的区域总大小是否超过新生代大小限制的,如果超出就会进行 GC

虽然每个区域的大小是有限的,不过针对一些占用较大的大对象(humongous object),还是会存在跨区域的情况。对于跨区域的对象,会分配多个连续的区域。

G1 中的区域,主要分为两种类型:

  1. 年轻代区域

    1. Eden 区域 - 新分配的对象
    2. Survivor 区域 - 年轻代 GC 后存活但不需要晋升的对象
  2. 老年代区域

    1. 晋升到老年代的对象
    2. 直接分配至老年代的大对象,占用多个区域的对象

G1 中的堆结构如下图所示:

和其他的垃圾回收方式有所不同,G1 的年轻代 / 老年代的回收算法都是一致的,属于移动 / 转移式回收算法。比如复制算法,就属于移动式回收算法,优点是没有碎片,存活的越少效率越高

RememberedSet

比如在对某个区域进行回收时,首先从 GC ROOT 开始遍历可直达这些区域中的对象,可由于晋升或者移动的原因,这些区域中的某些对象移动到了其他区域,可是移动之后仍然保持着对原区域对象的引用;那么此时原区域中被引用的对象对 GC ROOT 来说并不能 “直达”,他们被其他对象的区域引用,这个发起引用的其他对象对于 GC ROOT 可达。这种情况下,如果想正确的标记这种 GC ROOT 不可直达但被其他区域引用的对象时就需要遍历所有区域了,代价太高。

如下图所示,如果此时堆区域 A 进行回收,那么需要标记区域 A 中所有存活的对象,可是 A 中有两个对象被其他区域引用,这两个灰色的问号对象在区域 A 中对 GC ROOTS 来是不可达的,但是实际上这两个对象的引用对象被 GC ROOTS 引用,所以这两个对象还是存活状态。此时如果不将这两个对象标记,那么就会导致标记的遗漏,可能造成误回收的问题

**RememberedSet(简称 RS 或 RSet)**就是用来解决这个问题的,RSet 会记录这种跨代引用的关系。在进行标记时,除了从 GC ROOTS 开始遍历,还会从 RSet 遍历,确保标记该区域所有存活的对象(其实不光是 G1,其他的分代回收器里也有,比如 CMS)

如下图所示,G1 中利用一个 RSet 来记录这个跨区域引用的关系,每个区域都有一个 RSet,用来记录这个跨区引用,这样在进行标记的时候,将 RSet 也作为 ROOTS 进行遍历即可

所以在对象晋升的时候,将晋升对象记录下来,这个存储跨区引用关系的容器称之为 RSet,在 G1 中通过 Card Table 来实现。

注意,这里说 Card Table 实现 RSet,并不是说 CardTable 是 RSet 背后的数据结构,只是 RSet 中存储的是 CardTable 数据

Card Table

在 G1 堆中,存在一个 CardTable 的数据,CardTable 是由元素为 1B 的数组来实现的,数组里的元素称之为卡片 / 卡页(Page)。这个 CardTable 会映射到整个堆的空间,每个卡片会对应堆中的 512B 空间。

如下图所示,在一个大小为 1 GB 的堆下,那么 CardTable 的长度为 2097151 (1GB / 512B);每个 Region 大小为 1 MB,每个 Region 都会对应 2048 个 Card Page。

那么查找一个对象所在的 CardPage 只需要简单的计算就可以得出:

介绍完了 CardTable,下面说说 G1 中 RSet 和 CardTable 如何配合工作。

每个区域中都有一个 RSet,通过 hash 表实现,这个 hash 表的 key 是引用本区域的其他区域的地址,value 是一个数组,数组的元素是引用方的对象所对应的 Card Page 在 Card Table 中的下标。

如下图所示,区域 B 中的对象 b 引用了区域 A 中的对象 a,这个引用关系跨了两个区域。b 对象所在的 CardPage 为 122,在区域 A 的 RSet 中,以区域 B 的地址作为 key,b 对象所在 CardPage 下标为 value 记录了这个引用关系,这样就完成了这个跨区域引用的记录。

不过这个 CardTable 的粒度有点粗,毕竟一个 CardPage 有 512B,在一个 CardPage 内可能会存在多个对象。所以在扫描标记时,需要扫描 RSet 中关联的整个 CardPage。

写入屏障

写入屏障 (Write Barrier) 也是 GC 里的一个关键技术(不是 linux 里的 membarrier),当发生引用关系的更新时,通过写入屏障来(这里指转移用的写入屏障)记录这个引用关系的变更,只是一系列函数而已,就像这样(伪代码):

def evacuation_write_barrier(obj, field, newobj){
    //检查引用和被引用新对象是否在同一个区域
    if(!check_cross_ref(obj, newobj)){
        return 
    }
    //不重复添加dirty_card
    if(is_dirty_card(obj)){
        return 
    }
    to_dirty(obj);
    //将obj添加到newobj所在region的rs
    add_to_rs(obj, newobj);
}

为了便于理解,上面的伪代码屏蔽了一些细节,了解核心工作内容即可

不过在 G1 里,不止一种写入屏障,像前面介绍的 SATB 也是有的写入屏障,这里不做过多介绍

分代回收

G1 中有两种回收模式:

  1. 完全年轻代 GC(fully-young collection),也称年轻代垃圾回收(Young GC)
  2. 部分年轻代 GC(partially-young collection)又称混合垃圾回收(Mixed GC)

**完全年轻代 GC 是只选择年轻代区域(Eden/Survivor)进入回收集合(Collection Set,简称 CSet)进行回收的模式。**年轻代 GC 的过程和其他的分代回收器差不多,新创建的对象分配至 Eden 区域,然后将标记存活的对象移动至 Survivor 区,达到晋升年龄的就晋升到老年代区域,然后清空原区域(不过这里可没有年轻代复制算法中两个 Survivor 的交换过程)。

年轻代 GC 会选择所有的年轻代区域加入回收集合中,但是为了满足用户停顿时间的配置,在每次 GC 后会调整这个最大年轻代区域的数量,每次回收的区域数量可能是变化的

下面是一个完全年轻代 GC 过程的简单示意图:将选择的年轻代区域中所有存活的对象,移动至 Survivor 区域,然后清空原区域

上面只是一个简易的回收过程示意,接下来详细介绍年轻代的回收过程

年轻代垃圾回收(完全年轻代 GC)

当 JVM 无法将新对象分配到 eden 区域时,会触发年轻代的垃圾回收(年轻代垃圾回收是完全暂停的,虽然部分过程是并行,但暂停和并行并不冲突)。也会称为 “evacuation pause”

步骤 1. 选择收集集合(Choose CSet),G1 会在遵循用户设置的 GC 暂停时间上限的基础上,选择一个最大年轻带区域数,将这个数量的所有年轻代区域作为收集集合。

如下图所示,此时 A/B/C 三个年轻代区域都已经作为收集集合,区域 A 中的 A 对象和区域 B 中的 E 对象,被 ROOTS 直接引用(图上为了简单,将 RS 直接引用到对象,实际上 RS 引用的是对象所在的 CardPage)

**步骤 2. 根处理(Root Scanning),**接下来,需要从 GC ROOTS 遍历,查找从 ROOTS **直达到收集集合的对象,**移动他们到 Survivor 区域的同时将他们的引用对象加入标记栈

如下图所示,在根处理阶段,被 GC ROOTS 直接引用的 A/E 两个对象直接被复制到了 Survivor 区域 M,同时 A/E 两个对象所引用路线上的所有对象,都被加入了标记栈(Mark Stack),这里包括 E->C->F,这个 F 对象也会被加入标记栈中

步骤 3. **RSet 扫描(Scan RS),**将 RSet 作为 ROOTS 遍历,查找可直达到收集集合的对象,移动他们到 Survivor 区域的同时将他们的引用对象加入标记栈

在 RSet 扫描之前,还有一步更新 RSet(Update RS)的步骤,因为 RSet 是先写日志,再通过一个 Refine 线程进行处理日志来维护 RSet 数据的,这里的更新 RSet 就是为了保证 RSet 日志被处理完成,RSet 数据完整才可以进行扫描

如下图所示,老年代区域 C 中引用年轻代 A 的这个引用关系,被记录在年轻代的 RSet 中,此时遍历这个 RS,将老年代 C 区域中 D 对象引用的年轻代 A 中的 B 对象,添加到标记栈中

步骤 4. 移动(Evacuation/Object Copy),遍历上面的标记栈,将栈内的所有所有的对象移动至 Survivor 区域(其实说是移动,本质上还是复制)

如下图所示,标记栈中记录的 C/F/B 对象被移动到 Survivor 区域中    

当对象年龄超过晋升的阈值时,对象会直接移动到老年代区域,而不是 Survivor 区域。

对象移动后,需要更新引用的指针,这块具体的做法可以参考我的另一篇文章《垃圾回收算法实现之 - 复制(完整可运行 C 语言代码)

收尾步骤. 剩下的就是一些收尾工作,Redirty(配合下面的并发标记)**,**Clear CT(清理 Card Table),Free CSet(清理回收集合),清空移动前的区域添加到空闲区等等,这些操作一般耗时都很短

混合回收(部分年轻代 GC)

**混合回收,也称部分年轻代 GC,会选择所有年轻代区域(Eden/Survivor)(最大年轻代分区数)和部分老年代区域进去回收集合进行回收的模式。年轻代区域对象移动到 Survivor 区,老年代区域移动到老年代区域。**由于 G1 中老年代区域的回收方式和新生代一样是 “移动式”,被回收区域在移动后会全部清空,所以不会像其他使用清除算法的回收器一样(比如 CMS)有碎片问题。

下面是一个部分年轻代 GC 过程的简单示意图:

混合回收的执行过程主要包含两步:

  1. 并发标记(concurrent marking) - 增量式并发的标记存活对象,标记过程中 Mutator 的引用更新也会被标记
  2. 移动 / 转移(evacuation)- 和年轻代的移动过程一致,复用代码,最大的不同是将并发标记的结果也进行处理

并发标记

并发标记的目的是标记存活对象,为移动过程做准备。在并发标记的过程中,存活对象的标记和 Mutator 的运行是并发进行的。所以这个标记过程中引用变化的更新,是并发标记过程中最复杂的部分。

G1 的并发标记设计,是基于 CMS 回收器的,所以整体标记过程和 CMS 中的并发标记很像。并发标记会对区域内所有的存活对象进行标记,那么未标记的对象就是垃圾需要回收(这里 “并发” 的目的是为了降低 Mutator 的暂停时间)

当老年代使用的内存加上本次即将分配的内存占到总内存的 45%,就会启动混合回收,进行并发标记。

并发标记并不是直接在对象上进行标记,而是用了一个独立的数据容器 - 标记位图(MarkBitMap),统一的对区域中的所有对象进行标记,在移动时通过这个位图就可以判断对象是否存活

标记位图

每个区域内都有两个标记位图(Mark Bitmap):next 和 prev。next 是本次标记的标记位图,而 prev 是上次标记的标记位图,保存上次标记的结果。

标记位图就是通过对象地址,映射到标记位图中的数据位,标记位图中的每一个 bit 都代表一个对象的标记状态,如下图所示:

每个区域中,还有 4 个标记位置的指针,分别是 bottom,top,nextTAMS,prevTAMS。由于是并发标记,标记的同时 Mutator 会分配对象、修改引用关系,而且并发标记(这里指的是并发标记子阶段)会被年轻代 GC 所中断,中断后继续就需要基于上次中断的点继续标记,所以这几个指针可以理解为记录变化点,有点游戏里暂存点的意思。

如下图所示 ,某个区域在进行标记前。bottom 代表这个区域的底部,top 表示区域内存的顶部(即使用量),TAMS(Top-at-Mark-Start,标记开始时的 top),prevTAMS 和 nextTAMS 即上 / 下一次的标记信息

在标记时,如果该区域又分配了一些对象,那么 top 指针就会移动,那么此时 top-nextTAMS 就是标记过程中的新对象

并发标记的过程分为以下几个步骤:

初始标记(Initial Mark)

标记由根直接引用的对象(STW),这个过程是在年轻代 GC 中完成的,不过不是每次年轻代 GC 都会进行初始标记。

并发标记(Concurrent Mark)

以步骤 1 的标记结果作为 root,遍历可达的对象进行标记,和 mutator 并行,并且可被年轻代 GC 中断,年轻代 GC 完成后可继续进行标记

SATB

SATB (Snapshot At The Beginning,初始快照),是一种将并发标记阶段开始时对象间的引用关系,以逻辑快照的形式进行保存的手段

这个解释有点…… 抽象,简单理解就是,在并发标记时,以当前的引用关系作为基础引用数据,不考虑 Mutator 并发运行时对引用关系的修改(Snapshot 命名的由来),标记时是存活状态就认为是存活状态,同时利用 SATB Write Barrier 记录引用变化。详细的 SATB 解释,可以参考我的另一篇文章《SATB 的一些理解》

最终标记 (Remark)

标记遗漏的对象,主要是 SATB 相关 **(STW)

清理(Cleanup)

计算标记区域的活动对象数量,清理没有存活对象的区域(标记后没有存活对象,并不是正经的回收阶段),对区域排序等**(部分 STW)**

混合收集

这里的混合收集,是指混合回收 GC 下的回收过程。在并发标记完成后,就可以进行混合收集了(mixed),混合收集阶段和年轻代 GC 一致,从并发标记的结果 / ROOTS/RSet 遍历回收存活对象即可,只是多了老年代区域的回收。

Full GC

当混合回收无法跟上内存分配的速度,导致老年代也满了,就会进行 Full GC 对整个堆进行回收。G1 中的 Full GC 也而是单线程串行的,而且是全暂停,使用的是标记 - 整理算法,代价非常高。

暂停时间的控制

G1 在移动过程中虽然也是全暂停,不过 G1 在选择回收集合上是变化的,每次只选择部分的区域进行回收,通过计算每个区域的预测暂停时间来保证每次回收所占用的时间。简单的说就是将一次完整的 GC 拆分成多次短时间的 GC 从而降低暂停的时间,尽量保证每次的暂停时间在用户的配置范围(-XX:MaxGCPauseMilli)内。

年轻代大小的配置

G1 为了控制暂停时间,年轻代最大区域数是动态调整的,不过如果手动设置了年轻代大小,比如 Xmn/MaxNewSize/NewRatio 等,并且年轻代最大和最小值一样,那么相当于禁用了这个最大区域数调整的功能,可能会导致暂停时间控制的失效(因为年轻代 GC 是选择全部区域的,区域过多会导致暂停时间的增加)。

所以 G1 中尽量不要设置年轻代的大小,让 G1 自动的进行调整

日志解读

年轻代 GC 日志(完全年轻代)

//[GC pause (G1 Evacuation Pause) (young) 代表完全年轻代回收
// 0.0182341 secs 是本次GC的暂停时间
0.184: [GC pause (G1 Evacuation Pause) (young), 0.0182341 secs 是本次GC的暂停时间]
// 并行GC线程,一共有8个
   [Parallel Time: 16.7 ms, GC Workers: 8]
      /*这一行信息说明的是这8个线程开始的时间,Min表示最早开始的线程时间,Avg表示平均开始时间,Max表示的是最晚开始时间,Diff为最早和最晚的时间差。这个值越大说明线程启动时间越不均衡。线程启动的时间依赖于GC进入安全点的情况。关于安全点可以参考后文的介绍。*/
      [GC Worker Start (ms):  184.2  184.2  184.2  184.3  184.3  184.4  186.1  186.1
       Min: 184.2, Avg: 184.7, Max: 186.1, Diff: 1.9]
      /*根处理的时间,这个时间包含了所有强根的时间,分为Java根,分别为Thread、JNI、CLDG;和JVM根下面的StringTable、Universe、JNI Handles、ObjectSynchronizer、FlatProfiler、Management、SystemDictionary、JVMTI */
      [Ext Root Scanning (ms):  0.3  0.2  0.2  0.1  0.1  0.0  0.0  0.0
       Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.3, Sum: 0.8]
         /*Java线程处理时间,主要是线程栈。这个时间包含了根直接引用对象的复制时间,如果根超级大,这个时间可能会增加 */
         [Thread Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
         [StringTable Roots (ms):  0.0  0.1  0.1  0.1  0.1  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.4]
         [Universe Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [JNI Handles Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [ObjectSynchronizer Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [FlatProfiler Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Management Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [SystemDictionary Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [CLDG Roots (ms):  0.3  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.3, Diff: 0.3, Sum: 0.3]
         [JVMTI Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
       // CodeCache Roots实际上是在处理Rset的时候的统计值,它包含下面的
       // UpdateRS,ScanRS和Code Root Scanning
         [CodeCache Roots (ms):  5.0  3.9  2.2  3.3  2.1  2.2  0.6  2.2
          Min: 0.6, Avg: 2.7, Max: 5.0, Diff: 4.4, Sum: 21.6]
         [CM RefProcessor Roots (ms):  0.0
         0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Wait For Strong CLD (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Weak CLD Roots (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [SATB Filtering (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
       // 这个就是GC线程更新RSet的时间花费,注意这里的时间和我们在Refine里面处理RSet
       // 的时间没有关系,因为它们是不同的线程处理
       [Update RS (ms):  5.0  3.9  2.2  3.3  2.1  2.2  0.6  2.2
        Min: 0.6, Avg: 2.7, Max: 5.0, Diff: 4.4, Sum: 21.5]
          // 这里就是GC线程处理的白区中的dcq个数
         [Processed Buffers:  8  8  7  8  8  7  2  4
          Min: 2, Avg: 6.5, Max: 8, Diff: 6, Sum: 52]
      // 扫描RSet找到被引用的对象
      [Scan RS (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
       Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Code Root Scanning (ms):  0.0  0.0  0.0  0.0  0.0  0.1  0.0  0.0
       Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.1]
      // 这个就是所有活着的对象(除了强根直接引用的对象,在Java根处理时会直接复制)复制
      // 到新的分区花费的时间。从这里也可以看出复制基本上是最花费时间的操作。        
      [Object Copy (ms):  11.3  12.5  14.2  13.1  14.3  14.2  14.2  12.5
       Min: 11.3, Avg: 13.3, Max: 14.3, Diff: 3.0, Sum: 106.3]
      // GC线程结束的时间信息。
      [Termination (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
       Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Termination Attempts:  1  1  1  1  1  1  1  1
          Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 8]
      // 这个是并行处理时其他处理所花费的时间,通常是由于JVM析构释放资源等
      [GC Worker Other (ms):  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
       Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      // 并行GC花费的总体时间
      [GC Worker Total (ms):  16.6  16.6  16.6  16.5  16.5  16.4  14.7  14.7
       Min: 14.7, Avg: 16.1, Max: 16.6, Diff: 1.9, Sum: 128.7]
      // GC线程结束的时间信息
      [GC Worker End (ms):  200.8  200.8  200.8  200.8  200.8  200.8  200.8  200.8
       Min: 200.8, Avg: 200.8, Max: 200.8, Diff: 0.0]
    // 下面是其他任务部分。
    // 代码扫描属于并行执行部分,包含了代码的调整和回收时间
    [Code Root Fixup: 0.0 ms]   
    [Code Root Purge: 0.0 ms]
    // 清除卡表的时间
    [Clear CT: 0.1 ms]
    [Other: 1.5 ms]
      // 选择CSet的时间,YGC通常是0
      [Choose CSet: 0.0 ms]
      // 引用处理的时间,这个时间是发现哪些引用对象可以清除,这个是可以并行处理的
      [Ref Proc: 1.1 ms]
      // 引用重新激活
      [Ref Enq: 0.2 ms]
      // 重构RSet花费的时间
      [Redirty Cards: 0.1 ms]
         [Parallel Redirty:  0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0
          Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
         [Redirtied Cards:  8118  7583  6892  4496  0  0  0  0
          Min: 0, Avg: 3386.1, Max: 8118, Diff: 8118, Sum: 27089]
          // 这个信息是是可以并行处理的,这里是线程重构RSet的数目
       // 大对象处理时间
      [Humongous Register: 0.0 ms]
         [Humongous Total: 2]
          // 这里说明有2个大对象
         [Humongous Candidate: 0]
          // 可回收的大对象0个
      // 如果有大对象要回收,回收花费的时间,回收的个数
      [Humongous Reclaim: 0.0 ms]
         [Humongous Reclaimed: 0]
      // 释放CSet中的分区花费的时间,有新生代的信息和老生代的信息。
      [Free CSet: 0.0 ms]
         [Young Free CSet: 0.0 ms]
         [Non-Young Free CSet: 0.0 ms]
    // GC结束后Eden从15M变成0,下一次使用的空间为21M,S从2M变成3M,整个堆从
    // 23.7M变成20M
    [Eden: 15.0M(15.0M)->0.0B(21.0M) Survivors: 2048.0K->3072.0K 
     Heap: 23.7M(256.0M)->20.0M(256.0M)]

老年代垃圾回收(部分年轻代 / 混合回收)日志

并发标记日志

并发标记是全局的,和回收过程是两个阶段,所以并发标记可以说是独立的。

//并发标记 - 初始标记阶段,在年轻代GC中完成
100.070: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0751469 secs]
  [Parallel Time: 74.7 ms, GC Workers: 8]
    [GC Worker Start (ms): Min: 100070.4, Avg: 100070.5, Max: 100070.6, Diff: 
      0.1]
    [Ext Root Scanning (ms): Min: 0.1, Avg: 0.2, Max: 0.3, Diff: 0.2, Sum: 
      1.6]
    [Update RS (ms): Min: 0.6, Avg: 1.1, Max: 1.5, Diff: 0.9, Sum: 8.9]
       [Processed Buffers: Min: 1, Avg: 1.6, Max: 4, Diff: 3, Sum: 13]
    [Scan RS (ms): Min: 1.0, Avg: 1.4, Max: 1.9, Diff: 0.9, Sum: 10.8]
    [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 
      0.0]
    [Object Copy (ms): Min: 71.5, Avg: 71.5, Max: 71.6, Diff: 0.1, Sum: 572.1]
    [Termination (ms): Min: 0.3, Avg: 0.3, Max: 0.4, Diff: 0.1, Sum: 2.6]
       [Termination Attempts: Min: 1382, Avg: 1515.5, Max: 1609, Diff: 227, 
         Sum: 12124]
    [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.2]
    [GC Worker Total (ms): Min: 74.5, Avg: 74.5, Max: 74.6, Diff: 0.1, Sum: 
      596.3]
    [GC Worker End (ms): Min: 100145.1, Avg: 100145.1, Max: 100145.1, Diff: 
      0.0]
  [Code Root Fixup: 0.0 ms]
  [Code Root Purge: 0.0 ms]
  [Clear CT: 0.1 ms]
  [Other: 0.4 ms]
    [Choose CSet: 0.0 ms]
    [Ref Proc: 0.1 ms]
    [Ref Enq: 0.0 ms]
    [Redirty Cards: 0.1 ms]
    [Humongous Register: 0.0 ms]
    [Humongous Reclaim: 0.0 ms]
    [Free CSet: 0.0 ms]
  [Eden: 23.0M(23.0M)->0.0B(14.0M) Survivors: 4096.0K->4096.0K Heap: 84.5M
    (128.0M)->86.5M(128.0M)]
[Times: user=0.63 sys=0.00, real=0.08 secs]

// 把YHR中Survivor分区作为根,开始并发标记根扫描
100.146: [GC concurrent-root-region-scan-start]
// 并发标记根扫描结束,花费了0.0196297,注意扫描和Mutator是并发进行,同时有多个线程并行
100.165: [GC concurrent-root-region-scan-end, 0.0196297 secs]
// 开始并发标记子阶段,这里从所有的根引用:包括Survivor和强根如栈等出发,对整个堆进行标记
100.165: [GC concurrent-mark-start]
// 标记结束,花费0.08848s
100.254: [GC concurrent-mark-end, 0.0884800 secs]
// 这里是再标记子阶段,包括再标记、引用处理、类卸载处理信息
100.254: [GC remark 100.254: [Finalize Marking, 0.0002228 secs] 100.254: 
  [GC ref-proc, 0.0001515 secs] 100.254: [Unloading, 0.0004694 secs], 
  0.0011610 secs]
  [Times: user=0.00 sys=0.00, real=0.00 secs]
// 清除处理,这里的清除仅仅回收整个分区中的垃圾
// 这里还会调整RSet,以减轻后续GC中RSet根的处理时间
100.255: [GC cleanup 86M->86M(128M), 0.0005376 secs]
  [Times: user=0.00 sys=0.00, real=0.00 secs]

混合回收日志

// 混合回收Mixed GC其实和YGC的日志类似,能看到GC pause(G1EvacuationPause)(mixed)这样的信息
// 日志分析参考Y年轻代GC。
  122.132: [GC pause (G1 Evacuation Pause) (mixed), 0.0106092 secs]
  [Parallel Time: 9.8 ms, GC Workers: 8]
    [GC Worker Start (ms): Min: 122131.9, Avg: 122132.0, Max: 122132.0, 
      Diff: 0.1]
    [Ext Root Scanning (ms): Min: 0.1, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.7]
    [Update RS (ms): Min: 0.5, Avg: 0.7, Max: 0.9, Diff: 0.4, Sum: 5.4]
      [Processed Buffers: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 14]
    [Scan RS (ms): Min: 1.0, Avg: 1.3, Max: 1.5, Diff: 0.5, Sum: 10.4]
    [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 
      0.0]
    [Object Copy (ms): Min: 7.5, Avg: 7.6, Max: 7.7, Diff: 0.2, Sum: 60.9]
    [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [Termination Attempts: Min: 92, Avg: 105.1, Max: 121, Diff: 29, Sum: 841]
    [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
    [GC Worker Total (ms): Min: 9.7, Avg: 9.7, Max: 9.8, Diff: 0.1, Sum: 77.6]
    [GC Worker End (ms): Min: 122141.7, Avg: 122141.7, Max: 122141.7, Diff: 0.0]
  [Code Root Fixup: 0.0 ms]
  [Code Root Purge: 0.0 ms]
  [Clear CT: 0.2 ms]
  [Other: 0.7 ms]
    [Choose CSet: 0.0 ms]
    [Ref Proc: 0.1 ms]
    [Ref Enq: 0.0 ms]
    [Redirty Cards: 0.5 ms]
    [Humongous Register: 0.0 ms]
    [Humongous Reclaim: 0.0 ms]
    [Free CSet: 0.0 ms]
  [Eden: 3072.0K(3072.0K)->0.0B(5120.0K) Survivors: 3072.0K->1024.0K 
    Heap: 105.5M(128.0M)->104.0M(128.0M)]
[Times: user=0.00 sys=0.00, real=0.01 secs]

常用参数

这里只列出了一些最基础的参数。大多数情况下的GC调优,调的只是一些内存/比例/时间,对各种线程数调整的场景很少,更多的是基于GC日志来分析修改代码,所以一般默认参数下也足够了。

# 启动G1
-XX:+UseG1GC

# 最小堆内存
-Xms8G

# 最大堆内存
-Xmx8G

# metaspace初始值
-XX:MetaspaceSize=256M

# 期望的最大暂停时间,默认200ms
-XX:MaxGCPauseMillis

# 简称为IHOP,默认值为45,这个值是启动并发标记的阈值,当老年代使用内存占用堆内存的45%启动并发标记。
# 如果该过大,可能会导致mixed gc跟不上内存分配的速度从而导致full gc
-XX:InitiatingHeapOccupancyPercent

# G1自动调整IHOP的指,JDK9之后可用
-XX:+G1UseAdaptiveIHOP

# 并发标记时可以卸载Class,这个操作比较耗时,对Perm/MetaSpace进行清理,默认未开启
-XX:+ClassUnloadingWithConcurrentMark

# 多个线程并行执行java.lang.Ref.*,对象回收前的引用处理
-XX:-ParallelRefProcEnabled

和其他回收器的对比

  • 和 Parallel GC 相比,G1 的增量并行回收暂停时间更短
  • 和 CMS 相比,G1 由于采用移动式算法,所以没有碎片问题,更有效的利用了内存

总结

G1 的全称是 Garbage First,意思是 “垃圾优先”。什么叫垃圾优先呢?

在并发标记时,会根据存活对象的数量 / 大小,对标记的区域进行降序排序。到了移动过程时,就会优先选择移动效率高(垃圾多,存活对象少,需要移动的就少)的区域作为回收集合,这就是 Garbage First 命名的由来

但 G1 并不属于一个高效率的回收器,对老年代使用移动式的回收算法,虽然没有碎片问题,但效率是较低的。因为老年代对象大多数是存活的,所以每次回收需要移动的对象很多。而清除算法中是清除死亡的对象,所以从效率上来看,清除算法在老年代中会更好。

但是由于 G1 这个可控制暂停的增量回收,可以保证每次暂停时间在允许范围内,对于大多数应用来说,暂停时间比吞吐量更重要。再加上 G1 的各种细节优化,效率已经很高了。

垃圾回收系列贴(完整可运行 C 语言代码)

参考 & 资料

原创不易,转载请在开头著名文章来源和作者。如果我的文章对您有帮助,请点赞收藏鼓励支持。原文链接 segmentfault.com/a/119000003…