深入解析G1垃圾收集器与性能优化

本文详细介绍G1垃圾收集器的参数配置,如何进行性能调优, 以及怎样对GC性能进行分析和评估。

0. G1简介

G1的全称为 Garbage First Garbage Collector, 是一款内置在HotSpot JVM 中的服务端垃圾收集器。
G1使用【分代算法】, 将GC过程拆解为多个并发和并行阶段,将暂停时间打散,从而实现了低延迟特性,并保持良好的吞吐量。
只要G1认为可以进行垃圾收集,就会触发一次GC, 当然,G1优先回收存活数据较少的区域。
存活数据少就表示里面的垃圾对象多,这也是名字 Garbage First 的由来。

垃圾收集器本质上是一款内存管理工具。 G1算法主要通过以下方式来实现自动内存管理:

  • 【分代】在年轻代中分配新对象,达到一定年龄的对象则提升到老年代。
  • 【并发】在并发标记阶段遍历老年代中的所有存活对象。 只要Java中堆内存的总使用量超过阈值,HotSpot 就会触发标记周期。
  • 【整理】通过并行复制方式来整理存活对象,释放可用内存。

在GC中, 并行(parallel)是指多个GC线程一起干活, 并发(concurrent)指GC线程和业务线程一起并发执行。

本文先简要介绍怎样配置G1参数, 然后再介绍如何对GC性能进行分析和评估。
想要进行GC调优,至少要对 Java的垃圾收集机制 有一定了解。

G1是一款增量式的分代垃圾收集器。 什么是增量呢?
G1把堆内存分为很多个大小相同的【小区域、小块】(region)。
在JVM启动时,根据堆内存的配置,确定每个region的大小。 region的大小取值范围是 1MB32MB,总数一般不会超过2048

在G1中,新生代(eden),存活区(survivor)和老年代(old generation)都是逻辑上的概念,由这些region组合而成,这些region之间并不需要保持连续。

https://img-blog.csdn.net/20170205235146220

可以设置参数来指定 “期望的最大暂停时间”, G1会尽量去满足这个软实时目标值。
在【纯年轻模式(young)】的垃圾收集过程中,G1可以动态调整年轻代的大小(eden + survivor),以达成这个软实时目标暂停时间。
在【混合模式(mixed)】的垃圾收集过程中,G1可以调整本次GC需要回收的老年代region数量,取决于【要回收的总region数】,【每个region中存活对象的百分比】,以及【堆内存允许浪费的比例】等数据。

G1采用【增量并行复制】的方式来实现【堆内存碎片整理功能】,将回收集之中的存活对象拷贝到新region中,回收集的英文是 Collection Set,简称CSet,也就是本次GC涉及的region集合。
目标是尽可能多地,从有空闲的region中回收堆内存,同时也试图达成预期的暂停时间指标。

G1为每个region都单独设置了一份【记忆集】,英文是 Remembered Set,简称 RSet, 用来跟踪记录从别的region指向这个region中的引用。
通过这种region划分和独立的RSet数据结构,G1就可以并行地进行增量式垃圾回收,而不用遍历整个堆内存。
因为只需要扫描RSet,就可以得知有哪些跨区的引用指向这个region,从而对这些region进行回收。
G1使用【后置写屏障】(post-write barrier)来记录堆内存的修改信息, 并负责更新RSet。

1. 垃圾回收阶段简介

G1垃圾收集器的纯年轻代模式GC,以及混合模式GC, 除了转移暂停(evacuation pause)这个 STW 阶段之外,还有并行的、并发的,由多个子阶段组成的标记周期。
G1 使用开始快照算法(SATB,Snapshot-At-The-Beginning),在标记周期开始时,对堆内存中的存活对象信息进行一次快照。
那么,总的存活对象就包括开始快照中的存活对象,加上标记开始之后新创建的对象。
G1的标记算法使用【前置写屏障】(pre-write barrier)来记录和标记逻辑上属于这次快照的对象。

2. 纯年轻代模式的垃圾收集

G1将绝大部分的内存分配请求打到eden区。
在年轻代模式的垃圾收集过程中,G1会收集eden区和前一次GC使用的存活区。
并将存活对象拷贝/转移到一些新的region里面, 具体拷贝到哪里则取决于对象的年龄;
如果达到一定的GC年龄,就会转移/提升到老年代中;否则就会转移到存活区。
本次的存活区则会被加入到下一次年轻代GC/混合模式GC的CSet中。

3. 混合模式的垃圾收集

并发标记周期执行完毕之后,G1则会从纯年轻模式切换到混合模式。
在执行混合模式的垃圾收集时,G1会选择一部分老年代region加入回收集,当然,每次的回收集都包括所有eden区和存活区。
具体一次添加多少个老年代region,由哪些参数来决定,将会在后面进行讨论。
经过多次混合模式的垃圾收集之后,很多老年代region其实已经处理过了,然后G1又切换回纯年轻代模式,直到下一次的并发标记周期完成。

4. 标记周期的各个阶段

G1的标记周期包括以下这些阶段:

  • 【初始标记阶段】(Initial mark phase): 在此阶段标记 GC roots, 一般是附加在某次常规的年轻代GC中顺带着执行。
  • 【扫描GC根所在的region】(Root region scanning phase): 根据初始标记阶段确定的GC根元素,扫描这些元素所在region,获取对老年代的引用,并标记被引用的对象。 该阶段与应用线程并发执行,也就是说没有STW停顿,必须在下一次年轻代GC开始之前完成。
  • 【并发标记阶段】(Concurrent marking phase)”: 遍历整个堆,查找所有可达的存活对象。 此阶段与应用线程并发执行, 也允许被年轻代GC打断。
  • 【再次标记阶段】(Remark phase): 此阶段有一次STW暂停,以完成标记周期。 G1会清空SATB缓冲区,跟踪未访问到的存活对象,并进行引用处理。
  • 【清理阶段】(Cleanup phase): 这是最后的子阶段,G1在执行统计和清理RSet时会有一次STW停顿。 在统计过程中,会把完全空闲的region标记出来,也会标记出适合于进行混合模式GC的候选region。 清理阶段有一部分是并发执行的,比如在重置空闲region并将其加入空闲列表时。

5. 常用参数与默认值

G1是一款自适应垃圾收集器,大部分的参数都有默认值,一般情况下无需太多配置即可高效运行。
下面列出常用参数和对应的默认值, 如果有特殊需求,可调整JVM启动参数,以满足特定的性能指标。

-XX:G1HeapRegionSize=n

用来设置G1 region 的大小。 必须是2的幂(x次方),允许的范围是 1MB32MB
这个参数的默认值, 会根据堆内存的初始大小动态调整,以便将堆内存切分为2048个左右的region。

-XX:MaxGCPauseMillis=200

期望的最大暂停时间。 默认值为200毫秒。 这个值不会自动调整,启动时设置为多少就是多少。

-XX:G1NewSizePercent=5

设置年轻代的最小空间占比, 默认值为5,相当于最少有5%的堆内存会作为年轻代来使用。
这个参数会覆盖 -XX:DefaultMinNewGenPercent
这是实验性质的参数,后续版本有可能会有变更。

-XX:G1MaxNewSizePercent=60

设置年轻代的最大空间占比。 默认值为60,相当于最多有60%的堆内存会作为年轻代来使用。
此设置会覆盖 -XX:DefaultMaxNewGenPercent
这是实验性质的参数,后续版本有可能会有变更。

-XX:ParallelGCThreads=n

设置STW阶段的并行worker线程数。

  • 如果逻辑处理器小于等于8个,则默认 n 等于逻辑处理器的数量。
  • 如果逻辑处理器大于8个,则 n 默认约等于处理器数量的5/8
  • 如果是高配置的 SPARC 系统,则默认 n 大约等于逻辑处理器数量的5/16
  • 大多数情况下使用默认值即可。
  • 有一种情况除外,就是Docker容器中使用了低版本JDK,案例参考: JVM 问题排查分析下篇(案例实战)

-XX:ConcGCThreads=n

设置并发标记的GC线程数。 默认值约等于 ParallelGCThreads 值的 1/4

-XX:InitiatingHeapOccupancyPercent=45

设置标记周期的触发阈值, 即Java堆内存使用率的百分比。 默认的触发阈值是整个Java堆的45%

-XX:G1MixedGCLiveThresholdPercent=65

执行混合模式GC时,根据老年代region的使用率,确定是否包含到回收集之中。 阈值默认为65%
此设置会覆盖 -XX:G1OldCSetRegionLiveThresholdPercent
这是实验性质的参数,后续版本有可能会有变更。

-XX:G1HeapWastePercent=10

设置可以容忍的堆内存浪费率百分比。
如果可回收的堆内存占比小于这个阈值比例,则 HotSpot 不会启动混合模式GC。
默认值为10%

-XX:G1MixedGCCountTarget=8

在标记周期完成后,期望执行多少次混合模式的GC,直到存活数据的比例降到 G1MixedGCLiveThresholdPercent 之下。
默认是执行8次混合模式的GC。 具体执行的次数一般都会小于这个值。

-XX:G1OldCSetRegionThresholdPercent=10

混合模式的GC中,每次处理的老年代 region 数量上限占比。 默认值为Java堆的10%

-XX:G1ReservePercent=10

设置一定比例的保留空间, 让其保持空闲状态,降低 to空间 内存不足的风险。 默认值为 10%
虽然这是一个百分比,但实际会映射为具体的大小,所以当增加或减少百分比时,最好将Java堆的总大小也进行同样大小的调整。

6. 如何解锁实验性质的JVM参数

要修改实验性质的JVM参数值,必须先进行声明。
我们可以在命令行参数中,设置实验性质的参数之前,明确指定 -XX:+UnlockExperimentalVMOptions。 例如:

java -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=10 -XX:G1MaxNewSizePercent=75 G1test.jar

7. 最佳实践与建议

调整G1参数之前,需要记住以下几点:

  • 禁止设置年轻代的大小: 不要使用 -Xmn-XX:NewRatio 之类的选项来指定年轻代的大小。 如果指定固定的年轻代大小,则会覆盖最大暂停时间目标,可以说得不偿失。
  • 期望的最大暂停时间值: 不管对哪一款垃圾收集器进行调优,都需要在延迟与吞吐量指标之间进行权衡。
    G1是一款具有统一暂停时间的增量式垃圾收集器, 所以对CPU资源的开销相对要大一些。 G1的吞吐量目标,是指在 高负载 场景下,确保应用线程占有90%以上的CPU时间,GC线程的开销保持在10%以下。
    相比之下,HotSpot中自带的高吞吐量垃圾收集器可以优化到 99% 的应用线程时间, 也就是说只有不到1%的GC开销。
    因此,在压测G1的吞吐量指标时,需要放宽暂停时间指标。 如果设定的暂停时间目标值太小,就表示你愿意承担较大的GC开销,但这会影响到吞吐量。 在压测 G1 的延迟指标时,可以设置期望的软实时暂停时间指标,G1会尽力达成此目标。 副作用则是吞吐量会受到影响。
  • 对大部分服务端应用程序来说,CPU负载不会超过50%,即使GC多占了一点CPU也影响不大,因为还有很多冗余, 我们更关注的是GC暂停时间,因为这关系到响应延迟指标。
  • 混合模式的GC: 在调优混合模式的GC时,可以尝试以下选项。 这些选项的详细信息请参考前面的小节:
    • -XX:InitiatingHeapOccupancyPercent: 设置标记周期的触发阈值。
    • -XX:G1MixedGCLiveThresholdPercent-XX:G1HeapWastePercent: 调整混合模式GC相关的策略。
    • -XX:G1MixedGCCountTarget-XX:G1OldCSetRegionThresholdPercent 用于优化调整CSet中的老年代region比例。

8. GC日志中内存溢出和内存耗尽的信息

如果我们在GC日志中看到 to-space overflow/exhausted, 则表明G1没有足够的内存来存放存活区或者需要提升的对象,或者两者都不足。 这时候Java堆内存一般都已达到最大值,无法自动扩容。 示例如下:

924.897: [GC pause (G1 Evacuation Pause) (mixed) (to-space exhausted), 0.1957310 secs]

或者是这样:

924.897: [GC pause (G1 Evacuation Pause) (mixed) (to-space overflow), 0.1957310 secs]

要解决此类问题,可以尝试进行以下调整:

  • 加大 -XX:G1ReservePercent 选项的值, 以增加保留的 “to-space” 大小,一般来说,堆内存的总大小也需要相应地加大。
  • 降低 -XX:InitiatingHeapOccupancyPercent 来尽早触发标记周期。
  • 适当加大 -XX:ConcGCThreads 选项的值,增加并发标记的线程数。

这些选项的具体信息,请参考前面的描述。

9. 大对象/巨型对象的内存分配

如果某个对象超过单个 region 空间的一半,则会被G1视为 【大对象/巨型对象】(Humongous object)。 例如一个很大的数组或者String
这样的对象会直接分配到老年代的 “大对象region区(Humongous region)”。 一个大对象region区就是一组虚拟地址空间连续的region。 StartsHumongous 标志着开头的region,而 ContinuesHumongous 则标记随后的region集合。

在分配大对象region区之前,G1会先判断是否达到开启标记周期的阈值,在必要时会启动并发标记周期。

在标记周期最后的清理阶段,以及FullGC的清理过程中,都会释放不再使用的巨型对象。

为了减少内存复制的开销,所有转移暂停GC都不进行巨型对象的压缩和整理。 Full GC 时才会将巨型对象整理到位。

由于每个 StartsHumongous 和 ContinuesHumongous 组成的集合中都只保存一个巨型对象, 因此这个组合内部,最后面的空间总有一部分是浪费的。
如果某个对象占用的空间,只比N个region大上那么一点点,那么未使用的那部分空间实际上就产生了内存碎片。

如果在GC日志中,看到由 Humongous 分配而触发的大量并发周期,而且在老年代中形成了大量的内存碎片,就需要加大 -XX:G1HeapRegionSize 的值,让之前的巨型对象不再被当成巨无霸,而是走常规的对象分配方式【只要其小于region的50%即可】。

10. 总结

G1是一款 【并行+并发】 方式的【增量】垃圾收集器,将堆内存划分为很多个region,与其他 GC 算法实现相比,提供了可预测性更精准的暂停时间。
增量特性使得G1可以处理更大的堆内存空间,在最坏情况下依然保持合理的响应时间。

G1具有自适应特性,一般情况下,只需要设置3个调优参数即可:

  • 期望的最大暂停时间, 例如 -XX:MaxGCPauseMillis=50
  • 堆内存的最大值, 例如 -Xmx4g
  • 堆内存的最小值, 例如 -Xms4g

作者简介

Monica Beckwith, Oracle技术工作组的核心成员,是Java HotSpot VM 项目下, Garbage First Garbage Collector 的性能负责人。
在性能和架构领域具有10年以上的工作经验。
在Oracle和Sun Microsystems之前的工作,Monica 负责 Spansion Inc.的性能调优工作。
Monica与许多基于Java的性能测试标准进行了合作, 致力于探寻 Java HotSpot VM 的性能改进。

相关资源和链接

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页