25

概述

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,如下图所示:
image.png

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

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

可预测

G1的执行流程

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

image.png

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中的堆结构如下图所示:
image.png

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

RememberedSet

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

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

image.png

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

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

image.png

所以在对象晋升的时候,将晋升对象记录下来,这个存储跨区引用关系的容器称之为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。
image.png

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

介绍完了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记录了这个引用关系,这样就完成了这个跨区域引用的记录。
image.png

不过这个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区域,然后清空原区域
image.png

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

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

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

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

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

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

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

image.png

步骤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对象,添加到标记栈中
image.png

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

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

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

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

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

image.png

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

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

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

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

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

并发标记

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

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

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

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

标记位图

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

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

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

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

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

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

初始标记(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语言代码)

参考 & 资料

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


空无
3.3k 声望4.3k 粉丝

坚持原创,专注分享 JAVA、网络、IO、JVM、GC 等技术干货