JEP 423:G1 的区域固定功能
总结
通过在 G1 中实现区域固定来减少延迟,这样在 Java 本地接口(JNI)关键区域期间就不需要禁用垃圾回收。
目标
-
由于 JNI 关键区域,线程不会停滞。
-
由于 JNI 关键区域,启动垃圾回收时没有额外的延迟。
-
当没有 JNI 关键区域处于活动状态时,GC 暂停时间没有退化。
-
当 JNI 关键区域处于活动状态时,GC 暂停时间仅有极小的退化。
动机
为了与 C 和 C++ 等非托管编程语言实现互操作,JNI 定义了获取并随后释放指向 Java 对象的直接指针的函数。这些函数必须始终成对使用:首先,获取指向对象的指针(例如,通过 GetPrimitiveArrayCritical
);然后,在使用完该对象后,释放指针(例如,通过 ReleasePrimitiveArrayCritical
)。此类函数对之间的代码被认为运行在关键区域中,而在该时间段内可供使用的 Java 对象则称为关键对象。
当 Java 线程处于临界区时,JVM 必须注意在垃圾回收期间不要移动相关的临界对象。它可以通过将这些对象 固定 在其位置来实现这一点,这实际上是在 GC 移动其他对象时将它们锁定在原地。或者,它可以在线程处于临界区时简单地禁用 GC。
通过我们在此处提出的变更,Java 线程将永远不会等待 G1 GC 操作完成。
描述
背景
G1 将堆划分为固定大小的 内存区域(不要与 关键 区域混淆)。G1 是一个分代收集器,因此任何非空区域都属于年轻代或老年代。在任何特定的收集操作中,对象会从一部分区域 疏散(即移动)到其他区域的某个子集。
如果 G1 在一次小型(即,年轻代)回收过程中无法找到空间来转移某个对象,那么它会将该对象留在原地,并将其及其所在的区域标记为转移失败。在转移完成后,G1 通过将这些失败的区域从年轻代晋升到老年代来进行修复,这可能使它们为后续的转移做好准备。
G1 在执行大型(即,全部)收集操作期间,已经能够将对象固定到其内存位置,只需不疏散包含这些对象的区域即可。例如,G1 固定包含大对象的超大区域。它还会在单次收集期间固定任何超过指定活跃度阈值的区域。
G1 在进行小型收集操作时无法固定任意区域,不过它确实将巨型区域排除在这些收集之外。
在次要回收操作期间固定区域
我们计划通过扩展 G1 来实现上述目标,在主回收和次回收操作期间固定任意区域,具体如下:
-
维护每个区域中关键对象的数量计数:当获取该区域中的关键对象时递增计数,释放该对象时递减计数。当计数为零时,正常对该区域进行垃圾回收;当计数不为零时,将该区域视为固定(pinned)。
-
在主回收期间,不要疏散任何固定的区域。
-
在次回收期间,将年轻代中的固定区域视为疏散失败,从而将它们提升到老年代。不要疏散老年代中已有的固定区域。
一旦我们完成了这一工作,就可以通过锁定包含关键对象的区域并在未锁定的区域中继续收集垃圾来实现 JNI 关键区域 —— 而无需禁用 GC。
替代方案
JNI 规范建议实现关键区域的其他两种方法:
-
在关键区域的起始位置,将关键对象复制到 C 堆中,这样它就不会被移动;在关键区域的末尾,再将其复制回去。
这在时间和空间上都非常低效。在 G1 中,我们只能对无法固定的区域中的关键对象执行此操作。然而,这些区域位于年轻代中,而大多数对象的使用和修改通常都发生在年轻代中,因此我们不认为这会有很大帮助。
-
单独固定对象。
G1 只能疏散整个区域,因此区域中的单个固定对象会阻止该区域的收集。最终结果与我们在上面提出的方案差别不大,只是其开销更高,因为跟踪单个固定对象比维护每个区域的关键对象计数成本更高。
测试
除了功能测试之外,我们还将进行基准测试和性能测量,以收集必要的性能数据,确保实现我们的目标。
风险与假设
我们假设 JNI 关键区域的预期使用不会发生变化:它们将继续被谨慎使用,并且持续时间较短。
当一个应用程序同时固定许多区域时,存在堆耗尽的风险。我们对此没有解决方案,但 Shenandoah GC 在 JNI 关键区域期间固定内存区域且没有此问题的事实表明,这对 G1 来说不会成为问题。