Java 22中三种垃圾回收GC性能获得了大提升


 JDK 22 GA 即将到来,本文介绍该版本中 OpenJDK 的垃圾收集器GC的最新更改,主要是提升了效率和性能。

 JDK 22 GA 这个版本在 stop-the-world 收集器领域提供了相当重大的变化,例如JEP 423:G1 的区域固定。除了功能上的实际变化之外,它还需要在幕后进行一些至少在技术上有趣的变化。串行和并行 GC 年轻集合的性能也得到了改进

以下是 JDK 22 中 Hotspot stop-the-world 收集器的有趣变化的通常概述:

Parallel并行GC
在代际垃圾回收过程中,查找旧引用到新引用是一项重要任务。并行(和串行)GC 为此使用了卡片表。首先,突变者会将可能包含引用的卡片表项标记为 "脏"。然后,在暂停期间,算法会扫描卡片表中的这些脏标记,并查看这些标记所指示的对象,因为它们可能包含旧引用到新引用。

并行 GC(顾名思义)在查看典型的大型卡片表时使用多个并行线程。卡片表扫描的工作分配机制是将堆划分为 64kB 小块。任何从该区域开始的卡片标记对象都归该线程处理。

JDK-8310031 实现了两项优化,从而改善了工作分配并提高了性能

  • 在大型对象数组的内容被分割到不同分区后,现在不仅是拥有大型对象数组的线程会查找该对象中从老到新的引用。这以前可能会导致单个线程独自走完一个 GB 的大型对象。虽然在卡片扫描后,还存在一些基于从队列中窃取工作的额外工作分配机制,但与多个线程首先查看该对象的部分内容相比,这种窃取工作的成本相对较高。
  • 虽然脏卡已经指出了感兴趣的位置,但单个所有者线程总是会查看数组的所有元素。因此,处理线程经常会查看许多已知不包含任何从旧到新引用的引用。这就变成了一个线程只处理大型对象数组中被标记为脏的部分。

在某些情况下,旧的行为会导致反向线程缩放,与 G1 收集器等相比,暂停时间非常长。

并行 GC 的另一个性能问题与 Java 堆中的大型数组对象有关,该问题也已得到修复。现在,并行 GC 在块偏移表中使用与其他收集器相同的指数后跳来查找对象的起始位置,从而加快了这一过程和整体暂停时间。

块偏移表解决了在卡片表中查找卡片之前的对象起始位置的问题。其中一个应用是在上述卡片扫描过程中,垃圾收集器需要快速找到 Java 对象的起始点,无论是从该特定卡片开始还是进入该特定卡片,以便开始对该对象进行正确解码(查找引用)。

每张卡(通常代表 512 字节的 Java 堆)都有一个块偏移表 (BOT) 条目。该条目存储的信息要么是对象从与该卡对应的堆地址后退多少个字开始进入该卡,要么是在前一个卡中没有对象开始,算法需要查看前一个 BOT 条目以获取更多信息。JDK-8321013 引入的更改将后跳值从 "查看前一张卡 "改为以 2 为底的指数计算后退的卡数。

使用新BOT编码后,垃圾收集算法只需要几步。这大大减少了为大型对象寻找对象起点时的内存访问量,从而提高了性能

串行 GC
JDK-8319373 基于 JDK-8310031 中新增的并行 GC 代码,优化了串行 GC 中的卡片扫描代码(查找脏卡)。如果脏卡很少,这还会大大减少年轻卡的收集时间。

我们花费了大量精力清理串行 GC 代码,删除了 JDK 14/JEP 363 中删除的并发标记扫描收集器共享相同代码时的死代码和抽象。

G1 GC
以下是 JDK 22 面向 G1 用户的更改:

1、G1 现在 (JDK-8140326) 会在下一次任何类型的垃圾回收中回收疏散失败的区域。这提高了 G1 收集器的恢复能力,避免其旧版本被疏散失败的区域淹没。

主要用例是区域钉住:尝试疏散钉住的区域会导致疏散失败,从而将受影响的区域移到旧一代中。如果不采取任何措施,在区域解除钉扎后立即回收这些通常很快就能回收的区域,就会导致旧一代区域的大量堆积,从而产生更多的垃圾收集,在最糟糕的情况下还会产生不必要的全 GC。

显然,这也有助于回收因内存不足而无法复制对象所导致的疏散失败区域中的空间。

另外,这一改动还消除了 G1 中以前的(自我设置的)限制,即只有特定类型的年轻集合才能在旧一代区域中回收空间。现在,只要符合某些要求,任何年轻的集合都可以撤离旧版区域。

2、随着 JDK-8318706 的集成和 JEP 423:G1 的区域销钉的完成,在 G1 中取消使用 GCLocker 的漫长旅程已经结束。

简而言之,以前如果应用程序在与 JNI 交互时通过 Get/ReleasePrimitiveArrayCritical 方法访问数组,则不会发生垃圾收集。这一修改修改了垃圾收集算法,将这些对象保留在原处,"钉住 "它们并将相应的区域标记为 "钉住 "区域,但允许疏散钉住区域内的任何其他区域或非原始数组。后一种优化之所以可行,是因为 Get/ReleasePrimitiveArrayCritical 只能锁定非原始数组对象。

现在,Java 线程绝不会因为使用 G1 的 JNI 代码而停滞

3、在 JDK-8314573 中,Remark 暂停期间的堆大小调整有一些小改动,以使调整大小更加一致。堆大小调整现在根据 -XX:Min/MaxHeapFreeRatio 计算堆大小变化,而不考虑 Eden 区域。由于 Remark 暂停可在突变器阶段的任何时间发生,以前的行为使得堆大小变化非常依赖于当前的 Eden 占有率(即 Remark 暂停发生时应用程序已进入突变器阶段多长时间,用于计算的空闲区域数量可能相差很大,从而导致堆大小调整不同)。

这使得堆的大小更具有确定性,通常也不那么激进。

4、新改变列表还包括一项实际、直接的性能改进:区域的代码根集,即来自编译代码的根,以前在垃圾收集过程中由每个区域的单个线程处理。在代码根集非常不平衡的情况下(大量代码嵌入引用到一个或几个区域中),这可能会导致垃圾回收工作停滞。JDK-8315503 使 G1 即使在区域内也能将代码根扫描工作分配给多个线程,从而消除了这一潜在瓶颈。

所有 GC暂停情况
在 JDK-8290025 中,Loom 需要移除代码缓存清扫器。

就 STW 收集器而言,它的工作已被转移到适当的暂停中。

不幸的是,扫码器工作的一部分组件的运行时间为 O(n^2),其中 n 是卸载方法的数量。

只要扫码器与应用程序同时工作,这个问题就不会太大,但在移除这个组件后,当卸载大量编译代码时,暂停时间就会出现明显的倒退。

有了 JDK-8317809、JDK-8317007、JDK-8317677 和其他一些软件,现在在暂停时卸载类的速度实际上比移除代码缓存清扫器(但仍在进行所有工作)之前还要快