转载

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

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

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

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 区。新生代和老年代的内存在物理上不再是连续的,而是逻辑上处于连续。示意图如下。

JVM系列之垃圾回收器(中篇)——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。

JVM系列之垃圾回收器(中篇)——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 参数的值。

JVM系列之垃圾回收器(中篇)——G1的运行原理以及调优思路
微信公众号
原文  https://juejin.im/post/5ef73af55188252e8e650ed7
正文到此结束
Loading...