跳到主要内容

JEP 423:G1 的区域固定

概括

通过在 G1 中实现区域固定来减少延迟,以便在 Java 本机接口 (JNI) 关键区域期间无需禁用垃圾收集。

目标

  • 不会因 JNI 关键区域而导致线程停顿。

  • 由于 JNI 关键区域,启动垃圾收集不会产生额外的延迟。

  • 当没有 JNI 关键区域处于活动状态时,GC 暂停时间不会回归。

  • 当 JNI 关键区域处于活动状态时,GC 暂停时间的回归最小。

动机

为了与非托管编程语言(例如 C 和 C++)进行互操作,JNI定义了函数来获取然后释放指向 Java 对象的直接指针。这些函数必须始终成对使用:首先,获取指向对象的指针(例如,via GetPrimitiveArrayCritical);然后,在使用该对象后,释放指针(例如,via ReleasePrimitiveArrayCritical)。此类函数对中的代码被认为是在_关键区域_中运行,并且在此期间可用的 Java 对象是_关键对象_。

当 Java 线程位于关键区域时,JVM 必须注意不要在垃圾收集期间移动关联的关键对象。它可以通过将这些对象_固定_到它们的位置来实现这一点,本质上是在 GC 移动其他对象时将它们锁定在适当的位置。或者,只要线程位于关键区域,它就可以简单地禁用 GC。

默认 GC G1采用后一种方法,在每个关键区域禁用 GC 。这对延迟有显着影响:如果 Java 线程触发 GC,那么它必须等待,直到关键区域中没有其他线程。影响的严重程度取决于关键区域的频率和持续时间。在最糟糕的情况下,用户报告关键部分会阻塞整个应用程序几分钟,由于线程匮乏而导致不必要的内存不足情况,甚至虚拟机过早关闭。由于这些问题,一些 Java 库和框架的维护者选择默认不使用关键区域(例如JavaCPP),甚至根本不使用关键区域(例如Netty),尽管这样做会对吞吐量产生不利影响。

通过我们在此建议的更改,Java 线程将永远不会等待 G1 GC 操作完成。

描述

背景

G1将堆划分为固定大小的_内存区域_(不要与_临界_区域混淆)。 G1 是分代收集器,因此任何非空区域都是年轻代或老年代的成员。在任何特定的收集操作中,对象仅从区域的子集_疏散(即移动)到某个其他子集。_

如果 G1 在次要(即年轻代)收集期间无法找到空间来疏散对象,那么它将将该对象留在原地,并将其及其包含区域标记为_疏散失败_。疏散后,G1 通过将失败区域从年轻代提升到老年代来修复失败区域,从而为后续疏散做好准备。

G1 已经能够在主要(即完整)收集操作期间将对象固定到其内存位置,只需不撤离包含它们的区域即可。例如,G1 固定包含大型对象的_巨大_区域。它还会在单个集合的持续时间内固定超过指定活动阈值的任何区域。

G1 无法在次要收集操作期间固定任意区域,尽管它确实从此类收集中排除了巨大的区域。

在小型收集操作期间固定区域

我们的目标是通过扩展 G1 在主要和次要收集操作期间固定任意区域来实现上述目标,如下所示:

  • 维护每个区域中关键对象数量的计数:当获得该区域中的关键对象时增加它,并在释放该对象时减少它。当计数为零时,则正常垃圾收集该区域;当计数非零时,考虑要固定的区域。

  • 在主要收集期间,请勿疏散任何固定区域。

  • 在小型收集期间,将年轻代中的固定区域视为疏散失败,从而将其提升到老年代。不要撤离老一代中现有的固定区域。

完成此操作后,我们可以通过固定包含关键对象的区域并继续收集未固定区域中的垃圾来实现 JNI 关键区域(无需禁用 GC)。

备择方案

JNI 规范建议了另外两种实现关键区域的方法:

  • 在临界区开始处,将临界对象复制到C堆,在那里它不会被移动;在关键区域的末尾,将其复制回来。

    这在时间和空间上都是非常低效的。在 G1 中,我们只能对无法固定区域中的关键对象执行此操作。然而,这些区域位于年轻一代,大多数对象使用和修改通常发生在年轻一代中,因此我们预计这不会有太大帮助。

  • 单独固定对象。

    G1 只能疏散整个区域,因此区域中的单个固定对象将阻止该区域的收集。最终结果与我们上面建议的几乎没有什么不同,只是它会具有更高的开销,因为跟踪单个固定对象比维护关键对象的每个区域计数成本更高。

测试

除了功能测试之外,我们还将进行基准测试和性能测量,以收集必要的性能数据,以确保实现我们的目标。

风险和假设

我们假设 JNI 关键区域的预期使用不会发生变化:它们将继续谨慎使用,并且持续时间很短。

当应用程序同时固定多个区域时,存在堆耗尽的风险。我们对此没有解决方案,但事实上 Shenandoah GC 在 JNI 关键区域期间固定内存区域并且不存在此问题,这表明这对于 G1 来说不会是问题。