Skip to content

JEP 318: Epsilon: A No-Op Garbage Collector (Experimental) | Epsilon:无操作的垃圾收集器

摘要

开发一个处理内存分配但不实现任何实际内存回收机制的垃圾收集器。一旦可用的 Java 堆内存耗尽,JVM 将关闭。

目标

提供一个完全被动的垃圾收集器实现,具有有限的分配限制和尽可能低的延迟开销,代价是牺牲内存占用和内存吞吐量。成功的实现是一个独立的代码更改,不触及其他垃圾收集器,并且对 JVM 的其余部分进行最小的更改。

非目标

非目标包括向 Java 语言或 JVM 引入手动内存管理功能。非目标还包括向 Java 堆管理引入新的 API。此外,非目标还包括更改或清理内部 JVM 接口以适应此垃圾收集器。

动机

Java 实现以其高度可配置的垃圾收集器实现选择广泛而著称。尽管它们的可配置性使得它们的功能有所重叠,但可用的收集器种类繁多,最终满足了不同的需求。有时,维护一个独立的实现比在现有的垃圾收集器实现上增加另一个配置选项更容易。

在某些情况下,一个无操作的垃圾收集器证明是有用的:

  • 性能测试。 拥有一个几乎不执行任何操作的垃圾收集器是进行其他真实垃圾收集器的差异性能分析的有用工具。无操作的垃圾收集器有助于过滤掉由垃圾收集器诱发的性能伪影,如垃圾收集器工作线程的调度、垃圾收集器屏障的消耗、在不合适的时间触发的垃圾收集周期、位置变化等。此外,还有一些不是由垃圾收集器诱发的延迟伪影(例如调度停顿、编译器转换停顿等),而去除由垃圾收集器诱发的伪影有助于对比这些伪影。例如,拥有无操作的垃圾收集器可以估计低延迟垃圾收集工作的自然“背景”延迟基线。

  • 内存压力测试。 对于 Java 代码测试,建立已分配内存阈值的方法对于断言内存压力不变式很有用。目前,我们不得不从 MXBeans 中收集分配数据,甚至不得不解析垃圾收集日志。拥有一个只接受有限数量分配并在堆耗尽时失败的垃圾收集器可以简化测试。例如,如果我们知道测试应该分配的内存不超过 1GB,我们可以将无操作的垃圾收集器配置为-Xmx1g,并在违反此约束时使其崩溃并生成堆转储。

  • VM 接口测试。对于 VM 开发目的,拥有一个简单的垃圾收集器有助于理解 VM-GC 接口实现功能分配器所需的最少内容。对于无操作的垃圾收集器,接口不应该实现任何内容,而良好的接口意味着 Epsilon 的 BarrierSet 将仅使用默认实现中的无操作屏障实现。这证明了 VM-GC 接口的合理性,这在JEP 304(“垃圾收集器接口”)的背景下非常重要。

  • 极短生命周期的工作。短生命周期的工作可能依赖于快速退出以释放资源(例如堆内存)。在这种情况下,接受 GC 周期徒劳地清理堆是浪费时间,因为堆在退出时无论如何都会被释放。请注意,GC 周期可能会花费一些时间,因为它将取决于堆中活动数据的数量,这可能会很多。

  • 最后一点延迟的改进。对于超延迟敏感的应用程序,开发人员需要注意内存分配并确切知道应用程序的内存占用,或者甚至拥有(几乎)完全无垃圾的应用程序,接受 GC 周期可能是一个设计问题。在某些情况下,重新启动 JVM——让负载均衡器决定故障转移——有时是比接受 GC 周期更好的恢复策略。在这些应用程序中,长时间的 GC 周期可能被认为是错误的行为,因为这延长了故障的检测时间,并最终延迟了恢复。

  • 最后一点吞吐量的改进。即使对于非分配工作负载,选择 GC 意味着选择工作负载必须使用的 GC 屏障集,即使实际上没有发生 GC 周期。所有 OpenJDK GC 都是分代的(除了非主线的 Shenandoah 和 ZGC 的显著例外),并且它们至少发出一个引用写屏障。避免这个屏障可以带来最后一点吞吐量的改进。这里存在一些局部性的注意事项,请见下文。

描述

Epsilon GC 看起来和感觉起来就像其他 OpenJDK GC 一样,通过 -XX:+UseEpsilonGC 启用。

Epsilon GC 通过在单个连续的内存块中实现线性分配来工作。这允许 GC 中实现简单的无锁 TLAB(线程局部分配缓冲区)发放代码,然后可以重用现有 VM 代码处理的 TLAB 内的无锁分配。发放 TLABs 还有助于保持进程占用的常驻内存受到实际分配的内存的限制。Humongous/out-of-TLAB 分配由相同的代码处理,因为在此方案中分配 TLAB 和分配大型对象之间的差异很小。

Epsilon 使用的屏障集是完全空的/无操作的,因为 GC 不执行任何 GC 循环,因此不关心对象图、对象标记、对象复制等。引入新的屏障集实现可能是此实现中最具破坏性的 JVM 更改。

由于 Epsilon 的运行时接口的唯一重要部分是发放 TLABs,因此其延迟在很大程度上取决于发放的 TLAB 大小。对于任意大的 TLAB 和任意大的堆,延迟开销可以用任意小的正值来描述,因此得名。(另一种起源故事:“epsilon”经常表示“空符号”,这与此 GC 的无操作性质相吻合)。

一旦 Java 堆内存耗尽,就无法进行任何分配,也无法回收任何内存,因此我们必须宣告失败。此时有几个选项,大多数都与现有 GC 的做法一致:

  • 抛出一个带有描述性消息的 OutOfMemoryError

  • 执行堆转储(通常通过 -XX:+HeapDumpOnOutOfMemoryError 启用)

  • 强制 JVM 失败,并可选地执行外部操作(通过通常的 -XX:OnOutOfMemoryError=...),例如启动调试器或通知外部监控系统出现故障。

System.gc() 调用上无法执行任何操作,因为没有实现内存回收代码。实现可能会警告用户,尝试强制 GC 是徒劳的。

原型运行通过在小工作负载上存活并在更大工作负载上可预测地失败来证明这一概念。原型实现和一些测试可以在沙箱存储库中找到:

bash
$ hg clone http://hg.openjdk.java.net/jdk/sandbox sandbox
$ hg up -r epsilon-gc-branch
$ sh ./configure
$ make images

可以使用以下命令查看基准运行时和补丁运行时之间的差异:

bash
$ hg diff -r default:epsilon-gc-branch

自动生成的 webrev:https://builds.shipilev.net/patch-openjdk-epsilon-jdk/

示例二进制构建:https://builds.shipilev.net/openjdk-epsilon-jdk/

或者在 Docker 中:

bash
$ docker pull shipilev/openjdk-epsilon
$ docker run -it --rm shipilev/openjdk-epsilon java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xlog:gc -version
[0.006s][info][gc] 初始化时带有 2009M 堆内存,可调整至最大 30718M 堆内存,以 128M 为步长
[0.006s][info][gc] 使用 TLAB 分配;最小:2K,最大:4096K
[0.006s][info][gc] 使用 Epsilon GC
openjdk 版本 "11-internal" 2018-03-20
OpenJDK 运行时环境(构建 11-internal+0-nightly-sobornost-builds.shipilev.net-epsilon-jdkX-b32)
OpenJDK 64 位服务器 VM(构建 11-internal+0-nightly-sobornost-builds.shipilev.net-epsilon-jdkX-b32,混合模式)
[0.071s][info][gc] 总共分配:899 KB
[0.071s][info][gc] 平均分配速率:12600 KB/秒

替代方案

配置现有 GC 永远不执行循环。例如,使用 Serial 或 Parallel GC 应该符合相同的延迟特性,假设我们可以配置它们各自的启发式算法,使其在面临完全堆内存耗尽之前永不执行 GC 循环(例如,通过预调整堆内存大小、设置非常大的新生代大小、禁用自适应启发式算法等)。然而,由于它们提供了众多的 GC 选项,很难可靠地保证这一点,而且 GC 的持续改进也会迫使我们重新考虑无操作路径。

修改现有 GC 以永远不执行循环。我们可以使这些 GC 中的特殊选项更加可靠,但这可能违背了这些 GC 的设计目标。例如,用 DoNotGC 保护这些 GC 的大多数代码路径,并不比提供单独的独立实现明显更好。

移除现有 GC 实现的核心部分。另一种选择是移除现有 GC 实现的核心部分,以获得用于测试的基准实现。问题在于这样做会造成不便:开发人员需要确保这样的实现仍然是正确的,提供足够的性能以成为良好的基准,同时将其与其他运行时设施(堆转储、线程堆栈遍历、MXBeans)连接起来,以修正差异分析。其他平台的实现将需要更多的工作。在主线中提供现成的无操作实现可以解决这种不便。

移除现有 GC 屏障集。目前没有现成的替代方案可以禁用所有 GC 屏障,但我们可以为现有 GC 屏蔽屏障集。不幸的是,这同样会引发上述问题,而且由于需要在屏蔽后禁用 GC,问题变得更为复杂,因为 GC 通过屏障所期望的基本不变量将不再保持。

在 Parallel、G1 和 Shenandoah GC 中的进一步改进最终可能会实现足够低的开销,从而不再需要无操作 GC。如果这种情况发生,Epsilon 仍然会用于内存压力和性能测试。

测试

通用的 GC 测试对于 Epsilon GC 并不适用,因为大多数测试都假定它们可以分配任意数量的垃圾。需要开发新的测试来验证 GC 是否确实在低分配工作负载上表现良好,以及在堆内存耗尽时能否以可预测的方式失败。在 hotspot/gc/epsilon 下编写新的 jtreg 测试就足够断言其正确性。

在开发期间进行一次性的性能测试,就足以确保在使用解释器、C1 和 C2 编译器运行时具有期望的性能特性。由于实现打算在初始实现后永不更改,且其性能敏感路径已由其他 GC 隐式测试,因此不需要进行持续的性能测试。

风险与假设

实用性与维护成本。有人可能会认为这样的实现在产品中毫无用处,因为没人需要它。然而,经验告诉我们,Java 生态系统中的许多参与者已经通过从他们自定义构建的 JVM 中剔除 GC 来进行了这样的尝试。这意味着,拥有一个标准的无操作 GC 选项将有助于生态系统的那一部分。如果实现证明是微不足道的,那么维护成本也很低,因此这个风险是最小的。我们还认为,如果该功能仅在非产品构建中可用,并且带有“开发”标志,那么风险也是最小的。用户和下游发行版可以将其更改为“产品”或“实验”标志,以便将 Epsilon 暴露给他们的应用程序。

公众期望。提供一个实际上不进行垃圾收集的垃圾收集器可能被视为危险的做法。在生产环境中意外启用 Epsilon GC 可能导致 JVM 在堆内存耗尽时发生意外故障。我们认为,如果该功能在默认情况下在产品构建中不可用,而是在“开发”或“实验”选项下提供,那么这种风险是最小的。

局部性考虑。非压缩 GC 意味着它按分配顺序维护对象图。这会影响空间局部性,如果分配是随机的或产生大量稀疏垃圾,常规应用程序可能会受到吞吐量下降的影响。虽然这可能会带来一些吞吐量开销,但这超出了 GC 的控制范围,并且会影响大多数非移动 GC。如果局部性证明是一个问题,那么将需要进行局部性感知的应用程序编码来减轻这一缺点。

实现复杂性。可能会出现这样的情况:实现所需的共享代码更改比预期的多,例如在编译器和特定于平台的后端中。我们的原型表明,这些更改是足够孤立的,不会造成负面影响。如果这证明是一个风险,那么应该通过JEP 304(“垃圾收集器接口”)来减轻这一风险。

依赖关系

这项工作可能依赖于JEP 304(“垃圾收集器接口”),以最小化共享代码的更改。然而,如果共享代码的更改很小,那么这项工作可能不需要该接口。