JVM系列之垃圾回收器(中篇)——G1的运行原理以及调优思路

3,687 阅读15分钟

扫描下方二维码或者微信搜索公众号菜鸟飞呀飞,即可关注微信公众号,阅读更多Spring源码分析Java并发编程Netty源码系列MySQL工作原理JVM专题系列文章。

微信公众号
微信公众号

1. G1 垃圾回收器

Garbage First 简称 G1,是继 CMS 垃圾回收器之后,又一款并发的垃圾回收器,在 JDK7 中被去掉 Experimental 标识,开始可以被正式使用,在 JDK9 中被 JVM 设置为默认的垃圾回收器。G1 是垃圾收集器发展史上的一个新的里程碑,它采用分区算法,基于 Region 的内存布局方式,对整个堆内存进行局部回收,既能回收新生代,也能回收老年代。G1 垃圾回收器的目标是在期望的停顿时间内,尽可能地提高系统的吞吐量。

2. G1 的特点

与上篇文章(JVM 系列之经典垃圾回收器(上篇))中提到的 6 款垃圾回收器相比,G1 垃圾回收器在垃圾回收过程中,不仅支持并行,还支持并发。另外 G1 在内存布局以及实现思路上,与前面介绍的垃圾回收器具有非常大的不同之处。

2.1 region 分区

虽然 G1 仍然遵循分代收集理论,但是 G1 不再坚持固定大小、固定数量的分代区域划分,而是将整个内存区域划分为若干个大小相等的独立小区域(Region),每个 Region 都能扮演 Eden、Survivor、Old 区。新生代和老年代的内存在物理上不再是连续的,而是逻辑上处于连续。示意图如下。

G1分区示意图
G1分区示意图

在 G1 中,新增了一个 H 区的概念,如果一个对象的大小超过了一个 Region 的 50%,那么该对象就会被直接存放进 H 区。如果一个 Region 无法存放下对象,那么就会采用连续的多个 Region 来存放该超大对象。

每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设置,取值范围为 1MB~32MB,且为 2 的整数次幂。通常情况下,G1 会将堆内存划分为 2048 个 Region,如果我们指定堆内存的大小为 4G ,那么每个 Region 的大小为 2MB。

2.2 停顿时间

G1 的另外一大特点是可以设置一个期望的停顿时间,然后在期望的停顿时间内,对一部分 Region进行垃圾回收。在平时的工作中,我们经常为 JVM 设置合理的内存大小,优化部分参数,其实就是为了尽量减少 Minor GC 和 Full GC,从而减少系统的停顿时间,而 G1 垃圾回收器直接我们提供了最大停顿时间这个参数(-XX:MaxGCPauseMillis)。

那么 G1 是如何实现在期望的停顿时间内,完成垃圾回收的呢?

实际上,在系统运行过程中,G1 会收集每个 Region 的回收耗时、垃圾占比等各个可测量的信息,然后计算回收每个 Region 带来的收益大小(可回收的内存+回收耗时),通过维护一个优先级列表,然后在设置的最大停顿时间内,回收那些能带来最大收益的 Region。

虽然 G1 为我们提供了最大停顿时间这个参数,但是我们也不能异想天开的认为,这个参数设置得越小越好。如果设置得太小,那么会因为每次 GC 可以停顿的时间太少,导致每次 GC 只能回收极少的 Region,如果此时内存的分配速度大于 Region 回收的速度,那么在系统初期,可能会因为还有空闲内存可以支撑一段时间,但是时间一长,就会导致空闲内存越来越少,最终触发 Full GC,从而导致系统停顿时间更长。

2.3 并发执行

在并发标记阶段,G1 的垃圾回收线程和用户线程,是并发执行的,那么 G1 是如何保证垃圾回收线程与用户线程互不干扰的呢?在 CMS 中,采用的是增量收集算法,而在 G1 中采用的原始快照算法(SATB)。

2.4 运行流程

如果不考虑在垃圾回收过程中,用户线程的运行动作(如使用写屏障来维护记忆集等操作),那么 G1 的运行流程大致可以分为如下四个步骤:初始标记、并发标记、最终标记、筛选回收。

  1. 初始标记。仅仅只是标记出 GC Roots 直接关联的对象(此时当前 Region 中的记忆集也会被当做是 GC Roots),并且还会修改 TAMS 指针,让下一阶段用户线程并发执行时,能够正确的在可用的 Region 中分配新对象。这一步会造成 STW,但是由于只标记和 GC Roots 直接相连的对象,所以暂停时间很短,具体暂停多长时间,和 GC Roots 的数量有关。另外由于该阶段是借用进行 Minor GC 时同步完成的,因此不会额外造成停顿。

  2. 并发标记。从上一步标记出的对象出发,遍历整个对象图,这一步耗时较长,但是由于是和用户线程并发执行,因此不会造成 STW。

  3. 最终标记。由于在并发标记阶段,垃圾回收线程和用户线程并发执行,因此在这一过程中,可能会由于用户线程改变了对象的引用关系,造成对象”消失“,因此还需要重新处理 SATB(原始快照)记录下在并发阶段有引用关系改动的对象,这一过程就是在最终标记阶段完成的,会造成 STW,否则如果用户线程还一直进行,就会不停地造成对象引用关系的改变,我们就得不停的处理 SATB 记录。虽然会造成 STW,但毕竟 SATB 记录的引用改变的对象不会特别多,因此耗时比并发标记阶段的耗时会少很多。在这一步中,如果发现当前 Region 中的所有对象都是垃圾对象,那么就会立即对当前 Region 进行回收。

  4. 筛选回收。负责更新 Region 的统计数据,根据每个 Region 的回收价值和成本进行排序,然后根据用户期望停顿的时间内来指定回收计划,可以选择多个 Region 构成回收集,然后采用复制算法,将 Region 中存活的对象复制到空闲的 Region 中,从而回收 Region。

G1运行示意图
G1运行示意图

整体上看,G1 垃圾回收的 4 个阶段,只有并发标记阶段不会造成 STW,其他阶段都会产生 STW,因此它并非纯粹的追求低延时。

关于上面提到的记忆集、对象”消失"、TAMS 指针、SATB(原始快照) 等概念,有兴趣的朋友可以自行上网查阅,内容较多,这里就不展开说明。

2.5 优缺点

与同样具有低延时的垃圾回收器 CMS 相比,G1 既有优点也有缺点。

首先,G1 中可以指定最大停顿时间、对内存进行 Region 分区、按照收益动态进行垃圾回收,这些特性带来的红利都是 CMS 所不具有的。另外,G1 垃圾回收器从局部看,采用的的是复制算法,将一个 Region 中存活的对象复制到另一个 Region 中;从整体上看,G1 回收器采用的是标记-压缩(整理)算法。这两种算法最终都不会带来内存碎片,这有利于系统的长时间运行。而 CMS 则是采用的是标记-清除算法,会带来内存碎片,当连续内存不足以分配一个对象时,会产生 Full GC。

虽然 G1 的优点很多,但是它还不足以完全替代 CMS,它也存在在很明显的缺点。

G1 的内存占用相对而言,比较大。G1 堆内存采用 Region 分区设计,每个 Region 中都存在一个记忆集,而其他传统的垃圾回收器中,整个堆内存只需要维护一份记忆集即可,因此 G1 中记忆集所占用的内存相比传统的垃圾回收器而言,会大很多。加上其他内存消耗,G1 所占用的内存空间可能达到堆内存的 20%,甚至更多。(这个数据参考自周志明《深入理解 Java 虚拟机》第三版)。

G1 对系统造成的负载较高。G1 和 CMS 都是用到了写屏障来维护记忆集,不同的是,CMS 使用了写后屏障来维护记忆集,而 G1 在设计上由于更复杂,不仅使用了写前屏障还使用了写后屏障。G1 中写前屏障用来跟踪并发时的指针变化,从而实现 SATB(原始快照算法),使用写后屏障来维护记忆集中的卡表。由于 G1 对写屏障的复杂操作比 CMS 会消耗更多的资源,因此在 CMS 中,直接使用同步操作来实现写屏障,而在 G1 中不得不使用类似于队列的数据结构来实现写前屏障和写后屏障,进行异步处理。

在重新标记阶段,CMS 使用的是增量更新算法,而 G1 使用的是 SATB(原始快照)算法,原始快照搜索能够减少在并发标记阶段和最终标记阶段的时间消耗,避免像 CMS 在最终标记阶段停顿时间过程的缺点,但是原始快照算法会使系统的负载加重。

总的来说,G1 并不能在任何场景下取代 CMS,G1 更适合在大内存的机器中使用,CMS 更适合在小内存机器中使用,这个内存大小的界限大概为 6~8G。(这个数值也是参考自周志明《深入理解 Java 虚拟机》第三版一书)。

3. G1 垃圾回收器的运行细节

G1 垃圾回收器既能回收新生代,又能回收老年代,那么究竟在什么情况下会触发新生代 GC,什么情况下触发老年代 GC 呢?

3.1 什么时候触发新生代 GC

在 G1 中,Eden、Survivor、老年代的大小是在动态变化的。在初始时,新生代占整个堆内存的 5%,可以通过参数G1NewSizePercent设置,默认值为 5。

在 G1 中,虽然进行了 Region 分区,但是新生代依旧可以被分为 Eden 区和 Survivor 区,参数 SurvivorRatio 依旧表示 Eden/Survivor 的比值。

随着系统的运行,Eden 区的对象越来越多,当达到 Eden 区的最大大小时,就会触发 Minor GC。新生代的最大大小默认为整个堆内存的 60%,可以通过参数G1MaxNewSizePercent控制,默认值为 60。

G1 垃圾回收器在进行新生代的垃圾回收时,会采用复制算法来回收垃圾,不用考虑并发的场景,全程都是 STW,它会根据设置的停顿时间,尽可能的最大效率的回收新生代区域。

3.2 什么时候进入到老年代

新生代的对象要进入老年代,需要达到以下两个条件中的其中之一即可。

  1. 多次躲过新生代的回收,对象年龄达到MaxTenuringThreshold,该参数默认值为 15。 在每次 Minor GC 时,新生代的对象如果存活,会被移动到 Survivor 区中,同时会将对象的年龄加 1,当对象的年龄达到 MaxTenuringThreshold 后,就被被移到老年代中。

  2. 符合动态年龄判断规则。如果某次 Minor GC 过后,发现 Survivor 区中相同年龄的对象达到了 Survivor 的 50%,那么该年龄及以上的对象,会被直接移动到老年代中。 例如某次 Minor GC 过后,Survivor 区中存在年龄分别为 1、2、3、4 的对象,而年龄为 3 的对象超过了 Survivor 区的 50%,那么年龄大于等于 3 的对象,就会被全部移动到老年代中。

3.3 什么时候触发混合 GC

在 G1 中,不存在单独回收老年代的行为,而是当要发生老年代的回收时,同时也会对新生代以及大对象进行回收,因此这个阶段称之为混合回收(Mixed GC)

当老年代对堆内存的占比达到 45%时,就会触发混合回收。可以通过参数InitiatingHeapOccupancyPercent来设置当堆内存达到多少时,触发混合 GC,该参数的默认值为 45。

当触发混合 GC 时,会依次执行初始标记(在 Minor GC 时完成)、并发标记、最终标记、筛选回收这四个过程。最终会根据设置的最大停顿时间,来计算对哪些 Region 区域进行回收带来的收益最大。

实际上,在筛选回收阶段,可以分多次回收 Region,具体多少次可以通过参数G1MixedGCCountTarget控制,默认值为 8 次。具体什么意思呢?

假如现在有 80 个 Region 需要被回收,因为筛选回收阶段会造成 STW,如果一下子全部回收这 80 个 Region,可能造成的停顿时间较长,因此 JVM 会分 8 次来回收这些 Region,每次先回收 10 个 Region,然后让用户线程执行一会,接着再让 GC 线程回收 10 个 Region,直至回收完这 80 个 Region,这样尽可能的降低了系统的暂停时间。

G1 垃圾回收器的回收思路是:不需要对整个堆进行回收,只需要保证垃圾回收的速度大于内存分配的速度即可。因此在每次进行 Mixed GC 时,虽然我们设置了停顿时间,但是当回收得到的空闲 Region 数量达到了整个堆内存的 5%,那么就会停止回收。可以由参数G1HeapWaterPercent控制,默认值为 5%。

另外,在混合回收的过程中,由于使用的是复制算法,因此当一个 Region 中存活的对象过多的话,复制这个 Region 所耗费的时间就会较多,因此 G1 提供了一个参数,用来控制当存活对象占当前 Region 的比例超过多少后,就不会对该 Region 进行回收。该参数为G1MixedGCLiveThresholdPercent,默认值为 85%。

3.4 触发 Full GC

在进行混合回收时,使用的是复制算法,如果当发现空闲的 Region 大小无法放得下存活对象的内存大小,那么这个时候使用复制算法就会失败,因此此时系统就不得不暂停应用程序,进行一次 Full GC。进行 Full GC 时采用的是单线程进行标记、清理和整理内存,这个过程是非常漫长的,因此应该尽量避免 Full GC 的触发。

4. 调优思路

介绍了这么多 G1 相关的知识,而然实际上 G1 用起来却十分简单:-XX:+UseG1GC,难的是 JVM 的系统调优。G1 垃圾回收器中最重要的一个参数是:MaxGCPauseMillis,而要对 G1 进行调优,大概率就是结合系统的实际情况,来调整 MaxGCPauseMillis 的值。

该值设置的太小,虽然在 GC 回收时停顿时间较短,但是每次回收的 Region 也会较少,如果内存分配速度过快,就需要频繁的进行 GC,当回收速度跟不上内存分配速度时,会造成 Full GC。

如果设置得过大,那么虽然每次回收可以获得的空闲 Region 较多,但是系统停顿时间会过长,也不好。因此需要结合系统的实际情况,通过相关的工具,实时查看系统的内存情况,从而决定如何调整该参数。

另外应该尽量减少 Mixed GC 发生的次数。触发 Mixed GC 的条件是老年代占用堆内存到达 45%时,因此可以适当地调大该值。不建议使用,尽量使用默认值即可。

我们可以从源头上考虑,触发混合 GC 是因为老年代对象过多,而老年代的对象从哪儿来的?当 Survivor 区中的对象年龄达到阈值或者存活的对象数量太多,导致 Survivor 无法容纳下,最终使对象进入到老年代。

如果 MaxGCPauseMillis 设置得过大,会导致很久才进行一次新生代回收,由于新生代的对象积攒过多,存活的对象数量也可能比较多,当 Survivor 无法存放下时,可能触发动态年龄判断条件,从而导致对象直接进入到老年代中,进而导致 Mixed GC。

如果 MaxGCPauseMillis 设置得过小,导致新生代回收频繁,存活对象的年龄增长过快,从而进入到老年代,又会造成 Mixed GC。

因此想要减少 Mixed GC 发生的次数,其核心也是需要控制 MaxGCPauseMillis 参数的大小。

关于 G1 垃圾回收器,它有很多参数可以进行设置,在具体使用过程中,如何进行调优,需要结合实际情况进行设置。这里笔者只是提供一个思路,个人认为MaxGCPauseMillis参数是 G1 调优的核心,且能对哪些参数进行调优的前提是:需要明白 G1 垃圾收集器的工作原理以及这些参数对 G1 是如何影响的。

5. 总结

本文主要介绍了 G1 垃圾收集器的工作原理,以及相关特点,如 Region 分区、可控的停顿时间等,相比较另外 6 款经典的垃圾回收器,这些新的特性促使 G1 的回收效率更高,应用更加广泛。

最后结合 G1 的工作原理,提供了一种 G1 的调优思路:结合实际情况调整 MaxGCPauseMillis 参数的值。

微信公众号
微信公众号