JEP 423: Region Pinning for G1 | G1 的区域固定
摘要
通过在 G1 中实现区域锁定来减少延迟,以便在 Java 本地接口(JNI)关键区域中无需禁用垃圾收集。
目标
在 JNI 关键区域中不阻塞线程。
在 JNI 关键区域中启动垃圾收集时不增加额外延迟。
在没有 JNI 关键区域活动时,GC 暂停时间没有回归。
在 JNI 关键区域活动时,GC 暂停时间有最小回归。
动机
为了与 C 和 C++ 等未托管编程语言进行互操作,JNI 定义了 获取并随后释放 Java 对象直接指针的函数。这些函数必须始终以成对的方式使用:首先,获取对象的指针(例如,通过 GetPrimitiveArrayCritical
);然后,在使用对象后,释放指针(例如,通过 ReleasePrimitiveArrayCritical
)。在此类函数对中的代码被视为在 关键区域 中运行,并且在此期间可用于使用的 Java 对象是 关键对象。
当 Java 线程处于关键区域时,JVM 必须注意在垃圾收集期间不要移动相关的关键对象。它可以通过将此类对象 锁定 到其位置来实现这一点,基本上是在 GC 移动其他对象时将它们锁定在原位。或者,它可以在线程处于关键区域时简单地禁用 GC。
默认的 GC,G1,采用后一种方法,在每个关键区域期间 禁用 GC。这对延迟有重大影响:如果 Java 线程触发 GC,则必须等待直到没有其他线程处于关键区域。影响的严重程度取决于关键区域的频率和持续时间。在最坏的情况下,用户报告关键部分 阻塞整个应用程序数分钟,由于 线程饥饿 导致的不必要的内存不足情况,甚至提前关闭 VM。由于这些问题,一些 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 来说也不会是问题。