JVM——GC回收器原理

摘要

随着互联网的迅速发展和计算机硬件的迭代更新,越来越多的业务系统使用大内存。而且这些实时在线业务对响应时间比较敏感。比如需要实时获得响应消息的支付业务,如果 JVM 的某一次 GC 暂停时间达到 10 秒,显然会让客户的耐心耗尽。还有一些对延迟特别敏感的系统,一般要求响应时间在 100ms 以内。例如高频交易系统,业务本身就有一些运算耗时,如果 GC 暂停时间超过一半(>50ms),那很可能就会让某些交易策略失效,从而达不到规定的性能指标。

在这样的背景下,GC 消耗的资源(如 CPU、内存)相对来说并不是那么重要,吞吐量稍微小一点是能接受的。因为在这类系统中,硬件资源一般都有很多冗余,而且还可以通过限频、分流、集群等措施将单机的吞吐限制在一定范围内。也就是说低延迟才是这些系统的核心非功能性需求。

如何让系统能够在高并发、高吞吐、大内存(如堆内存 64/128G+)的情况下,保持长期稳定运行,将 GC 停顿延迟降低到 10ms 级别,就成为一个非常值得思考的问题,也是业界迫切需要解决的难题。

JVM 中这些算法的具体实现。首先要记住的是,大多数 JVM 都需要使用两种不同的 GC 算法——一种用来清理年轻代,另一种用来清理老年代。我们可以选择 JVM 内置的各种算法。如果不通过参数明确指定垃圾收集算法,则会使用相应 JDK 版本的默认实现。

一、串行 GC(Serial GC)

串行 GC 对年轻代使用 mark-copy(标记—复制)算法,对老年代使用 mark-sweep-compact(标记—清除—整理)算法。

两者都是单线程的垃圾收集器,不能进行并行处理,所以都会触发全线暂停(STW),停止所有的应用线程。因此这种 GC 算法不能充分利用多核 CPU。不管有多少 CPU 内核,JVM 在垃圾收集时都只能使用单个核心。要启用此款收集器,只需要指定一个 JVM 启动参数即可,同时对年轻代和老年代生效:

-XX:+UseSerialGC

该选项只适合几百 MB 堆内存的 JVM,而且是单核 CPU 时比较有用。对于服务器端来说,因为一般是多个 CPU 内核,并不推荐使用,除非确实需要限制 JVM 所使用的资源。大多数服务器端应用部署在多核平台上,选择 串行 GC 就意味着人为地限制了系统资源的使用,会导致资源闲置,多余的 CPU 资源也不能用增加业务处理的吞吐量。

二、并行 GC(Parallel GC)

并行垃圾收集器这一类组合,在年轻代使用“标记—复制(mark-copy)算法”,在老年代使用“标记—清除—整理(mark-sweep-compact)算法”。年轻代和老年代的垃圾回收都会触发 STW 事件,暂停所有的应用线程来执行垃圾收集。两者在执行“标记和复制/整理”阶段时都使用多个线程,因此得名“Parallel”。通过并行执行,使得 GC 时间大幅减少。

通过命令行参数 -XX:ParallelGCThreads=NNN 来指定 GC 线程数,其默认值为 CPU 核心数。可以通过下面的任意一组命令行参数来指定并行 GC:

-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseParallelGC -XX:+UseParallelOldGC

并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量:

  • 在 GC 期间,所有 CPU 内核都在并行清理垃圾,所以总暂停时间更短;
  • 在两次 GC 周期的间隔期,没有 GC 线程在运行,不会消耗任何系统资源。

另一方面,因为此 GC 的所有阶段都不能中断,所以并行 GC 很容易出现长时间的卡顿(注:这里说的长时间也很短,一般来说例如 minor GC 是毫秒级别,full GC 是几十几百毫秒级别)。如果系统的主要目标是最低的停顿时间/延迟,而不是整体的吞吐量最大,那么就应该选择其他垃圾收集器组合。:长时间卡顿的意思是,此 GC 启动之后,属于一次性完成所有操作,于是单次暂停 的时间会较长。

三、CMS 垃圾收集器

CMS GC 的官方名称为Mostly Concurrent Mark and Sweep Garbage Collector(最大并发—标记—清除—垃圾收集器)。其对年轻代采用并行 STW 方式的 mark-copy(标记—复制)算法,对老年代主要使用并发 mark-sweep(标记—清除)算法。

CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:

  • 第一,不对老年代进行整理,而是使用空闲列表(free-lists)来管理内存空间的回收。
  • 第二,在 mark-and-sweep(标记—清除)阶段的大部分工作和应用线程一起并发执行。

也就是说,在这些阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢 CPU 时间。默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4。通过以下选项来指定 CMS 垃圾收集器:

-XX:+UseConcMarkSweepGC

如果服务器是多核 CPU,并且主要调优目标是降低 GC 停顿导致的系统延迟,那么使用 CMS 是个很明智的选择。通过减少每一次 GC 停顿的时间,很多时候会直接改善系统的用户体验。因为多数时候都有部分 CPU 资源被 GC 消耗,所以在 CPU 资源受限的情况下,CMS GC 会比并行 GC 的吞吐量差一些(对于绝大部分系统,这个吞吐和延迟的差别应该都不明显)。

在实际情况下,进行老年代的并发回收时,可能会伴随着多次年轻代的 minor GC。在这种情况下,full GC 的日志中就会掺杂着多次 minor GC 事件,像前面所介绍的一样。下面我们来看一看 CMS GC 的几个阶段。

3.1 Initial Mark(初始标记)

这个阶段伴随着 STW 暂停。初始标记的目标是标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活对象所引用的对象(老年代单独回收)。为什么 CMS 不管年轻代了呢?前面不是刚刚完成 minor GC 嘛,再去收集年轻代估计也没什么效果。

3.2 Concurrent Mark(并发标记)

在此阶段,CMS GC 遍历老年代,标记所有的存活对象,从前一阶段“Initial Mark”找到的根对象开始算起。“并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。请注意,并非所有老年代中存活的对象都在此阶段被标记,因为在标记过程中对象的引用关系还在发生变化。当前处理的对象”的一个引用就被应用线程给断开了,即这个部分的对象关系发生了变化

3.3 Concurrent Preclean(并发预清理)

因为前一阶段“并发标记”与程序并发运行,可能有一些引用关系已经发生了改变。如果在并发标记过程中引用关系发生了变化,JVM 会通过“Card(卡片)”的方式将发生了改变的区域标记为“脏”区,这就是所谓的“卡片标记(Card Marking)”。

在预清理阶段,这些脏对象会被统计出来,它们所引用的对象也会被标记。此阶段完成后,用以标记的 card 也就会被清空。此外,本阶段也会进行一些必要的细节处理,还会为 Final Remark 阶段做一些准备工作。

3.4 Concurrent Abortable Preclean(可取消的并发预清理)

此阶段也不停止应用线程。本阶段尝试在 STW 的 Final Remark 阶段 之前尽可能地多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件(如迭代次数,有用工作量,消耗的系统时间等等)。此阶段可能显著影响 STW 停顿的持续时间,并且有许多重要的配置选项和失败模式。

3.5 Final Remark(最终标记)

最终标记阶段是此次 GC 事件中的第二次(也是最后一次)STW 停顿。本阶段的目标是完成老年代中所有存活对象的标记. 因为之前的预清理阶段是并发执行的,有可能 GC 线程跟不上应用程序的修改速度。所以需要一次 STW 暂停来处理各种复杂的情况。通常 CMS 会尝试在年轻代尽可能空的情况下执行 Final Remark 阶段,以免连续触发多次 STW 事件。在 5 个标记阶段完成之后,老年代中所有的存活对象都被标记了,然后 GC 将清除所有不使用的对象来回收老年代空间。

3.6 Concurrent Sweep(并发清除)

此阶段与应用程序并发执行,不需要 STW 停顿。JVM 在此阶段删除不再使用的对象,并回收它们占用的内存空间。

3.7 Concurrent Reset(并发重置)

此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。

总之,CMS 垃圾收集器在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程。当然,CMS 也有一些缺点,其中最大的问题就是老年代内存碎片问题(因为不压缩),在某些情况下 GC 会造成不可预测的暂停时间,特别是堆内存较大的

四、G1 垃圾收集器

G1 GC 最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。事实上,G1 GC 是一款软实时垃圾收集器,可以为其设置某项特定的性能指标。例如可以指定:在任意 xx 毫秒时间范围内,STW 停顿不得超过 yy 毫秒。举例说明:任意 1 秒内暂停时间不超过 5 毫秒。G1 GC 会尽力达成这个目标(有很大概率会满足,但并不完全确定)。

为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。首先,堆不再分成年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的 小块堆区域(smaller heap regions)。每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代,如图所示:

这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块,见下图带对号的部分:

 G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:垃圾最多的小块会被优先收集。这也是 G1 名称的由来。通过以下选项来指定 G1 垃圾收集器:

-XX:+UseG1GC -XX:MaxGCPauseMillis=50

4.1 G1 GC 常用参数设置

  • -XX:+UseG1GC:启用 G1 GC,JDK 7 和 JDK 8 要求必须显示申请启动 G1 GC;
  • -XX:G1NewSizePercent:初始年轻代占整个 Java Heap 的大小,默认值为 5%;
  • -XX:G1MaxNewSizePercent:最大年轻代占整个 Java Heap 的大小,默认值为 60%;
  • -XX:G1HeapRegionSize:设置每个 Region 的大小,单位 MB,需要为 1、2、4、8、16、32 中的某个值,默认是堆内存的 1/2000。如果这个值设置比较大,那么大对象就可以进入 Region 了。
  • -XX:ConcGCThreads:与 Java 应用一起执行的 GC 线程数量,默认是 Java 线程的 1/4,减少这个参数的数值可能会提升并行回收的效率,提高系统内部吞吐量。如果这个数值过低,参与回收垃圾的线程不足,也会导致并行回收机制耗时加长。
  • -XX:+InitiatingHeapOccupancyPercent(简称 IHOP):G1 内部并行回收循环启动的阈值,默认为 Java Heap 的 45%。这个可以理解为老年代使用大于等于 45% 的时候,JVM 会启动垃圾回收。这个值非常重要,它决定了在什么时间启动老年代的并行回收。
  • -XX:G1HeapWastePercent:G1 停止回收的最小内存大小,默认是堆大小的 5%。GC 会收集所有的 Region 中的对象,但是如果下降到了 5%,就会停下来不再收集了。就是说,不必每次回收就把所有的垃圾都处理完,可以遗留少量的下次处理,这样也降低了单次消耗的时间。
  • -XX:G1MixedGCCountTarget:设置并行循环之后需要有多少个混合 GC 启动,默认值是 8 个。老年代 Regions 的回收时间通常比年轻代的收集时间要长一些。所以如果混合收集器比较多,可以允许 G1 延长老年代的收集时间。
  • -XX:+G1PrintRegionLivenessInfo:这个参数需要和 -XX:+UnlockDiagnosticVMOptions 配合启动,打印 JVM 的调试信息,每个 Region 里的对象存活信息。
  • -XX:G1ReservePercent:G1 为了保留一些空间用于年代之间的提升,默认值是堆空间的 10%。因为大量执行回收的地方在年轻代(存活时间较短),所以如果你的应用里面有比较大的堆内存空间、比较多的大对象存活,这里需要保留一些内存。
  • -XX:+G1SummarizeRSetStats:这也是一个 VM 的调试信息。如果启用,会在 VM 退出的时候打印出 RSets 的详细总结信息。如果启用-XX:G1SummaryRSetStatsPeriod参数,就会阶段性地打印 RSets 信息。
  • -XX:+G1TraceConcRefinement:这个也是一个 VM 的调试信息,如果启用,并行回收阶段的日志就会被详细打印出来。
  • -XX:+GCTimeRatio:大家知道,GC 的有些阶段是需要 Stop—the—World,即停止应用线程的。这个参数就是计算花在 Java 应用线程上和花在 GC 线程上的时间比率,默认是 9,跟新生代内存的分配比例一致。这个参数主要的目的是让用户可以控制花在应用上的时间,G1 的计算公式是 100/(1+GCTimeRatio)。这样如果参数设置为 9,则最多 10% 的时间会花在 GC 工作上面。Parallel GC 的默认值是 99,表示 1% 的时间被用在 GC 上面,这是因为 Parallel GC 贯穿整个 GC,而 G1 则根据 Region 来进行划分,不需要全局性扫描整个内存堆。
  • -XX:+UseStringDeduplication:手动开启 Java String 对象的去重工作,这个是 JDK8u20 版本之后新增的参数,主要用于相同 String 避免重复申请内存,节约 Region 的使用。
  • -XX:MaxGCPauseMills:预期 G1 每次执行 GC 操作的暂停时间,单位是毫秒,默认值是 200 毫秒,G1 会尽量保证控制在这个范围内。

这里面最重要的参数,就是:

  • -XX:+UseG1GC:启用 G1 GC;
  • -XX:+InitiatingHeapOccupancyPercent:决定什么情况下发生 G1 GC;
  • -XX:MaxGCPauseMills:期望每次 GC 暂定的时间,比如我们设置为 50,则 G1 GC 会通过调节每次 GC 的操作时间,尽量让每次系统的 GC 停顿都在 50 上下浮动。如果某次 GC 时间超过 50ms,比如说 100ms,那么系统会自动在后面动态调整 GC 行为,围绕 50 毫秒浮动。

4.2 年轻代模式转移暂停(Evacuation Pause)

通过前面的分析可以看到,G1 GC 会通过前面一段时间的运行情况来不断的调整自己的回收策略和行为,以此来比较稳定地控制暂停时间。在应用程序刚启动时,G1 还没有采集到什么足够的信息,这时候就处于初始的 fully-young 模式。当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。

4.3 并发标记(Concurrent Marking)

同时我们也可以看到,G1 GC 的很多概念建立在 CMS 的基础上,所以下面的内容需要对 CMS 有一定的理解。G1 并发标记的过程与 CMS 基本上是一样的。G1 的并发标记通过 Snapshot-At-The-Beginning(起始快照) 的方式,在标记阶段开始时记下所有的存活对象。即使在标记的同时又有一些变成了垃圾。通过对象的存活信息,可以构建出每个小堆块的存活状态,以便回收集能高效地进行选择。这些信息在接下来的阶段会用来执行老年代区域的垃圾收集。

有两种情况是可以完全并发执行的:

  • 如果在标记阶段确定某个小堆块中没有存活对象,只包含垃圾;
  • 在 STW 转移暂停期间,同时包含垃圾和存活对象的老年代小堆块。

当堆内存的总体使用比例达到一定数值,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。

4.3.1 Initial Mark(初始标记)

此阶段标记所有从 GC 根对象直接可达的对象。在 CMS 中需要一次 STW 暂停,但 G1 里面通常是在转移暂停的同时处理这些事情,所以它的开销是很小的。

4.3.2 Root Region Scan(Root 区扫描)

此阶段标记所有从“根区域”可达的存活对象。根区域包括:非空的区域,以及在标记过程中不得不收集的区域。

因为在并发标记的过程中迁移对象会造成很多麻烦,所以此阶段必须在下一次转移暂停之前完成。如果必须启动转移暂停,则会先要求根区域扫描中止,等它完成才能继续扫描。在当前版本的实现中,根区域是存活的小堆块:包括下一次转移暂停中肯定会被清理的那部分年轻代小堆块。

4.3.3 Concurrent Mark(并发标记)

此阶段和 CMS 的并发标记阶段非常类似:只遍历对象图,并在一个特殊的位图中标记能访问到的对象。为了确保标记开始时的快照准确性,所有应用线程并发对对象图执行引用更新,G1 要求放弃前面阶段为了标记目的而引用的过时引用。

4.3.4 Remark(再次标记)

和 CMS 类似,这是一次 STW 停顿(因为不是并发的阶段),以完成标记过程。G1 收集器会短暂地停止应用线程,停止并发更新信息的写入,处理其中的少量信息,并标记所有在并发标记开始时未被标记的存活对象。 这一阶段也执行某些额外的清理,如引用处理或者类卸载(class unloading)。

4.3.5 Cleanup(清理)

最后这个清理阶段为即将到来的转移阶段做准备,统计小堆块中所有存活的对象,并将小堆块进行排序,以提升 GC 的效率。此阶段也为下一次标记执行必需的所有整理工作(house-keeping activities):维护并发标记的内部状态。所有不包含存活对象的小堆块在此阶段都被回收了。有一部分任务是并发的:例如空堆区的回收,还有大部分的存活率计算。此阶段也需要一个短暂的 STW 暂停,才能不受应用线程的影响并完成作业。

4.4 转移暂停:混合模式(Evacuation Pause(mixed))

并发标记完成之后,G1 将执行一次混合收集(mixed collection),就是不只清理年轻代,还将一部分老年代区域也加入到 回收集 中。混合模式的转移暂停不一定紧跟并发标记阶段。有很多规则和历史数据会影响混合模式的启动时机。比如,假若在老年代中可以并发地腾出很多的小堆块,就没有必要启动混合模式。因此,在并发标记与混合转移暂停之间,很可能会存在多次 young 模式的转移暂停。

具体添加到回收集的老年代小堆块的大小及顺序,也是基于许多规则来判定的。其中包括指定的软实时性能指标,存活性,以及在并发标记期间收集的 GC 效率等数据,外加一些可配置的 JVM 选项。混合收集的过程,很大程度上和前面的 fully-young gc 是一样的。

4.5 Remembered Sets(历史记忆集)

Remembered Sets(历史记忆集)用来支持不同的小堆块进行独立回收。

例如,在回收小堆块 A、B、C 时,我们必须要知道是否有从 D 区或者 E 区指向其中的引用,以确定它们的存活性. 但是遍历整个堆需要相当长的时间,这就违背了增量收集的初衷,因此必须采取某种优化手段。类似于其他 GC 算法中的“卡片”方式来支持年轻代的垃圾收集,G1 中使用的则是 Remembered Sets。

如下图所示,每个小堆块都有一个 Remembered Set,列出了从外部指向本块的所有引用。这些引用将被视为附加的 GC 根。注意,在并发标记过程中,老年代中被确定为垃圾的对象会被忽略,即使有外部引用指向它们:因为在这种情况下引用者也是垃圾(如垃圾对象之间的引用或者循环引用)。

接下来的行为,和其他垃圾收集器一样:多个GC线程并行地找存活对象,确定哪些是垃圾:

最后,存活对象被转移到存活区(survivor regions),在必要时会创建新的小堆块。现在,空的小堆块被释放,可用于存放新的对象了。

五、Pauseless GC 基本情况

早在 2005 年,Azul Systems 公司的三位工程师就给出了非常棒的解决方案,在论文《无停顿 GC 算法(The Pauseless GC Algorithm)》中提出了 Pauseless GC 设计。他们发现,低延迟的秘诀主要在于两点:

  • 使用读屏障
  • 使用增量并发垃圾回收

论文提出后,经历了 10 多年的研究和开发,JDK 11 正式引入 ZGC 垃圾收集器,基本上就是按照这篇论文中提出的算法和思路来实现的。当然,JDK 12 中引入的 Shenandoah GC(读作“谢南多厄”)也是类似的设计思想。

之前的各种 GC 算法实现,都是在业务线程执行的代码中强制增加“写屏障(write barrier)”,以控制对堆内存的修改,同时也可以跟踪堆内存中跨区的引用。这种实现方法使得基于分代/分区的 GC 算法具有非常卓越的性能,被广泛用于各种产品级 JVM 中。换句话说,以前在生产环境中很少有人使用“读屏障(read barrier)”,主要原因是理论研究和实现都不成熟,也没有优势。

好的 GC 算法肯定要保证内存清理的速度要比内存分配的速度快,除此之外,Pauseless GC 并没有规定哪个阶段是必须快速完成的。每个阶段都不必跟业务线程争抢 CPU 资源,没有哪个阶段需要抢在后面的业务操作之前必须完成。

Pauseless GC 算法主要分为三个阶段:标记(Mark)、重定位(Relocate)和重映射(Remap)。每个阶段都是完全并行的,而且每个阶段都是和业务线程并发执行的。

六、ZGC原理

JDK 11 从 JDK 9 和 JDK 10 版本中继承了很多优秀的特性,比如 JDK 9 引入的模块化功能和 jhsdb 调试工具等等。如果要在 JDK 11 中选择一个最令人激动的特性,那就非 ZGC 莫属了。

ZGC 即 Z Garbage Collector(Z 垃圾收集器,Z 有 Zero 的意思,主要作者是 Oracle 的 Per Liden),这是一款低停顿、高并发,基于小堆块(region)、不分代的增量压缩式垃圾收集器,平均 GC 耗时不到 2 毫秒,最坏情况下的暂停时间也不超过 10 毫秒。

在 Linux 系统中,JDK 11 安装完成后,可以通过如下参数启用 ZGC:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx16g

ZGC 垃圾收集器从 JDK11 开始支持,但截止目前(2020 年 2 月),仅支持 x64 平台的 Linux 操作系统。

在 Linux x64 下的 JDK 11 以上版本中可以使用 ZGC 垃圾收集器。

笔者翻阅了一下移植版本的开发进展情况,发现 macOS 系统上的开发已经完成,但还没有集成到 JDK 中;按官方计划,会在 JDK 14 中集成进来(当前的日常 JDK 14 早期 build 版本中也没有加入)。

JDK 13 是 2019 年 9 月份发布的,按照半年发布一次的约定,JDK 14 大约会在 2020 年 3 月份发布。

ZGC 最主要的特点包括:

  • GC 最大停顿时间不超过 10ms
  • 堆内存支持范围广,小至几百 MB 的堆空间,大至 4TB 的超大堆内存(JDK 13 升至 16TB)
  • 与 G1 相比,应用吞吐量下降不超过 15%
  • 当前只支持 Linux/x64 位平台,预期 JDK14 后支持 macOS 和 Windows 系统

有的地方说“GC 暂停”,有的地方说“GC 停顿”,其实两者是一个意思,但为了表述的流畅,所以会使用不同的词语。更细致的辨别,可以认为暂停是业务线程的暂停,停顿是指应用程序层面的停顿。官方介绍说停顿时间在 10ms 以下,其实这个数据是非常保守的值。根据基准测试(见参考材料里的 PDF 链接),在 128G 的大堆下,最大停顿时间只有 1.68ms,远远低于 10ms;和 G1 算法比起来相比,改进非常明显。

左边的图是线性坐标,右边是指数坐标。可以看到,不管是平均值、95 线、99 线还是最大暂停时间,ZGC 都优胜于 G1 和并行 GC 算法。根据我们在生产环境的监控数据来看(16G~64G 堆内存),每次暂停都不超过 3ms。比如下图是一个低延迟网关系统的监控信息,几十 GB 的堆内存环境中,ZGC 表现得毫无压力,暂停时间非常稳定。

像 G1 和 ZGC 之类的现代 GC 算法,只要空闲的堆内存足够多,基本上不触发 FullGC。

所以很多时候,只要条件允许,加内存才是最有效的解决办法。既然低延迟是 ZGC 的核心看点,而 JVM 低延迟的关键是 GC 暂停时间,那么我们来看看有哪些方法可以减少 GC 暂停时间:

  • 使用多线程“并行”清理堆内存,充分利用多核 CPU 的资源;
  • 使用“分阶段”的方式运行 GC 任务,把暂停时间打散;
  • 使用“增量”方式进行处理,每次 GC 只处理一部分堆内存(小堆块,region);
  • 让 GC 与业务线程“并发”执行,例如增加并发标记,并发清除等阶段,从而把暂停时间控制在非常短的范围内(目前来说还是必须使用少量的 STW 暂停,比如根对象的扫描,最终标记等阶段);
  • 完全不进行堆内存整理,比如 Golang 的 GC 就采用这种方式(题外话)。

可以看到“标记清除”算法就是不进行内存整理,所以会产生内存空间碎片。“复制清除”和“标记整理”算法则会进行内存空间整理,以消除内存碎片,但为了让堆结构不再变化,这个整理的过程需要将用户线程全部暂停,也就是我们所说的 STW 现象。只有在 STW 结束之后,程序才能继续运行。这个暂停时间一般是几百毫秒,多的可能到几秒,甚至几十秒。对于现在的实时应用系统和低延迟业务系统来说,这是一个大坑。

到了 G1,就把堆内存分成很多“小堆块”(region,为了不和区块链冲突,我们就不叫它“小区块”了)。就像 ConcurrentHashmap 将 hash 表分成很多段(segment)、来支持更小粒度的锁以提升性能一样,更小粒度的内存块划分,也就允许增量垃圾收集的实现,意味着每次暂停的时间更短。

当然实际经验证明,G1 设置的最大暂停时间(-XX:MaxGCPauseMillis)这个预估值十分不精确。甚至在恶劣情况下还会退化,出现长时间的 Full GC(JDK 10 之前是单线程串行回收,之后是多线程执行)。

ZGC,以及后面将要介绍的 Shenandoah GC,还有 Azul 公司的 C4 垃圾收集器处理方法都类似,它们专注于减少停顿时间,同时也会整理堆内存。

和前面介绍的其他 GC 算法不同,ZGC 几乎在所有地方都是(与应用线程)并发执行的,只有初始标记阶段会有 STW 暂停。所以 ZGC 的停顿时间基本上就消耗在了初始标记上,这部分时间非常短,而且这个暂停时间不会随着堆内存和存活对象的数量增加而递增。而内存整理,也就是重定位的过程是并发执行的,用到了我们前面说到的“读屏障”。读屏障是 ZGC 的关键法宝,具体实现原理将继续阅读下面的部分。

6.1 ZGC执行原理

 每个 GC 周期分为 6 个小阶段:

  1. 暂停—标记开始阶段:第一次暂停,标记根对象集合指向的对象;
  2. 并发标记/重映射阶段:遍历对象图结构,标记对象;
  3. 暂停—标记结束阶段:第二次暂停,同步点,弱根对象清理;
  4. 并发准备重定位阶段:引用处理、弱对象清理等;
  5. 暂停—重定位开始阶段:第三次暂停,根对象指向重定向集合;
  6. 并发重定位阶段:重定向集合中的对象重定向。

这 6 个阶段在绝大部分时间都是并发执行的,因此对应用运行的 GC 停顿影响很小。ZGC 采用了并发的设计方式,这个实现是非常有技术含量的:

  • 需要把一个对象拷贝到另一个地址,这时另外一个线程可能会读取或者修改原来的这个老对象;
  • 即使拷贝成功,在堆中依然会有很多引用指向老的地址,那么就需要将这些引用更新为新地址。

为了解决这些问题,ZGC 引入了两项关键技术:“着色指针”和“读屏障”。

6.1 1 着色指针

ZGC 使用着色指针来标记所处的 GC 阶段。着色指针是从 64 位的指针中,挪用了几位出来标识表示 Marked0、Marked1、Remapped、Finalizable。所以不支持 32 位系统,也不支持指针压缩技术,堆内存的上限是 4TB。从这些标记上就可以知道对象目前的状态,判断是不是可以执行清理压缩之类的操作。

6.1.2 读屏障

对于 GC 线程与用户线程并发执行时,业务线程修改对象的操作可能带来的不一致问题,ZGC 使用的是读屏障,这点与其他 GC 使用写屏障不同。

有读屏障在,就可以留待之后的其他阶段,根据指针颜色快速的处理。并且不是所有的读操作都需要屏障,例如下面只有第一种语句(加载指针时)需要读屏障,后面三种都不需要,又或者是操作原生类型的时候也不需要。

 著名的 JVM 技术专家 RednaxelaFX 提到:ZGC 的 Load Value Barrier,与 Red Hat 的Shenandoah 收集器使用的屏障不同,后者选择了 70 年代比较基础的 Brooks Pointer,而 ZGC 则是在古老的 Baker barrier 基础上增加了 self healing 特性。可以把“读屏障”理解为一段代码,或者是一个指令,后面挂着对应的处理函数。

比如下面的代码:

Object a = obj.x;
Object b = obj.x;

两行 load 操作对应的代码都插入了读屏障,但 ZGC 在第一个读屏障触发之后,不但将 a 的值更新为最新的,通过 self healing 机制使得 obj.x 的指针也会被修正,第二个读屏障再触发时就直接进入 FastPath,基本上没有什么性能损耗了;而 Shenandoah 则不会修正 obj.x 的值,所以第二个读屏障又要走一次 SlowPath。

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。

着色指针和读屏障,相当于在内存管理和应用程序代码之间加了一个中间层,通过这个中间层就可以实现更多的功能。但是也可以看到算法本身有一定的开销,也带来了很多复杂性。

6.2 ZGC 的参数介绍

除了上面提到的 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC 参数可以用来启用 ZGC 以外,ZGC 可用的参数见下表:

一些常用的参数介绍:

  • -XX:ZCollectionInterval:固定时间间隔进行 GC,默认值为0。
  • -XX:ZAllocationSpikeTolerance:内存分配速率预估的一个修正因子,默认值为 2,一般不需要更改。
  • -XX:ZProactive:是否启用主动回收策略,默认值为 true,建议开启。
  • -XX:ZUncommit:将不再使用的内存还给 OS,JDK 13 以后可以使用;JVM 会让内存不会降到 Xms 以下,所以如果 Xmx 和 Xms 配置一样这个参数就会失效。
  • -XX:+UseLargePages -XX:ZPath:使用大内存页。Large Pages 在 Linux 称为 Huge Pages,配置 ZGC 使用 Huge Pages 可以获得更好的性能(吞吐量、延迟、启动时间)。配置 Huge Pages 时,一般配合 ZPath 使用。配置方法可以见:Main - Main - OpenJDK Wiki
  • -XX:UseNUMA:启用 NUMA 支持(挂载很多 CPU,每个 CPU 指定一部分内存条的系统)。ZGC 默认开启 NUMA 支持,意味着在分配堆内存时,会尽量使用 NUMA-local 的内存。开启和关闭可以使用 -XX:+UseNUMA 或者 -XX:-UseNUMA
  • -XX:ZFragmentationLimit:根据当前 region 已大于 ZFragmentationLimit,超过则回收,默认为 25。
  • -XX:ZStatisticsInterval:设置打印 ZStat 统计数据(CPU、内存等 log)的间隔。

此外还有前面提过的并发线程数参数 -XX:ConcGCThreads=<number>,这个参数对于并发执行的 GC 策略都很重要,需要根据 CPU 核心数考虑,配置太多导致线程切换消耗太大,配置太少导致回收垃圾速度跟不上系统使用的速度。

6.3 Java 13 对 ZGC 的改进

Java 11 中的 ZGC,并没有像这个版本中的 G1 一样,主动将未使用的内存释放给操作系统。

也就是说内存回收以后,没有还给操作系统,依然是自己在管理。

对于大多数应用程序来说,CPU 和内存都属于有限的紧缺资源,这样就不利于资源的最大化利用(对于单个系统只部署一个 Java 应用,独享所有内存的部署,特别是 Xmx 和 Xms 一样大的时候,其实对系统影响不大)。

在 Java 13 中,ZGC 将会释放掉被标识为长时间未使用的页面,还给操作系统,这样就可以被其他进程使用(考虑多个批处理作业系统轮流执行等场景)。同时将这些未使用的内存还给操作系统不会导致堆大小缩小到参数设置的初始值以下,如果将最小和最大堆内存设置为相同的值,则不会释放任何内存给操作系统。

Java 13 中对 ZGC 的改进,主要体现在下面几点:

  • 可以释放不使用的内存给操作系统
  • 最大堆内存支持从 4TB 增加到 16TB
  • 添加参数 -XX:SoftMaxHeapSize 来软限制堆大小

注意,SoftMaxHeapSize 是指 GC 跟原来的 Xmx 和 Xms 都不相同,默认情况下 GC 会尽量让堆内存不超过这个大小,但是也不能排除在特定情况下超过这个限制,可以看做是变得更有弹性了。主要用在下面几种情况:

  • 当希望在一般情况下降低堆内存占用,同时保持应对堆空间临时增加的能力,
  • 亦或想保留充足内存空间,以能够应对内存分配,而不会因为内存分配意外增加而陷入分配停滞状态。

注意,不要将 SoftMaxHeapSize 设置为大于 Xmx 的值,因为如果设置了 Xmx,会以 Xmx 为最大值,即永远不会到达SoftMaxHeapSize。

在 Java 13 中,ZGC 内存释放给操作系统特性是默认开启的,可以使用参数 -XX:-ZUncommit 来关闭。也可以使用参数 -XX:ZUncommitDelay=<seconds> 来配置延迟一定时间后再释放内存,默认为 300 秒。

七、Shenandoah GC原理

作为 ZGC 的另一个选择,Shenandoah 是一款超低延迟垃圾收集器(Ultra-Low-Pause-Time Garbage Collector),其设计目标是管理大型的多核服务器上,超大型的堆内存。GC 线程与应用线程并发执行、使得虚拟机的停顿时间非常短暂。

Shenandoah GC 立项比 ZGC 更早,Red Hat 早在 2014 年就宣布启动开展此项目,实现 JVM 上 GC 低延迟的需求。

设计为 GC 线程与应用线程并发执行的方式,通过实现垃圾回收过程的并发处理,改善停顿时间,使得 GC 执行线程能够在业务处理线程运行过程中进行堆压缩、标记和整理,从而消除了绝大部分的暂停时间。

Shenandoah 团队对外宣称 Shenandoah GC 的暂停时间与堆大小无关,无论是 200 MB 还是 200 GB 的堆内存,都可以保障具有很低的暂停时间(注意:并不像 ZGC 那样保证暂停时间在 10ms 以内)。

7.1 Shenandoah GC工作原理

Shenandoah GC 的原理,跟 ZGC 非常类似。

部分日志内容如下:

GC(3) Pause Init Mark 0.771ms
GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms
GC(3) Pause Final Mark 1.821ms
GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms
GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms
GC(3) Pause Init Update Refs 0.084ms
GC(3) Concurrent update references 75700M->76424M(102400M) 354.341ms
GC(3) Pause Final Update Refs 0.409ms
GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms

对应工作周期如下:

  1. 初始标记阶段(Init Mark):为堆和应用程序准备并发标记,然后扫描根对象集。这是 GC 周期的第一次暂停,持续时间取决于根对象集的大小。因为根对象集很小,所以速度很快,暂停非常短。
  2. 并发标记阶段(Concurrent Mark):并发标记遍历堆,并跟踪可到达的对象。该阶段与应用程序同时运行,其持续时间取决于存活对象的数量以及堆中对象图的结构。由于应用程序可以在此阶段自由分配新数据,因此在并发标记期间堆占用率会上升。
  3. 最终标记阶段(Final Mark):通过排空所有等待中的标记/更新队列,并重新扫描根对象集来完成并发标记。这是 GC 周期中的第二次暂停,这里最主要的时间消耗在排空队列并扫描根对象集合。
  4. 并发清理阶段(Concurrent Cleanup):并发清除会回收即时的垃圾区域,即在并发标记之后检测到的没有活动对象的区域。
  5. 并发转移阶段(Concurrent Evacuation):并发转移将对象从各个不同区域复制到指定区域。这是与其他 OpenJDK GC 的主要区别。此阶段与应用程序还是可以同时运行,持续时间取决于要复制的集合大小,不会导致程序暂停。
  6. 初始引用更新阶段(Init Update Refs):本阶段确保所有 GC 和应用程序线程均已完成转移,然后为下一阶段 GC 做准备。这是周期中的第三次暂停,是所有暂停中最短的一次。
  7. 并发引用更新阶段(Concurrent Update References):遍历堆,并发更新引用,并将引用更新为在并发转移期间移动的对象。这是与其他 OpenJDK GC 的主要区别。它的持续时间取决于堆中对象的数量,而不在乎对象图结构,因为它会线性扫描堆。此阶段与应用程序同时运行。
  8. 最终引用更新阶段(Final Update Refs):通过再次更新现有的根对象集合来完成更新引用阶段。这是 GC 周期中的最后一个暂停,其持续时间取决于根对象集的大小。
  9. 并发清理阶段(Concurrent cleanup):回收现阶段没有引用的区域。

使用 Shenandoah 时需要全面了解系统运行情况,综合分析系统响应时间。下图是官方给出的各种 GC 工作负载对比:

可以看到,相对于 CMS、G1、Parallel GC,Shenandoah 在系统负载增加的情况下,延迟时间稳定在非常低的水平,而其他几种 GC 都会迅速上升。

7.2 Shenandoah GC常用参数介绍

推荐几个配置或调试 Shenandoah 的 JVM 参数:

  • -XX:+AlwaysPreTouch:使用所有可用的内存分页,减少系统运行停顿,为避免运行时性能损失。
  • -Xmx 等于 -Xms:设置初始堆大小与最大值一致,可以减轻堆内存扩容带来的压力,与 AlwaysPreTouch 参数配合使用,在启动时申请所有内存,避免在使用中出现系统停顿。
  • -XX:+UseTransparentHugePages:能够大大提高大堆的性能。

启发式参数 启发式参数告知 Shenandoah GC何时开始GC处理,以及确定要归集的堆块。可以使用 -XX:ShenandoahGCHeuristics=<name> 来选择不同的启发模式,有些启发模式可以配置一些参数,帮助我们更好地使用 GC。可用的启发模式如下。

1. 自适应模式(adaptive)

此为默认参数,通过观察之前的一些 GC 周期,以便在堆耗尽之前尝试启动下一个 GC 周期。

  • -XX:ShenandoahInitFreeThreshold=#:触发“学习”集合的初始阈值
  • -XX:ShenandoahMinFreeThreshold=#:启发式无条件触发GC的可用空间阈值
  • -XX:ShenandoahAllocSpikeFactor=#:要保留多少堆来应对内存分配峰值
  • -XX:ShenandoahGarbageThreshold=#:设置在将区域标记为收集之前需要包含的垃圾百分比

2. 静态模式(static)

根据堆使用率和内存分配压力决定是否启动 GC 周期。

  • -XX:ShenandoahFreeThreshold=#:设置空闲堆百分比阈值
  • -XX:ShenandoahAllocationThreshold=#:设置内存分配量百分比阈值
  • -XX:ShenandoahGarbageThreshold=#:设置小堆块标记为可回收的百分比阈值
  • -XX:ShenandoahFreeThreshold=#:设置启动GC周期时的可用堆百分比阈值
  • -XX:ShenandoahAllocationThreshold=#:设置从上一个GC周期到新的GC周期开始之前的内存分配百分比阈值
  • -XX:ShenandoahGarbageThreshold=#:设置在将区域标记为收集之前需要包含的垃圾百分比阈值

3. 紧凑模式(compact)

只要有内存分配,就会连续运行 GC 回收,并在上一个周期结束后立即开始下一个周期。此模式通常会有吞吐量开销,但能提供最迅速的内存空间回收。

  • -XX:ConcGCThreads=#:设置并发 GC 线程数,可以减少并发 GC 线程的数量,以便为应用程序运行留出更多空间
  • -XX:ShenandoahAllocationThreshold=#:设置从上一个 GC 周期到新的 GC 周期开始之前的内存分配百分比

4. 被动模式(passive)

内存一旦用完,则发生 STW,用于系统诊断和功能测试。

5. 积极模式(aggressive)

它将尽快在上一个 GC 周期完成时启动新的 GC 周期(类似于“紧凑型”),并且将全部的存活对象归集到一块,这会严重影响性能,但是可以被用来测试 GC 本身。

有时候启发式模式会在判断后把更新引用阶段和并发标记阶段合并。可以通过 -XX:ShenandoahUpdateRefsEarly=[on|off] 强制启用和禁用这个特性。

同时针对于内存分配失败时的策略,可以通过调节 ShenandoahPacingShenandoahDegeneratedGC 参数,对线程进行一定的调节控制。如果还是没有足够的内存,最坏的情况下可能会产生 Full GC,以使得系统有足够的内存不至于发生 OOM。

八、Epsilon GC

其实 Java 11 版本中还新引入了 Epsilon GC,可以通过参数 -XX:+UseEpsilonGC 开启。

Epsilon GC 的目标是只分配内存,不执行垃圾回收。神兽貔貅一般只吃不拉,不回收和释放内存,所以适合用来做性能分析,但无法用于生产环境,很少有人提及,大家有一个映像即可。

因为不回收,所以在程序执行过程中也就没有 GC 消耗,性能测试更准确!

九、GC 的使用总结

到目前为止,我们一共了解了 Java 目前支持的所有 GC 算法,一共有 7 类:

  • 串行 GC(Serial GC):单线程执行,应用需要暂停;
  • 并行 GC(ParNew、Parallel Scavenge、Parallel Old):多线程并行地执行垃圾回收,关注与高吞吐;
  • CMS(Concurrent Mark-Sweep):多线程并发标记和清除,关注与降低延迟;
  • G1(G First):通过划分多个内存区域做增量整理和回收,进一步降低延迟;
  • ZGC(Z Garbage Collector):通过着色指针和读屏障,实现几乎全部的并发执行,几毫秒级别的延迟,线性可扩展;
  • Epsilon:实验性的 GC,供性能分析使用;
  • Shenandoah:G1 的改进版本,跟 ZGC 类似。

从中可以看出 GC 算法和实现的演进路线:

  • 串行 -> 并行:重复利用多核 CPU 的优势,大幅降低 GC 暂停时间,提升吞吐量。
  • 并行 -> 并发:不只开多个 GC 线程并行回收,还将 GC 操作拆分为多个步骤,让很多繁重的任务和应用线程一起并发执行,减少了单次 GC 暂停持续的时间,这能有效降低业务系统的延迟。
  • CMS -> G1:G1 可以说是在 CMS 基础上进行迭代和优化开发出来的。修正了 CMS 一些存在的问题,而且在 GC 思想上有了重大进步,也就是划分为多个小堆块进行增量回收,这样就更进一步地降低了单次 GC 暂停的时间。可以发现,随着硬件性能的提升,业界对延迟的需求也越来越迫切。
  • G1 -> ZGC:ZGC 号称无停顿垃圾收集器,这又是一次极大的改进。ZGC 和 G1 有一些相似的地方,但是底层的算法和思想又有了全新的突破。 ZGC 把一部分 GC 工作,通过读屏障触发陷阱处理程序的方式,让业务线程也可以帮忙进行 GC。这样业务线程会有一点点工作量,但是不用等,延迟也被极大地降下来了。

综合来看,G1 是 JDK11 之前 HotSpot JVM 中最先进的准产品级(production-ready) 垃圾收集器。重要的是,HotSpot 工程师的主要精力都放在不断改进 G1 上面。在更新的 JDK 版本中,将会带来更多强大的功能和优化。

可以看到,G1 作为 CMS 的代替者出现,解决了 CMS 中的各种疑难问题,包括暂停时间的可预测性,并终结了堆内存的碎片化。对单业务延迟非常敏感的系统来说,如果 CPU 资源不受限制,那么 G1 可以说是 HotSpot 中最好的选择,特别是在最新版本的 JVM 中。当然这种降低延迟的优化也不是没有代价的:由于额外的写屏障和守护线程,G1 的开销会更大。如果系统属于吞吐量优先型的,又或者 CPU 持续占用 100%,而又不在乎单次 GC 的暂停时间,那么 CMS 是更好的选择。总之,G1 适合大内存,需要较低延迟的场景。

选择正确的 GC 算法,唯一可行的方式就是去尝试,并找出不合理的地方,一般性的指导原则:

  • 如果系统考虑吞吐优先,CPU 资源都用来最大程度处理业务,用 Parallel GC;
  • 如果系统考虑低延迟有限,每次 GC 时间尽量短,用 CMS GC;
  • 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。

对于内存大小的考量:

  • 一般 4G 以上,算是比较大,用 G1 的性价比较高。
  • 一般超过 8G,比如 16G-64G 内存,非常推荐使用 G1 GC。

最后讨论一个很多开发者经常忽视的问题,也是面试大厂常问的问题:

JDK 8 的默认 GC 是什么?很多人或觉得是 CMS,甚至 G1,其实都不是。答案是:并行 GC 是 JDK8 里的默认 GC 策略。注意,G1 成为 JDK9 以后版本的默认 GC 策略,同时,ParNew + SerialOld 这种组合不被支持。

同时我们应该注意到,并发压缩目前似乎是减少暂停时间的最佳解决方案。但是经验告诉我们:“脱离场景谈性能都是耍流氓”。 

目前绝大部分 Java 应用系统,堆内存并不大比如 2G~4G 以内,而且对 10ms 这种低延迟的 GC 暂停不敏感,也就是说处理一个业务步骤,大概几百毫秒都是可以接受的,GC 暂停 100ms 还是 10ms 没多大区别。另一方面,系统的吞吐量反而往往是我们追求的重点,这时候就需要考虑采用并行 GC 或 CMS。如果堆内存再大一些,可以考虑 G1、GC。如果内存非常大(比如超过 16G,甚至是 64G、128G),或者是对延迟非常敏感(比如高频量化交易系统),就需要考虑使用本节提到的新 GC 实现。

伴随着业务需求、Java 生态的演进,硬件技术的进步,以及 GC 理论研究的进展,我们可选的 GC 算法越来越多,适用的场景也越来越多。每一种 GC 算法都有自己的适应场景,只是范围广不广而已。

现在是技术爆炸的时代,随着各类业务场景的不断涌现,新的技术也在不断发展。新的技术升级一般都是为了针对性地解决原有技术的一些问题。这样只要我们能提前预研一些新技术,就能够最短的时间把这些新技术应用到自己的某些适当场景,享受到技术作为第一生产力的红利,提升技术水平和对业务的支持发展和服务能力。

博文参考

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

庄小焱

我将坚持分享更多知识

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值