深入学习G1垃圾收集器

G1 (Garbage-First) 垃圾收集器在是Java9的默认垃圾收集器。G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量。

如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择。

从分代上看,G1依然属于分代垃圾回收器,它会区分年代和老年代,依然有eden和survivor区,但从堆的结构上看,它并不要求整个eden区、年轻代或者老年代都连续。

分代收集器将堆分为年轻代、老年代和永久代,每个代空间都是确定的。

Hotspot 分代收集器堆结构

而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),在jvm启动时会设置 region 的数量,取决于堆的大小,每个 region 的大小可在 1MB ~ 32 MB调整,最好不要超过2048个 region 。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。

G1 堆结构

执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么被取名为 Garbage-First 的原因。

G1 工作流程

G1垃圾收集器将堆进行分区,划分为一个个的区域,每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生一次停顿时间。

G1的收集过程可能有4个阶段:

  1. 新生代GC
  2. 并发标记周期
  3. 混合式GC
  4. (如果需要)进行Full GC。

新生代GC

G1收集器堆结构

G1 中的young gc

年轻代收集概念上和之前介绍的其他分代收集器大差不差的,但是它的年轻代会动态调整。

并发标记周期

接下来是 Old GC 的流程(含 Young GC 阶段),其实把 Old GC 理解为并发周期是比较合理的,不要单纯地认为是清理老年代的区块,因为这一步和年轻代收集也是相关的。下面我们介绍主要流程:

  1. 初始标记: stop-the-world,它伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。

因为 Young GC 是需要 stop-the-world 的,所以并发周期直接重用这个阶段,虽然会增加 CPU 开销,但是停顿时间只是增加了一小部分。

  1. 扫描根引用区: 因为先进行了一次 YGC,所以当前年轻代只有 Survivor 区有存活对象,它被称为根引用区。扫描 Survivor 到老年代的引用,该阶段必须在下一次 Young GC 发生前结束。

这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。

  1. 并发标记: 寻找整个堆的存活对象,该阶段可以被 Young GC 中断。

这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程

  1. 重新标记: stop-the-world,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

Oracle 的资料显示,这个阶段会回收完全空闲的区块

  1. 清理: 清理阶段真正回收的内存很少。

到这里,G1 的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。因为整堆一般比较大,所以这个周期应该会比较长,中间可能会被多次 stop-the-world 的 Young GC 打断。

混合垃圾回收周期

在并发标记周期中,虽有部分对象被回收,但是回收的比例是非常低的。但是在并发标记周期后,G1已经明确知道哪些区域含有比较多的垃圾对象,在混合回收阶段,就可以专门针对这些区域进行回收。当然G1会优先回收垃圾比例较高的区域(回收这些区域的性价比高),这正是G1名字的由来(Garbage First Garbage Collector:译为垃圾优先的垃圾回收器),这里的垃圾优先(Garbage First)指的是回收时优先选取垃圾比例最高的区域。

这个阶段叫做混合回收,是因为在这个阶段,即会执行正常的年轻代GC,又会选取一些被标记的老年代区域进行回收,同时处理了新生代和老年代。

混合回收会被执行多次,直到回收了足够多的内存空间,然后,它会触发一次新生代GC。新生代GC后,又可能会发生一次并发标记周期的处理,最后又会引起混合回收,因此整个过程可能是如下图:

G1 GC 过程

Full GC

到这里我们已经说了年轻代收集、并发周期、混合回收周期了,大家要熟悉这几个阶段的工作。

下面我们来介绍特殊情况,那就是会导致 Full GC 的情况,也是我们需要极力避免的:

并发模式失效 (concurrent mode failure)

G1启动标记周期,但在Mix GC之前,老年代就被填满,这时候G1会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads等)

GC日志如下:

解决办法:

  1. 堆需要增加了,
  2. 或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束
  3. 或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用就开始进行并发周期。

晋升失败 (to-space exhausted或者to-space overflow)

G1收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。(G1在进行GC的时候没有足够的内存供存活对象或晋升对象使用),由此触发了Full GC。

下面日志中(可以在日志中看到(to-space exhausted)或者(to-space overflow)),反应的现象是混合式GC之后紧接着一次Full GC。

这种失败通常意味着混合式收集需要更迅速的完成垃圾收集:每次新生代垃圾收集需要处理更多老年代的分区。

解决办法:

  1. 增加 -XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
  2. 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
  3. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目

疏散失败 (to-space exhausted或者to-space overflow)

进行新生代垃圾收集是,Survivor空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在GC日志中通常是:

解决办法:

  1. 增加 -XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
  2. 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
  3. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。

大对象分配失败 (Humongous Object 分配失败)

当Humongous Object 找不到合适的空间进行分配时,就会启动Full GC,来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象。

Humongous Object:巨型对象
Humongous regions:巨型区域

对于G1而言,只要超过regin大小的一半,就被认为是巨型对象。巨型对象直接被分配到老年代中的“巨型区域”。这些巨型区域是一个连续的区域集。StartsHumongous 标记该连续集的开始,ContinuesHumongous 标记它的延续。

在分配巨型对象之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 Full GC。

对于巨型对象,有以下几个点需要注意:

  1. 没有被引用的巨型对象会在标记清理阶段或者Full GC时被释放掉。
  2. 为了减少拷贝负载,只有在Full GC的时候,才会压缩大对象region。
  3. 每一个region中都只有一个巨型对象,该region剩余的部分得不到利用,会导致堆碎片化。
  4. 如果看到由于大对象分配导致频繁的并发回收,需要把大对象变为普通的对象,建议增大Region size。(或者切换到ZGC)

对于增大Region size有一个负面影响就是:减少了可用region的数量。因此,对于这种情况,你需要进行相应的测试,以查看是否实际提高了应用程序的吞吐量或延迟。

常见参数

  1. -XX:+UseG1GC

使用 G1 收集器

  1. -XX:MaxGCPauseMillis=200

指定目标停顿时间,默认值 200 毫秒。

在设置 -XX:MaxGCPauseMillis 值的时候,不要指定为平均时间,而应该指定为满足 90% 的停顿在这个时间之内。记住,停顿时间目标是我们的目标,不是每次都一定能满足的。

  1. -XX:InitiatingHeapOccupancyPercent=45

整堆使用达到这个比例后,触发并发 GC 周期,默认 45%。

如果要降低晋升失败的话,通常可以调整这个数值,使得并发周期提前进行

  1. -XX:NewRatio=n

老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代

不要设置年轻代为固定大小,否则:
G1 不再需要满足我们的停顿时间目标
不能再按需扩容或缩容年轻代大小

  1. -XX:SurvivorRatio=n

Eden/Survivor,默认值 8,这个和其他分代收集器是一样的

  1. -XX:MaxTenuringThreshold =n

从年轻代晋升到老年代的年龄阈值,也是和其他分代收集器一样的

  1. -XX:ParallelGCThreads=n

并行收集时候的垃圾收集线程数

  1. -XX:ConcGCThreads=n

并发标记阶段的垃圾收集线程数

增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到:

ConcGCThreads=(ParallelGCThreads + 2) / 4^3

  1. -XX:G1ReservePercent=n

堆内存的预留空间百分比,默认 10,用于降低晋升失败的风险,即默认地会将 10% 的堆内存预留下来。

  1. -XX:G1HeapRegionSize=n

每一个 region 的大小,默认值为根据堆大小计算出来,取值 1MB~32MB,这个我们通常指定整堆大小就好了。

参考

  1. https://www.oracle.com/technical-resources/articles/java/g1gc.html
  2. https://juejin.im/post/5d346aedf265da1bbc701332#heading-2
  3. https://www.javadoop.com/post/g1#toc_4

深入学习G1垃圾收集器
https://www.weypage.com/2020/06/03/java/jvm/G1垃圾收集器/
作者
weylan
发布于
2020年6月3日
许可协议