经历了数千次改进,Java 的垃圾回收在吞吐量、延迟和内存大小方面有了巨大的进步。
2014 年3 月 JDK 8 发布,自那以来 JDK 又连续发布了许多版本,直到今日的 JDK 18 是 Java 的第十个版本。借此机会,我们来回顾一下 HotSpot JVM 的垃圾回收器的发展全过程。
HotSpot JVM 中负责管理应用程序堆的组件叫做“垃圾回收器”(Garbage Collector,即GC)。GC 负责管理应用程序堆对象的整个生命周期,从应用程序分配内存到内存被回收,都由 GC 负责。
从高层来看,JVM 垃圾回收算法的最基本功能如下:
当应用程序请求分配内存时,GC 负责提供内存。提供内存的过程应尽可能快;
GC 检测应用程序不再使用的内存。这个操作也应当十分高效,不应消耗太多时间。这种不再使用的内存称为“垃圾”;
GC 将同一块内存再次提供给应用程序,最好是“实时”,也就是要快。
好的垃圾回收算法还有更多的需求,但这三条是最基本的,也足以支撑本文的讨论了。
满足这些需求有很多方法,但很不幸,我们并没有一蹴而就的方法,也没有能一次性解决所有需求的方法。因此,JDK 提供了多种垃圾回收算法以供选择,每种算法适用于不同的场景。这些算法的实现基本上可以根据吞吐量、延迟和内存大小这三个性能度量,以及对应用程序的影响进行归类。
吞吐量指的是单位时间内能够完成的工作量。在此语境下,垃圾回收算法的优劣取决于能在单位时间内完成的回收工作量,这些算法可以让 Java 应用程序实现更高的吞吐量;
延迟指的是单次操作所需时间。垃圾回收算法需要尽可能减小延迟。在垃圾回收的语境下,关键点就是垃圾回收期是否会导致暂停、暂停的范围,以及暂停的时长;
在垃圾回收的语境下,内存大小指的是为了让垃圾回收期正常工作,需要在正常的应用程序堆内存之外,再额外占用多少内存。如果 GC(或更一般地,JVM)需要的内存很少,就可以给应用程序堆留出更多内存。
这三个度量是互相关联的:高吞吐量的垃圾回收器可能会严重影响延迟(但对应用程序的影响最小)。为了降低内存消耗,我们需要采用在其他度量方面不是那么出色的算法。延迟较低的回收期需要并行进行更多工作,或以更小的单位进行工作,这就会消耗更多处理器资源。
这些关系通常可以画成一个三角形,如图1所示。每个垃圾回收算法占据三角形的一个角。
图1. GC 性能度量三角
提高 GC 在某方面的表现,通常会导致其他方面的表现降低。
2. JDK 18 中的 OpenJDK GC
OpenJDK 提供了五种 GC,分别专注于不同的性能度量。表 1 列出了 GC 的名称、专注领域,以及实现特定特性所使用的核心概念。
表1. OpenJDK的五种GC
Parallel GC 是 JDK 8 以及更早版本的默认回收期。它专注于吞吐量,尽快完成工作,而很少考虑延迟(暂停)。
Parallel GC 会在 STW(全局暂停)期间,以更紧凑的方式,将正在使用中的内存移动(复制)到堆中的其他位置,从而制造出大片的空闲内存区域。当内存分配请求无法满足时就会发生 STW 暂停,然后JVM完全停止应用程序运行,投入尽可能多的处理器线程,让垃圾回收算法执行内存压缩工作,然后分配请求的内存,最后恢复应用程序执行。
Parallel GC 也是一个分代回收器,旨在最大化垃圾回收效率。本文稍后会详细讨论分代式回收的思想。
G1 GC 是JDK 9 以后的默认回收期。G1 试图平衡吞吐量和延迟。一方面,在 STW 暂停期间,依然会利用分代继续执行内存回收工作,从而最大化效率,这一点和 Parallel GC 相同;但是,它还会尽可能避免在暂停期间执行需要较长时间的操作。
G1 的长时间操作会与应用程序并行进行,即通过多线程方式,在应用程序运行时执行。这样可以大幅度减少暂停,代价是整体的吞吐量会降低一点。
ZGC 和 Shenandoah GC 专注于用吞吐量换延迟。这两种回收器会尝试在不进行明显的暂停的前提下,完成所有垃圾回收工作。目前,这两者都不是分代式的。它们的非实验性版本分别于 JDK 15 和 JDK 12 引入。
Serial GC 专注于内存大小和启动时间。这个 GC 像是更简单、更慢的 Parallel GC,它在 STW 暂停期间仅使用一个线程完成所有工作。堆也是按照分代组织的。但是 Serial GC 占用的内存更小、启动速度更快。由于它更简单,所以更适合小型、短时间运行的应用程序。
OpenJDK 还提供了另一个名为 Epsilon 的 GC。为什么没有在表 1 中列出呢?因为 Epsilon 只执行内存分配,从不进行内存回收,因此不满足 GC 的所有条件。但是,Epsilon 适合一些非常特殊的应用程序。
3. G1 GC 简介
G1 GC 于 JDK 6 update 14 作为实验特性引入,从 JDK 7 update 4 开始正式支持。从JDK 9 开始,G1 由于其多用性,成了 HotSpot JVM 的默认垃圾回收器:它非常稳定、成熟,维护也非常活跃,而且一直在改进。
那么,G1 是如何在吞吐量和延迟之间进行平衡的呢?
一项关键技术就是分代垃圾回收。该技术利用了一个特点:最近分配的对象很可能可以立即回收(即它们“死亡”得更快)。所以 G1(以及其他分代式 GC)将 Java 的堆分为两个区域:一个叫做“青年代”,用于存放刚刚分配的对象;另一个叫做“老年代”,用于存放经历了几次垃圾回收后依然存活的对象,从而减少回收时所需的操作。
通常,青年代要比老年代小得多。因此,回收青年代的开销更小,再加上G1这种跟踪式的垃圾回收器在回收青年代对象时通常只会处理活跃对象,这就意味着青年代的垃圾回收一般非常快,而且能回收大量内存。
在某个时间点,长时间存活的对象会被移动到老年代中。
因此,随着老年代不断增长,我们也需要对其进行垃圾回收。由于老年代一般很大,而且通常包含相当多的活跃对象,对其进行回收需要花费很长时间。(例如,Parallel GC的完全回收过程通常需要消耗青年代回收数倍的时间。)
因此,G1 将老年代垃圾回收过程分成了两个阶段。
G1首先跟踪活跃对象,这一操作与Java应用程序并行进行。这样,从老年代回收内存的大量操作就不需要在垃圾回收暂停期间执行了,从而减小延迟。不过,实际的内存回收操作如果一次性完成的话,对于大型应用程序的堆而言,依然需要大量时间。
因此,G1会增量式地从老年代回收内存。在跟踪了活跃对象之后,在接下来的几次对青年代进行回收的同时,G1会额外对老年代中的一小部分进行压缩,这样长期即可达到对年长对象进行回收的效果。
对年长对象进行增量回收,比一次性回收(如 Parallel GC 的做法)的效率略低,因为跟踪对象关系图总会不准确,而且增量回收所需的数据结构的管理也需要额外的时间和空间开销,但这种方式可以有效减小暂停的时长。大致来看,增量式垃圾回收所需的时长基本上等于只回收青年代的算法在暂停中所花费的时长。
此外,你还可以通过 MaxGCPauseMillis 命令行选项设置两种垃圾回收算法的暂停时长的目标。G1 会尽可能将暂停时长保持在目标以下。默认的时长为 200 毫秒,这个值也许不适合你的应用程序,但它只是最大值的目标。G1 会尽可能将暂停时长控制在该值以下。因此,改善暂停时长的第一步,可以从减小 MaxGCPauseMillis 开始。
4. 从 JDK 8 到 JDK 18 的进步
介绍完了 OpenJDK 的 GC,我们来进一步看看在过去 10 次 JDK 发布中,GC 在吞吐量、延迟和内存大小三个性能度量方面的进步。
G1 的吞吐量增长。为了演示吞吐量和延迟方面的进步,本文采用了 SPECjbb2015 基准测试。SPECjbb2015 是一个衡量 Java 服务器性能的常用业界测试,它包含了一系列各种各样的操作。该测试包含两个度量:
maxjOPS 是系统能够提供的最大事务数量。这是吞吐量的度量指标;
criticaljOPS 测量在几个特定的服务级别协议(SLA)下的吞吐量,比如从 10 毫秒到 100 毫秒的响应时间。
本文采用 maxjOPS 作为比较不同 JDK 版本的吞吐量的基准,采用实际暂停时长的改进量作为比较延迟的基准。虽然 criticaljOPS 也表明了暂停时长引起的延迟,但该指标还包含其他来源的延迟。直接比较暂停时长可以避免这个问题。
图 2 展示了 G1 在组合模式下在一个 16GB 的 Java 堆上的 maxjOPS 结果,图中给出了JDK 8、JDK 11 和 JDK 18 的对比。可以看出,JDK 版本越新,吞吐量得分就越高。JDK 11 比 JDK 8 高出了约 5%,而 JDK 18 高出了约 18%。简单来说,JDK 版本越新,用于应用程序实际工作的资源就越多。
图2. G1d 的吞吐量增长,利用 SPECjbb2015 的 maxjOPS 测量
下面,我们着重讨论垃圾回收的改进对于吞吐量增长的贡献。但是,其他的一般性改进(如代码编译)也对垃圾回收的性能——特别是吞吐量的增长——有很大的贡献,所以垃圾回收的改进并不是唯一的贡献者。
JDK 9 之前的一个重大改进是 G1 采用了懒惰式老年代回收,它会尽可能推迟回收操作。
在 JDK 8 中,用户需要手动设置 G1 何时应该对老年代回收中的活跃对象进行并行跟踪。如果时机设置得太早,JVM 在回收操作开始之前,并没有用完所有分配给老年代的堆内存,如此老年代中的对象并没有得到足够多的时间从而变成可回收的状态。因此, G1 不仅需要更多的处理资源来分析其活跃状态(因为许多数据依然处于活跃中),还要做许多额外的工作才能从老年代中释放内存。
另一个问题是,如果开始老年代回收的时机太晚,JVM就可能会耗尽内存,从而导致内存回收过程极其缓慢。从JDK 9开始,G1会自动决定开始老年代跟踪的最佳时机,甚至还会自动适配应用程序的行为。
JDK 9 中实现的另一个思想涉及到 G1 对于老年代中的大型对象的回收频率比其他对象高的现象。与分代的思想类似,这是另一个投入产出比很高的想法。毕竟,大型对象所占用的内存空间很多。在某些应用程序中(尽管不太常见),该方法甚至能大幅度减少垃圾回收的次数,并降低整体的暂停时长,使 G1 的吞吐量大大超过 Parallel GC。
一般来说,每次发布都会包含一些改进,减小垃圾回收在执行同样操作时的暂停时长。这样就会自然地改善吞吐量。还有许多可以写在本文中的改进,接下来我们在讨论延迟改进时会提到一些。
与 Parallel GC 类似,从 JDK 14 开始,G1 在 Java 堆上分配内存时,可以独立地感知非统一性内存访问(NUMA)。从那时起,在拥有多内存插槽且各个内存的访问时间不一致的机器上(也就是说内存访问与内存插槽有关,即某些内存访问更慢),G1 会尽可能利用本地性。
有了 NUMA 感知后,G1 GC 会假设在某个内存节点上(由单个线程或线程组)分配的对象基本上被来自同一个节点的其他对象引用。因此,当对象属于青年代时,G1 会将对象保持在同一节点上,甚至还会将老年代中的长时间生存的对象分布到不同节点上,以最小化访问时间的不一致性。这与 Parallel GC 的实现类似。
还有一个我想讨论的改进是关于一些罕见情况的,比如完整回收。正常情况下,G1 会调整内部参数,尽力避免完整回收,但是在一些极端情况下,G1 会在暂停期间进行完整回收。直到 JDK 10 之前,该算法都是单线程的,所以非常慢。而目前的实现与 Parallel GC 的完整回收过程不相上下。它依然很慢,依然应当尽力避免,但比以前已经好多了。
Parallel GC 的吞吐量增长。关于 Parallel GC,图3给出了从 JDK 8 到 JDK 18 中 maxjOPS 的改进结果,堆的设置与之前的测试相同。同样,即使是 Parallel GC,仅仅替换 JVM 也可以获得大约2%的吞吐量提升,最好情况下甚至能提升 10%。提升比 G1 小,因为 Parallel GC 原本的起点就很高,因此增长较小。
图3. Parallel GC 的吞吐量增长,用 SPECjbb2015 的 maxjOPS 度量
G1 的延迟改进。为了演示 HotSpot JVM GC 在延迟方面的改进,本节采用了 SPECjbb2015 基准测试,负载固定,然后测量其暂停时长。Java 堆设置为 16GB。表 2 总结了暂停时长的平均值和第 99 百分位值(P99),以及在 200 毫秒的默认暂停时长目标值下,不同 JDK 的相对暂停总时长。
表2 默认的200毫秒暂停时长下的延迟改进
JDK 8 的暂停平均时长为 124 毫秒,P99 为 176 毫秒。JDK 11 将平均时长提高到了 111 毫秒,P99 提高到了 134 毫秒,总体减少了 15.8% 的暂停时长。JDK 18 再次显著改善,平均时长减少到了 89 毫秒,P99 减小到了 104 毫秒,总时长减小了 34.4%。
我扩展了试验范围,增加了JDK 18 下暂停时长设置为 50 毫秒,因为之前随意设置的 -XX:MaxGCPauseMillis 为 200 毫秒还是太长了。平均来看,G1 达到了暂停时长的目标,P99 垃圾回收暂停时长为 56 毫秒(见表 3)。总体上,与 JDK 8 相比,暂停花费的总时间并没有增加太多(0.06%)。
换句话说,将 JDK 8 JVM 替换成 JDK 18 JVM,就能获显著降低平均暂停时长,同时还有可能在同样的暂停时长目标下提升吞吐量;或者将 G1 的暂停时长保持在更低的水平(50 毫秒),而暂停总时长保持不变,同时保持相同的吞吐量。
表 3. 将暂停时长目标设置为 50 毫秒后的延迟改进
表 3 的结果是自从 JDK 8 以来大量改进的结果。下面是最值得一提的改进。
降低延迟的许多改进都用在了减小收集老年代对象所需的元数据上。“记住的集合”(remembered sets)的数据结构得到了大幅度删减,部分原因是数据结构的精简,另一部分是不存储永远不会用到的数据。在今天的计算机体系架构中,减小元数据意味着更小的内存访问开销,能够带来性能的提升。
有关“记住的集合”的另一个方面是,人们改进了查找指向堆中当前被移动的区域的引用的算法,使其更容易并行化。G1 不再并行遍历整个数据结构并在内层循环中过滤掉重复数据,而是分别并行地过滤掉重复数据,再并行地处理剩余数据。这样可以让两个步骤都更有效、更容易并行化。
进一步,处理记住的集合的过程也被仔细分析,删减了不必要的代码,优化了常用路径。
JDK 8 之后的另一个焦点是,通过一个暂停来改进任务的并行化。人们尝试将任务的多个阶段并行化,或将较小的顺序阶段变成更大的并行阶段,以此避免不必要的同步,从而改进并行化。人们在这方面投入了大量资源来改进并行阶段的负载平衡性,这样如果某个线程没有任务时,它会尝试从其他线程那里获取任务。
此外,后续的JDK开始着手更罕见的情况,其中之一就是内存移动失败(evacuation failure)。如果会在垃圾回收时,没有足够的空间复制对象时,就会发生内存移动失败。
ZGC 的垃圾回收暂停。如果你的应用程序需要更短的垃圾回收暂停时长,可以参考表 4,该表比较了 G1 与另一个专注于暂停时长的垃圾回收期 ZGC。该表采用的负载与前面相同。最右边一列给出了 ZGC 的暂停时长。
表 4. ZGC 与 G1 的延迟比较
ZGC 实现了亚毫秒级别的暂停时长目标,它的全部内存回收工作都与应用程序并行执行。只有部分不重要的工作依然需要暂停。可以想象,这些暂停非常短暂,在上述情况下,暂停时长甚至远远低于 ZGC 声称的毫秒级别。
G1 的内存占用改进。本文的最后一项指标就是 G1 垃圾回收算法的内存占用方面的改进。此处,算法的内存大小指的是垃圾回收算法为了正常工作,在正常的 Java 堆之外所需的额外内存大小。
对于 G1 来说,除了依赖于 Java 堆大小的静态数据(大小大约为 Java 堆尺寸的 3.2%),另一个主要的内存消耗来源是“记住的集合”,它负责分代垃圾收集,以及老年代的增量垃圾收集处理。
会给G1的记住的集合带来压力的应用之一是对象缓存。每当对象缓存增加或删除新的缓存项时,都会在堆上的老年代中,不断生成区域之间的引用。
图 4 展示了从 JDK 8 到 JDK 18 中,G1 的原生内存占用情况,测试应用程序实现了一个对象缓存:对象表示缓存信息,对象可以被查询、添加,并以最近最少使用(LRU)的方式从一个更大的堆中删除。本例中的Java堆为20GB,使用了JVM的原生内存跟踪(NMT)机制来确定内存使用情况。
图 4. G1 GC 的原生内存大小
在 JDK 8 中,经过了短暂的预热阶段后,G1 原生内存使用稳定在 5.8GB 左右。JDK 11在此基础上,将原生内存代销降低到了 4GB 左右;JDK 17 进一步改进到 1.8GB,而 JDK 18 稳定在 1.25GB。额外内存使用量从 JDK 时代的 30% 堆大小降低到了 JDK 18 时代的 6% 左右。
如前所示,这些改进并没有造成吞吐量下降或延迟提升。实际上,G1 GC 减小元数据,也给其他度量带来了提升。
从 JDK 8 到 JDK 18,这些改进的主要原则是,将垃圾回收元数据严格维持在仅保存必须数据的限度。因此,G1 会并行地重建并管理内存,尽快释放数据。JDK 18 对元数据的表现方式和存储也进行了改进,存储得更紧密,因此有效降低了内存大小。
图4还表明,在新版的 JDK 中,G1 更为积极,会主动查找稳态操作的高峰和低谷中的差异,更积极地将内存交还给操作系统。在最新的版本中,G1 甚至会并行执行该操作。
5. 垃圾回收的未来
尽管很难预测未来会怎样、以后会有多少垃圾回收方面的项目,但 G1 很可能会在 HotSpot JVM 中实现下面这些改进。
人们在努力解决的问题之一是,在原生代码使用 Java 对象时,会阻止垃圾回收的进行。如果有任何区域引用了原生代码中使用的 Java 对象,触发垃圾回收的 Java 线程就必须等待。最糟糕的情况下,原生代码甚至会阻止垃圾回收长达数分钟。这会导致开发人员完全避免使用原生代码,从而大幅度影响吞吐量。JEP 423 给出了解决方案,因此 G1 GC 很快就能解决该问题。
与 Parallel GC 相比,G1 GC 的另一个已知问题是,它会影响吞吐率。根据用户报告,在极端情况下,影响甚至会达到 10%~20%。问题的原因是已知的,人们已经提出了几种在不影响 G1 GC 其他方面的品质的前提下的解决方案。
最近人们还发现,暂停时长和暂停期间的负载分散的效率依然不是最优的。
最近人们的焦点是将 G1 的最大的辅助数据结构标记位图削减一半。G1 算法使用了两个位图,用于确定哪些对象活跃,可以安全地并行检查。一项仍在讨论的建议表明,这两个位图之一可以通过其他方式取代。这就能将G1的元数据削减至一半大小,至 Java 堆大小的1.5%。
ZGC 和 Shenandoah GC 也有很多在积极开发的项目,着眼于将这两个垃圾回收器改造成分代式垃圾回收器。在许多应用中,这两个 GC 的单分代设计在吞吐量和即时性方面有太多的缺陷,因此需要更大的堆大小来补偿。
6. 总结
本文展示了 HotSpot JVM 垃圾回收算法从 JDK 8 到 JDK 18 的改进。这些改进非常显著,所有三个性能指标,包括吞吐量、延迟和内存大小,都得到了显著提升。每次 JDK 发布新版本,都会带来可见的提升。在可见的未来,这种趋势仍将继续,所以请期待这些改进吧。
感谢 OpenJDK 各位贡献者们付出的努力。