0%

Java - G1 GC

G1(Garbage First) GC,是JDK9中的默认收集器,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。G1的设计目标是取代CMS收集器(CMS收集器在JDK9中已经被废弃),与CMS相比,G1在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片;
  • G1的STW(stop the world)耗时更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

内存划分

G1也是采用的分代收集的方式,只不过它的分代与常规的分代有点区别:

1

常规的垃圾收集器中,各个分代的内存地址都是连续的,而在G1中,G1将堆内存划分为若干个块,每一个方块都是一个Region,每一个Region都有一个分代的角色:eden、survivor、old、humongous,而且各个分代的Region不需要地址连续。G1对每个角色的Region数量并没有强制的限定,也就是说对每种分代内存的大小,是可以动态变化的,默认配置下年轻代会占整个堆内存的5%。

humongous是一个新概念,归属于老年代,如果一个对象占用的空间超过了Region容量的50%以上,G1收集器就认为这是一个巨型对象,默认直接会被分配在年老代。

但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响,为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。

如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

默认情况下一共有2048个Region,如果没有使用参数-XX:G1HeapRegionSize指定Region的大小,则Region默认大小范围为1MB~32MB,会根据堆的大小参数,计算出一个合理的Region大小(Region大小只能是2的指数)。

分代GC

G1算法中,一共有三种GC:

  • Young GC:负责收集年轻代垃圾收集;

  • Mix GC:负责年轻代以及部分老年代垃圾收集;

  • Serial Old GC:当Mixed GC无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会切换到Serial Old GC来做Full GC。

    System.gc()默认使用Serial Old GC来做Full GC,只有加上-XX:+ExplicitGCInvokesConcurrent时,G1才会用自身的并发GC来执行System.gc()。此时System.gc()的作用是强行启动一次global concurrent marking,一般情况下暂停中只会做initial marking然后就返回了,接下来的concurrent marking还是照常并发执行。

Young GC

G1 GC提供了一个单独进行Young GC的收集器,其回收的是所有年轻代的Region,GC工作时需要STW。当E区不能再分配新的对象时会触发Young GC。

Young GC过程中,E区的对象会移动到S区,当S区空间不够的时候,E区的对象会直接晋升到O区,同时S区的数据移动到新的S区,如果S区的部分对象到达一定年龄,也会晋升到O区。整个GC逻辑与常规分代收集算法的年轻代GC差不多。

RSet

在做Young GC的时候,如果有老年代对象引用了年轻代对象,那么这些老年代对象也会被作为GC Roots的一部分,但是如果全量扫描老年代的对象,这个开销就太大了,G1使用RSet算法来解决这个问题。

G1中的RSet机制与CMS中的Card Table的目的类似,都是辅助找到非收集区的GC Roots,不过实现上有点区别,CMS中是Card Table中记录了本对象指向年轻代的对象的记录,这是一种points-out的做法,而G1中采用的是points-into的做法,即是记录有哪些对象引用了当前Region中的对象。

在G1中,每个Region会被划分成多个Card,并且每个Region都有一个RSet,RSet是一个哈希表,key是Region的起始地址,value是一个字节数组,字节数组下标表示Card的空间地址。

下图中Region1和Region3中有对象引用了Region2的对象,则在Region2的Rset中会记录Region1和Region3引用对Region2的引用(只有垮分代的引用才会被记录)。

2

更详细的G1收集器中的RSet的实现方式,可以阅读G1垃圾收集器之RSet

在做Young GC的时候,只需要扫描年轻代的Region的RSet作为根集,这些RSet记录了老年代对年轻代的对象引用,避免了扫描整个老年代去寻找垮分代引用的那部分GC Roots。

日志分析

一次Young GC的日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2020-03-07T19:12:29.110-0800: [GC pause (G1 Evacuation Pause) (young), 0.0028170 secs]
[Parallel Time: 1.7 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 323.0, Avg: 323.0, Max: 323.3, Diff: 0.3]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.4, Sum: 1.4]
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.4, Diff: 0.4, Sum: 0.4]
[Object Copy (ms): Min: 0.5, Avg: 0.8, Max: 1.4, Diff: 1.0, Sum: 6.4]
[Termination (ms): Min: 0.0, Avg: 0.5, Max: 0.7, Diff: 0.7, Sum: 4.4]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 1.3, Avg: 1.6, Max: 1.7, Diff: 0.4, Sum: 12.7]
[GC Worker End (ms): Min: 324.6, Avg: 324.6, Max: 324.7, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.9 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.7 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 24.0M(24.0M)->0.0B(32.0M) Survivors: 0.0B->4096.0K Heap: 24.0M(100.0M)->2272.0K(100.0M)]

其中,几个关键的信息:

  • 2020-03-07T19:12:29.110-0800:GC发生的时间
  • [GC pause (G1 Evacuation Pause):
  • (young):这次GC的类型,年轻代;
  • 0.0028170 secs:此次GC耗时;
  • GC Workers:GC的工作线程数;
  • Eden: 24.0M(24.0M)->0.0B(32.0M):
    • 24.0M(24.0M):GC之前,Eden区的占用量(Eden区的总大小);
    • 0.0B(32.0M):GC之后,Eden区的占用量(Eden区在GC之后的新的总大小);
  • Survivors: 0.0B->4096.0K:GC之前,S区的占用量(GC之后,S区的占用量)
  • Heap: 24.0M(100.0M)->2272.0K(100.0M)
    • 24.0M(100.0M):GC之前,整个堆的占用量(整个堆的总大小)
    • 2272.0K(100.0M):GC之后,整个堆的占用量(整个堆的总大小)

Mix GC

除了Young GC之外,G1 GC还提供了Mix GC的模式,其触发条件也是由一些参数控制。比如XX:InitiatingHeapOccupancyPercent表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC。

Mixed GC并不是只针对年轻代,或者老年代,或者整个堆,而是回收所有的年轻代+部分的老年代,大体上分为两步:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

全局并发标记(global concurrent marking)

全局并发标记过程一共分为如下五个阶段,大致思路与CMS的思路相似:

  • 初始标记(Initial Mark)(STW)

    扫描S区,标记出那些可能对老年代对象有引用的Region,这些Region被定义为Root Region。

    很多Blog中说到第一步是标记所有的GC Roots,我觉得不太准确,从来看,第一步是标记GC Root Regions,标记的是Regions,而不是对象。

    注意,初始标记需要STW,但是通常在Young GC启动的时候跟着一起启动,这样可以借用Young GC的STW,就不用再单独触发一次STW,尽可能降低STW的时间。

    事实上,当达到参数配置的Mix GC触发阈值时,G1并不会立即发起并发标记周期,而是等待下一次Young GC,利用年轻代收集的STW,完成初始标记,这种方式称为借道(Piggybacking)。

  • 根区域扫描(Root Region Scanning)

    通过第一步扫描到的Root Regions,标记那些引用了老年代对象的存活对象。只有完成该阶段后,才能开始下一次STW的Young GC。

  • 并发标记(Concurrent Marking)

    这个阶段从前面两个阶段标记的对象开始,利用可达性分析,查找整个堆中存活的对象。注意,这个阶段是可能被Young GC打断的。

  • 最终标记(Remark)(STW)

    由于前面两个步骤都不需要STW,所以在扫描标记的过程中,可能会有用户线程破坏扫描到的对象引用关系,因此需要一个STW的并发标记阶段,来弥补并发阶段用户线程的影响。

    这个阶段利用STAB(Snapshot At The Beginning)算法来记录并发阶段用户线程对象标记的影响,并在STW的前提下,最终完成存活对象的扫描。

    CMS中采用的是Incremental update的方式来处理并发阶段的冲突问题,STAB与Incremental update有很多关联之处,关于STAB的详细算法,以及二者的区别,可以参考文末R大的回答,解释得很详细了。

    G1与CMS的Remark阶段虽然目的相同,但是实现上有一个本质上的区别,G1的Remark只需要扫描SATB算法中缓存的数据,而CMS的Remark需要重新扫描Dirty Card外加整个根集合,而此时整个年轻代(不管对象死活)都会被当作根集合的一部分,因而CMS的Remark有可能会非常慢。

  • 清除(Cleanup)(STW)

    这个阶段并不会清除堆中的实际对象,而是统计每个Region中有多少标记为存活的对象,完全没有存活对象的Region就会将其整体回收到可分配region列表中。

拷贝存活对象(evacuation)(STW)

经过上面的标记之后,我们就知道哪些Region里面有存活对象,Evacuation阶段采用了“复制”算法的思路,将Region里面存活的对象拷贝到空的Region里面,然后回收原本的Region。

此阶段会自由选择任意多个Region构成收集集合(collection set,简称CSet)来独立收集。CSet选定后,采用并行的方式把CSet里每个Region里的活对象拷贝到新的Region里,整个过程都需要STW。

由于标记完成之后,JVM知道每个Region中的存活对象的情况,因此在做拷贝的时候,可以优先回收高收益的Region,从而使得在固定一段时间内的GC工作中的收益最高。

日志分析

一次随着Young GC的全局并发标记日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2020-03-07T19:26:00.238-0800: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0049115 secs]
[Parallel Time: 4.1 ms, GC Workers: 8]
// ... 省略YoungGC
[Eden: 16.0M(16.0M)->0.0B(8192.0K) Survivors: 4096.0K->4096.0K Heap: 70.0M(100.0M)->66.0M(100.0M)]
[Times: user=0.01 sys=0.01, real=0.01 secs]
2020-03-07T19:26:00.243-0800: [GC concurrent-root-region-scan-start]
2020-03-07T19:26:00.243-0800: [GC concurrent-root-region-scan-end, 0.0001139 secs]
2020-03-07T19:26:00.243-0800: [GC concurrent-mark-start]
2020-03-07T19:26:00.244-0800: [GC concurrent-mark-end, 0.0011904 secs]
2020-03-07T19:26:00.244-0800: [GC remark [Finalize Marking, 0.0001910 secs] [GC ref-proc, 0.0000782 secs] [Unloading, 0.0005380 secs], 0.0009553 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
2020-03-07T19:26:00.245-0800: [GC cleanup 67M->59M(100M), 0.0002635 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
2020-03-07T19:26:00.245-0800: [GC concurrent-cleanup-start]
2020-03-07T19:26:00.245-0800: [GC concurrent-cleanup-end, 0.0000102 secs]

全局并发标记的initial-mark阶段,会伴随着Young GC的STW一起运行。

一次Mix GC的日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2020-03-07T19:26:00.353-0800: [GC pause (G1 Evacuation Pause) (mixed), 0.0042019 secs]
[Parallel Time: 3.3 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 936.1, Avg: 936.2, Max: 936.3, Diff: 0.2]
[Ext Root Scanning (ms): Min: 0.2, Avg: 0.3, Max: 0.4, Diff: 0.2, Sum: 2.3]
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Processed Buffers: Min: 0, Avg: 0.2, Max: 1, Diff: 1, Sum: 2]
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.4, Diff: 0.3, Sum: 0.7]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 1.0, Avg: 1.6, Max: 2.7, Diff: 1.7, Sum: 12.7]
[Termination (ms): Min: 0.0, Avg: 1.1, Max: 1.4, Diff: 1.4, Sum: 8.6]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 3.0, Avg: 3.1, Max: 3.2, Diff: 0.2, Sum: 24.5]
[GC Worker End (ms): Min: 939.2, Avg: 939.3, Max: 939.3, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.3 ms]
[Other: 0.7 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.3 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.2 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 4096.0K(4096.0K)->0.0B(4096.0K) Survivors: 4096.0K->4096.0K Heap: 70.0M(100.0M)->57.2M(100.0M)]

停顿时间预测

G1是一个响应时间优先的垃圾收集器,用户可以通过-XX:MaxGCPauseMillis参数来设置一次GC的目标停顿时间,默认值是200ms(这是一个期望值,并非一个确定值)。G1通过全局并发标记,能够知道Region中的存活对象情况,并结合历史数据,预测本次收集需要选择的Region数量,从而尽量满足用户设定的时间要求。

但是毕竟G1也需要STW来执行工作,所以这个目标停顿时间再怎么低也是有限制的,大部分情况下,几十到一百甚至两百毫秒都很正常的。所以切记不要把目标停顿时间设得太低,不然G1跟不上目标就容易导致垃圾堆积,反而更容易引发Full GC而降低性能。通常设到100ms、250ms之类的都可能是合理的。设到50ms就不太靠谱,G1可能一开始还跟得上,跑的时间一长就开始乱来了。

参考



-=全文完=-