Java 20 中垃圾回收的新变化

本文总结了 Hotspot 的 STW(Stop The World)垃圾收集器在 JDK 20 版本中的更新和改进。

此版本不包含 GC 的任何 JEP(JDK 增强建议),但是 Generational ZGC 的 JEP 最近已达到 Candidate(候选)状态,因此也许它将为 JDK 21 做好准备 🙂

除此之外,JDK 20 的整个 Hotspot GC 子组件总共解决或关闭了约 220 个更改。

并行 GC

并行 GC 唯一显著改进是在 Full GC 过程中处理跨越整理区域的对象的并行化(JDK-8292296)。而非在最后的单线程阶段迭代和修正这些对象,工作者线程会收集跨越它们本地整理区域的对象并自行处理。

M. Gasson 指出在某些情况下全 GC 暂停时间减少了 20%。

讨论链接:https://github.com/openjdk/jdk/pull/10313

串行 GC

串行 GC 没有重大变化,只进行了一些代码清理工作。

G1 GC

下面是 JDK 20 中 G1 GC 的更新列表:

1、JDK-8210708 通过删除遍及整个 Java 堆的一个标记位图,将 G1 本地内存占用量减少约 Java 堆大小的 1.5%。

G1 并发标记的博客包括对该更改的详细讨论。

文章链接:https://tschatzl.github.io/2022/08/04/concurrent-marking.html

该博客的内容让原始 G1 论文 中有关并发标记的信息过时了。现在这篇论文中几乎没有有关当前 G1 的准确信息了。

论文链接:http://cs.williams.edu/~dbarowy/cs334s18/assets/p37-detlefs.pdf

实际上这篇博客的内容某种程度上也过时了:JDK-8295118 把名为“清除声明标记(Clear Claimed Marks)”的从并发启动垃圾回收暂停中移除了,针对这个标记的准备工作偶尔会非常耗时。一个名为“并发清除声明标记(Concurrent Clear Claimed Marks)”的新阶段将在日志中出现,即带有 gc+marking=debug 日志记录。

为了给 G1 的未来区域固定(Region Spinning)支持做准备,JDK-8256265 在处理被用户固定的区域(或者由于 Java 堆中没有空间而无法清理)时,将并行化粒度降低了。现在,任务粒度是部分区域,而不是按线程分配整个区域。这使线程更好地共享工作,大大减少了无效等待完成的时间。

2、JDK-8137022 使精细化线程控制更具自适应性。

以前,在垃圾回收暂停期间 G1 计算了离散阈值,以此来激活和停用特定精细化线程以在 mutator 时间中帮助进行精细化。这个计算是基于用户希望在垃圾回收暂停期间花费在精细化卡片上的允许时间(选项 XX:G1RSetUpdatingPauseTimePercent)、最近的下一个暂停的间隔以及很多“魔法”。

编注:“堆被划分为一组 card 卡片,每个卡片通常比内存页面小。JVM 维护一个卡片映射,其中每个卡片对应一个比特(或在某些实现中是字节)。每次在堆中的对象中修改指针字段时,将设置该卡片的卡片映射中的相应位。”

由于没有直接观察应用程序,例如考虑最近的传入和精细化线程处理工作的速率以及下一个垃圾回收的预期时间,精细化线程控制在防止太多工作留在收集暂停期间方面表现得比较激进。这种行为不仅浪费了精细化线程中的 CPU 循环,而且还带来了另一个缺点:Java 堆上留下了很少的未精细化的 card。虽然这听起来有好处,但在某个水平以下,这样处理可能适得其反。当有一个新的未访问 card 时,写入障碍需要执行更多的工作,如此处所述,比如果 card 留在队列中以供以后处理要多。

精细化线程的额外活动不仅消耗了 CPU 资源,而且 G1 反复精细化相同的 card 而没有减少暂停期间留下的工作。

该更改考虑到 mutator 活动能够更好地在暂停之间分配精细化线程活动,并将更多的 card 留在精细化任务队列中更长时间,从而减少了新 card 的生成速率并更确定地实现了用户的意图(即 -XX:G1RSetUpdatingPauseTimePercent)。最终,这通常需要比应用程序中少一些 CPU 周期,从而提供更好的吞吐量。

与旧精细化控制相关的几个 G1 垃圾收集器选项被废弃,当使用它们时,VM 会在启动时退出。发行说明中详细说明了它们。

3、G1 使用本地分配缓冲区(PLAB)来减少垃圾回收暂停期间的同步开销。

基于最近的分配模式调整 PLAB 的大小,以减少垃圾回收暂停期间这些缓冲区中未使用的空间。如果没有太多的分配需求会进行收缩,否则就会增长。这种每个收集暂停的自适应调整对于在垃圾回收之间具有相当恒定的分配的应用程序非常有效;然而,如果是突发性分配,这种方法则不起作用。PLAB 将在垃圾回收后的几个垃圾回收中过大时会浪费空间,或者在分配激增时过小浪费 CPU 循环重新加载 PLAB。

在某些平台上我们注意到非常长的暂停峰值,范围在几秒钟左右。JDK-8288966 中的更改添加了一些相当激进的 PLAB 调整,以在垃圾回收期间对抗这些情况。

4、JDK 20 投入了大量精力来改进用于调整年轻代大小的预测,这最终负责垃圾回收所需的时间

详细信息可查看下面的 bug 报告:

https://bugs.openjdk.org/browse/JDK-8296419?jql=labels%20%3D%20gc-g1-prediction

预测方面的改进使得 G1 可以更好地观察暂停时间目标(由 -XX:MaxGCPauseMillis 指定)。这样可以减少暂停时间和使用可用暂停时间目标,通过每个垃圾回收使用更多的年轻代区域。这增加了暂停时间在允许的目标范围内,但可以显着减少垃圾回收的数量。这些更改应用之后,我们测量的应用程序在垃圾回收暂停中花费了 10-15% 的时间。

5、JDK 20 默认禁用预防性垃圾回收(JDK-8293861)。

预防性垃圾回收是在 JDK 19 中引入的,旨在避免 G1 在垃圾回收期间没有足够的 Java 堆内存来疏散对象(也称为“疏散失败”)。之前的疏散失败的区域处理很慢,一种观点最好是进行垃圾回收,该垃圾回收在没有这种疏散失败的情况下进行,以便它释放足够的内存以完全避免这些疏散失败。

问题是如何正确地预测这种情况。G1 用于确定是否启动预防性收集的预测证明是次优的,并经常不必要地过早地启动预防性收集,导致浪费了许多时间。还有许多情况下不会被触发预防性收集,导致应用程序遇到疏散失败。最后,这种垃圾回收使垃圾回收变得更加没有规律,一旦发生,通常会使预测更加困难。

6、JDK-8297247 引入了名为“G1 Concurrent GC”的新的 GarbageCollectorMXBean。

新的 GarbageCollectorMXBean 能够报告 G1 Remark 和 Cleanup 暂停的发生和持续时间。当暂停发生时,还会更新了 G1 Old Gen MemoryManagerMXBean 内存池信息。

总之,我认为这些重要的垃圾收集更新值得升级,即使其中一些更新要等到 JDK 21 也值得考虑。

接下来会有哪些变化

JDK 21 的工作已在进行时。下面列举了一些已经集成或正在开发中的一些变化。通常没有人能保证它们一定会出现在下一个版本中,当然已经集成的改变很可能会保留;)

在改进细化线程控制之后,JDK-8225409 删除了 Hot Card Cache。这个数据结构在某种程度上是解决了上述问题的一种解决方法,即细化过于激进并且保持 card 不细化更有优势。在这种机制下,对于每个 card,G1 保留了一个计数器,用来记录它在这个 mutator 周期中被细化的次数,如果该计数超过阈值,则该卡片不会被细化,并保留在一个小的固定大小的环形缓冲区中,直到由于溢出或垃圾回收而从中删除。 在 JDK 20 细化线程控制改变之后,我们无法再测量 Hot Card Cache 带来的任何影响。当前的细化控制似乎足够 lazy(延迟加载),以吸收  Hot Card Cache 功能。 这为其他用途释放了 0.2% 的 Java 堆大小的本机内存。

此外,正在努力改善 G1 在区域级碎片的回收工作。目前,如果 G1 即使在进行 Full GC 后仍然找不到连续的内存范围来分配巨型对象,尽管在聚合中会有足够的内存可用,但是 VM 会退出并引发 OutOfMemoryException。这个 PR 更改了“最后的防线”G1 Full Collection 的行为,以移动巨型对象以创建更多连续内存。这在许多情况下应该避免 OutOfMemoryException,但代价是牺牲了长期的 Full GC。 由于 G1 Full GC 仅在进行了常规 G1 完整收集后立即发生,因此延长该 GC 以避免 VM 故障似乎是可接受的。

为了避免 G1 对未能疏散的区域(或在未来被固定)淹没老年代,允许 G1 尽快在生成后的任何垃圾回收时疏散这些老年代。 对于未能疏散的区域,当前的策略是将它们变成老年代,这意味着 G1 不能再分配它们,尽管它们通常只包含少量活跃的对象。这些也没有记忆集,因此从它们中回收空间的唯一方法是等待下一个并发标记完成。如果许多区域在多次 GC 期间未能疏散,那么 Java 堆将很快填满这些通常非常轻松占用的区域,这很容易导致完全 GC。 这个更改将完全打破先前的假设,即仅限年轻代的 GC 将永远不会对老年代 GC。尽管从技术上讲,G1 自 JDK 8u65 开始已经尝试收集一些巨型对象,这些是老年代对象,但是这种假设已经很长时间没有被严格遵守。

JDK-8297639 移除了一些预防性垃圾回收的预测功能。因为这种预测的性价比降低了,额外的 GC 会带来许多问题却没有提供任何好处。

来源:tschatzl.github.io/2023/03/14/jdk20-g1-parallel-gc-changes.html

请登录后发表评论

    没有回复内容