JVM系列之经典垃圾回收器

上篇:https://mp.weixin.qq.com/s/GVabLd4BAZGDBe9xL86k3w

1.前言

随着 JDK 的不断更新,垃圾回收器的效率也越来越高。每一次 JDK 的更新,必然会包含有垃圾回收器的更新,截止目前,在最新的 JDK14 版本中,最新的垃圾回收器为 ZGC。

从垃圾回收器发展至今,出现过很多垃圾回收器,例如:Serial、ParNew、Parallel Scavenge、SerialOld、CMS、Parallel Old、G1、Shenandoah、ZGC 等,虽然目前比较流行的是 G1 和 ZGC,但是那些经典的垃圾回收器我们也有必要了解一下它们的工作原理,一方面是因为目前仍然有很多系统使用的都是 JDK8 及以下版本,而这些版本中有很多系统都是默认使用的经典的垃圾回收器,搞懂它们的原理,方便我们对它进行调优,另一方面就是为了面试,毕竟垃圾回收器是面试高频考点。

接下来本文将先介绍 Serial、ParNew、Parallel Scavenge、SerialOld、CMS、Parallel Old 这六款经典的垃圾回收器的工作原理,以及使用场景和相关参数配置,在下一篇文章中将会主要介绍 G1、Shenandoah、ZGC 这些 GC 的工作原理。

2.性能指标

在介绍垃圾回收器之前,先介绍一下衡量垃圾回收器的几个常用指标。

首先是「吞吐量」,它描述的是用户线程执行的时间比上全部运行时间(全部运行时间 = 用户线程时间+垃圾回收线程执行时间执行),吞吐量越高,表明系统的资源利用率越高。

然后是「停顿时间」,它表示的是 GC 线程在执行过程中,导致用户线程停顿的时间,如果停顿时间越长,那么用户线程卡顿的时间越长,用户体验越差,因此我们希望的是停顿时间越短越好。

另外还有一个指标就是「内存占用率」,因为垃圾回收器在执行过程中,它也需要占用一定的内存空间,当然我们期望的是内存占用率越小越好,尤其是在服务器内存配置较低的情况下。如果服务器的资源配置很高,内存很大,内存占用率高一点也可以接受。

通常情况下,「吞吐量和低延时(停顿时间)这两个指标是对立的」,无法同时兼顾两者,如果想追求低延时,那么吞吐量就会下降;如果追求高吞吐量,那么停顿时间就会变长。不过随着目前垃圾回收器的不断发展,越来越多的垃圾回收器都是以「在保证高吞吐量的情况下,尽可能的去追求低延时」为原则,来进行垃圾回收器的实现。

3.Serial

Serial 是针对新生代的垃圾回收器,它是单线程执行的,是一款串行的垃圾回收器,采用的是复制算法。它的单线程并不仅仅指它在进行垃圾回收时是单线程或者单处理器执行,更深的含义是它在垃圾回收时,需要暂停其他所有的线程,造成 STW。

当 JVM 处于客户端模式下时,Serial 是默认的垃圾回收器,它的优点是简单高效。在内存资源受限的环境下,Serial 垃圾回收器相比其他垃圾回收器,它所占用的内存更小。对于单处理器的场景,Serial 处理器由于是单线程的,它省去了线程之间的资源竞争,因此会更加高效。

当使用参数 「-XX:+UseSerialGC」时,在开启使用Serial垃圾回收器同时,老年代的垃圾回收器为Serial Old。

4.Serial Old

和 Serial 一样,Serial Old 也是单线程执行的,是一款串行的垃圾回收器,不同的是 Serail Old 回收的是老年代区域,采用的算法是标记-压缩(整理)算法。在进行垃圾回收时,同样也会造成 STW 的现象。

当 JVM 处于「客户端」模式下时,Serial Old 通常与 Serial 垃圾收集器搭配使用,Serial 回收新生代区域,Serial Old 回收老年代区域。Serial 和 Serial Old 搭配使用时的示意图如下。

JVM系列之经典垃圾回收器

Serial/Serial Old 搭配进行垃圾回收示意图

当 JVM 处于「服务端」模式下时,Serial Old 有两个用途,其一:与 Parallel Scavenge 垃圾回收器(回收新生代)搭配使用;其二:作为 CMS 垃圾回收器的后备方案(这一点会在下面讲解 CMS 垃圾回收器时具体讲解)。其实 Serial Old 还可以与 ParNew 垃圾回收器搭配使用,不过这种组合方式,从 JDK9 开始,已经被移除了。

5.ParNew

ParNew 是一款针对新生代区域的垃圾回收器,它是 Serial 垃圾收集器的多线程版本,即它是一款并行的垃圾回收器,支持多个垃圾回收线程同时并行回收垃圾,使用的也是复制算法。ParNew 的大部分参数配置和 Serial 收集器一样,但额外多了部分参数,如:可以通过参数 「-XX:ParallelGCThreads」 来指定并行的垃圾回收的线程个数,默认情况下,垃圾回收线程的个数与处理器的个数相等。在单处理器的系统中,ParNew 的性能并不一定比 Serial 好,因为线程的切换需要额外耗费 CPU 资源。

可以使用参数 「-XX:+UseParNewGC」 来开启使用 ParNew 进行垃圾回收。

ParNew 可以和 Serial Old 或者 CMS 搭配使用,然而从 JDK9 开始,官方已经移除了 ParNew 和 Serial Old 的组合使用方式,同时 JDK9 中将 CMS 标记为 Deprecated 状态,在 JDK14 中彻底移除 CMS,这就导致了 ParNew 将处于一个十分尴尬的地位,在高版本中既不能和 Serial Old 搭配使用,也将在未来无法和 CMS 搭配使用,这就导致了 ParNew 这款垃圾回收器必然消失在历史的舞台。

 

JVM系列之经典垃圾回收器

 

ParNew/Serial Old 搭配进行垃圾回收示意图

6.Parallel Scavenge

Parallel Scavenge 也是一款针对「新生代的并行的」垃圾回收器,它和 ParNew 虽然都是并行、针对新生代,但是它们的区别很大,Parallel Scavenge 是一款「吞吐量优先」的垃圾回收器。适用于那些期望尽可能的利用 CPU 资源、尽快完成程序的运算任务以及不太注重用户交互行为的场景。

Parallel Scavenge 提供了两个参数来精准地控制吞吐量,分别是 「MaxGCPauseMillis」 和 「GCTimeRatio」

MaxGCPauseMillis 表示的是每次进行 GC 时,系统的最大停顿时间,如果配置了该参数,那么 JVM 在每次进行垃圾回收时,它会尽可能的将停顿时间控制在 MaxGCPauseMillis 之内。该参数并不是配置的越小越好,如果配置得很小,那么 JVM 可能会为了达到停顿时间控制在 MaxGCPauseMillis 之内的目的,选择以减小新生代区域的大小为代价,毕竟每次回收 300M 的空间所花的时间肯定比 500M 的短。「而 JVM 将新生代的内存区域调小后,带来的后果就是垃圾回收进行得更加频繁了,最后会导致系统的吞吐量下降」。通常情况下,我们无法精准地把控每次垃圾回收需要停顿的时间,所以该参数需要慎用,一不小心,配置的不合理,可能适得其反。

GCTimeRatio 表示的是每次 GC 的时间占用的比率是多少(具体计算方是:GCTimeRatio = 用户线程运行时间/ GC 线程运行时间),例如:如果 GCTimeRatio 参数的值配置的 19,那么 GC 运行的时间占总时间的 5%(1/(1+19))。JVM 通过这个参数来达到控制系统吞吐量的目的。

另外 JVM 还提供了一个参数,叫做「UseAdpativeSizePolicy」,它表示的是让 JVM「根据系统的运行情况来动态调整」新生代(Eden、S0、S1)、老年代的大小,我们只需要设置好最基本的内存参数以及 MaxGCPauseMillis(最大停顿时间)或者 GCTimeRatio(目标吞吐量)即可,不需要设置-XX:Xmn(新生代的内存大小)、-XX:SurvivorRatio (Surivivior区域的比例)等参数了,JVM 会根据系统运行时监控到相关信息,来动态进行调整。Parallel Scavenge 支持动态调整策略,这也算是它和 ParNew 收集器的另一大不同之处了。

7.Parallel Old

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,也是支持多线程的并行执行,它底层是基于标记-压缩(整理)算法来实现的。在 JDK6 中才开始停供,在 Parallel Old 出现之前,Parallel Scavenge 收集器只能配合着 Serial Old 使用,无法与 CMS 垃圾回收器配合使用,这是因为 Parallel Scavenge 与 CMS、Serial、ParNew 这些收集器的底层框架不一样,无法兼容导致的。而 Serial Old 又是单线程的垃圾收集器,在多处理器的场景下,性能不高,白白浪费了 Parallel Scavenge 并行的优点,好车配劣马,所以在 Parallel Old 出现之前,Parallel Scavenge 一直处于比较鸡肋的地位。目前,Parallel Scavenge 和 Parallel Old 的组合,其垃圾回收效果不错,是 JDK8 中默认的垃圾回收组合方式。

JVM系列之经典垃圾回收器

Parallel Scavenge/Parallel Old 垃圾回收示意图

8.CMS

CMS 的全称是 Concurrent-Mark-Sweep 的缩写,翻译过来就是并发标记清除,它是一款「以低停顿时间为目标」的垃圾回收器,特点是低延时。CMS 的工作原理大致分为四个步骤:初始标记、并发标记、重新标记、并发清除。使用参数:「-XX:+UseConcMarkSweepGC」 即可开启使用 CMS 垃圾回收器。

  1. 「初始标记」指的是仅仅只标记出和 GC Roots 直接关联的对象,这个过程需要暂停所有的用户线程,因此会产生 STW。由于这一步仅仅标记和 GC Roots 直接关联的对象,因此这一步耗费的时间会很短,造成的停顿时间会很短。

  2. 「并发标记」。这一步是从和 GC Roots 直接关联的对象出发,开始遍历整个对象图引用链,这个过程是 GC 线程和用户线程并发执行的,因此不会造成 STW。这一步因为需要遍历所有对象的引用链,所以耗费时间较长,由于不会造成 STW,即使耗时较长,也没有关系。

  3. 「重新标记」。在并发标记阶段,用户线程仍然在运行,因此会改变对象之间的引用关系,那么在重新标记阶段,就是对并发标记的结果进行修正。把那些怀疑是垃圾,而实际不是垃圾的对象重新标记为存活对象。这一步需要暂停所有的用户线程,因此会造成 STW 的现象,这一步的耗时会比初始标记阶段长一些,但是远小于并发标记阶段的耗时。

  4. 「并发清除」。这一阶段是垃圾回收线程和用户线程一起并发执行,垃圾回收线程进行垃圾对象的清除,这一步耗时较长,但不会造成 STW。

整体上来看,CMS 垃圾回收器只有在初始标记阶段和重新标记阶段会造成用户线程的停顿,但是这两步都耗时较短,因此整体上,CMS 进行垃圾回收时,是低延时的。示意图如下。

JVM系列之经典垃圾回收器

CMS 垃圾回收示意图

8.1 CMS 优缺点与参数调优

CMS 垃圾回收器的优点就是低延时,对于那些期望快速响应、暂停时间较短以提高用户体验的网站或者系统,CMS 垃圾回收器就特别适合它们。

CMS 的缺点也很明显。第一,「对 CPU 资源比较敏感」。因为垃圾回收线程需要和用户线程并发执行,会涉及到争夺 CPU 资源的现象,导致系统的吞吐量下降。我们可以通过参数 「-XX:ParallelGCThreads」 来控制垃圾回收线程的数量,系统默认的数值是 「(处理器核心数+3)/4」

第二,CMS 「会产生"浮动"垃圾」。在 CMS 进行垃圾回收时,用户线程也在执行,在此过程中,用户线程可能也会产生新的垃圾,而这些新的垃圾在 CMS 进行本次垃圾回收时,是不会被回收掉的,这些垃圾被称之为"浮动"垃圾。也正是因为垃圾线程和用户线程存在同时执行的场景,因此 CMS 垃圾回收器,不会等到堆内存被使用完才进行垃圾回收,它需要为系统预留一部分内存以供用户线程运行,所以 CMS 通常是当堆内存使用率达到达到某一个阈值之后,就会触发执行垃圾回收。

该阈值在 JDK5 中,默认值为 68%,到了 JDK6 以后,该阈值的默认值被提高到了 92%,我们可以通过参数 「-XX:CMSInitiatingOccupancyFraction」 手动设置。在实际使用过程中,如果系统的内存使用增长比较慢,那么这个阈值就可以适当的设置得大一点,降低垃圾回收的频率,以提高系统性能。但如果设置得过大,也会带来额外的问题,当设置得过大时,也即是内存即将被使用完,此时由于用户线程仍在运行,如果恰好此时需要为一个比较大的对象分配内存空间,而剩余的内存不足了,此时就会出现“Concurrent Mode Failure”的日志提示,表示并发回收垃圾失败,那么 JVM 此时就不得不启动备选方案,冻结用户线程,采用 Serial Old 垃圾回收器进行老年代的垃圾回收,在一定程度上,可能降低系统的性能。

第三,CMS 「会产生垃圾碎片」。CMS 使用的是标记-清除算法,所以会产生内存碎片,内存碎片可能会影响程序性能。如果要为大对象分配内存时,发现内存中没有一块能容得下该对象的内存,那这个时候 JVM 就不得不触发一次 Full GC,导致程序发生 STW。为了减少内存碎片,CMS 的研发人员提供了一个开关,当开启这个开关时,会让垃圾回收器在 Full GC 完成后,进行一次内存碎片的整理,这个参数就是:「-XX:+UseCMSCompactAtFullCollection」, 「+」 号表示开启开关。

这个开关虽然解决了内存碎片带来的问题,但是,如果每次在 Full GC 之后都进行一次内存碎片的整理,而每次内存碎片的整理又需要暂停用户线程,造成 STW,这会使 CMS 具有低延时的特点大打折扣,因此 CMS 的研发人员还提供了另外一个参数:「-XX:CMSFullGCsBeforeCompaction」,该参数表示的含义是当发生多少次 Full GC 后,才对内存进行一次整理。

8.2 CMS 发展现状及未来

CMS 是一款针对老年代的垃圾回收器,它需要与新生代的垃圾回收器搭配使用,而且只能与 Serial、ParNew 这两款新生代的垃圾回收器搭配。在 G1 垃圾回收器出现之前,CMS 曾经因为是唯一一款并发的、低延时的垃圾回收器,其应用十分广泛,但是后来被同样具有并发清理、低延时,但性能却比 CMS 高效很多倍的 G1 垃圾回收器打破了局面,以至于在 JDK9 中,CMS 被标记为「Deprecated」,开始逐渐的淡出人们的视野,在目前最新的 JDK14 中,CMS 则是完全被移除了,成为了第一款被彻底遗弃的垃圾回收器。

9.总结

本文详细介绍了 Serial、ParNew、Parallel Scavenge、SerialOld、CMS、Parallel Old 这六款垃圾回收器,最后用一张图来总结它们相互之间搭配使用的关系。需要说明的是,在 JDK9 中,取消了 ParNew 与 Serial Old、Serial 与 CMS 的搭配组合,并且 CMS 被标记 Deprecated,在 JDK14 中被彻底移除。

JVM系列之经典垃圾回收器

垃圾回收器搭配组合

 

中篇:https://mp.weixin.qq.com/s/7CWbARimO5rFBHq4NtAt5Q

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 中,新增了一个 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 垃圾回收的 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 参数的值。