Skip to content

JEP 346: Promptly Return Unused Committed Memory from G1 | 及时返回 G1 中未使用的已提交内存

摘要

增强 G1 垃圾回收器,以便在空闲时自动将 Java 堆内存返回给操作系统。

非目标

  • Java 进程之间共享已提交但为空的页面。内存应该被返回(取消提交)给操作系统。

  • 返还内存的过程不需要节省 CPU 资源,也不需要立即完成。

  • 使用除可用内存取消提交之外的其他方法来返还内存。

  • 支持除 G1 之外的其他收集器。

成功指标

如果应用程序活动非常低,G1 应该在合理的时间段内释放未使用的 Java 堆内存。

动机

目前,G1 垃圾回收器可能不会及时将已提交的 Java 堆内存返回给操作系统。G1 仅在完全 GC(垃圾收集)或并发周期期间从 Java 堆中释放内存。由于 G1 努力完全避免完全 GC,并且仅基于 Java 堆占用率和分配活动来触发并发周期,因此在许多情况下,除非被外部强制,否则它不会返回 Java 堆内存。

这种行为在容器环境中特别不利,因为资源是按使用量付费的。即使在 VM 仅由于其不活动而使用其分配的内存资源的很小一部分的阶段,G1 也会保留整个 Java 堆。这导致客户始终为所有资源付费,而云提供商 无法充分利用其硬件

如果 VM 能够检测到 Java 堆利用率不足的阶段(“空闲”阶段),并在那段时间内自动减少其堆使用,那么双方都将受益。

Shenandoah 和 OpenJ9 的 GenCon 收集器 已经提供了类似的功能。

Bruno 等人的原型测试中,第 5.5 节,显示基于 Tomcat 服务器在白天处理 HTTP 请求,而在夜间主要处于空闲状态的实际使用情况,此解决方案可以将 Java VM 提交的内存量减少 85%。

描述

为了完成将最大量内存返回给操作系统的目标,G1 将在应用程序不活跃时,定期尝试继续或触发并发周期来确定 Java 堆的总体使用情况。这将导致它自动将 Java 堆的未使用部分返回给操作系统。此外,在用户控制下,可以选择执行完全 GC 以最大化返回的内存量。

如果同时满足以下两个条件,则认为应用程序不活跃,G1 将触发定期垃圾收集:

  • 自上次垃圾收集暂停以来,已经过去超过 G1PeriodicGCInterval 毫秒,并且此时没有并发周期正在进行中。值为零表示禁用了用于快速回收内存的定期垃圾收集。

  • JVM 宿主系统(例如容器)上通过 getloadavg() 调用返回的平均一分钟系统负载值低于 G1PeriodicGCSystemLoadThreshold。如果 G1PeriodicGCSystemLoadThreshold 为零,则忽略此条件。

如果上述任一条件不满足,当前预期的定期垃圾收集将被取消。当下一次 G1PeriodicGCInterval 时间过去时,将重新考虑进行定期垃圾收集。

定期垃圾收集的类型由 G1PeriodicGCInvokesConcurrent 选项的值确定:如果设置,G1 将继续或启动一个并发周期,否则 G1 将执行完全 GC。在任一收集结束时,G1 将调整当前的 Java 堆大小,并可能将内存返回给操作系统。新的 Java 堆大小由现有的 Java 堆大小调整配置确定,包括但不限于 MinHeapFreeRatioMaxHeapFreeRatio 以及最小和最大堆大小配置。

默认情况下,G1 会在这种定期垃圾收集期间启动或继续一个并发周期。这最大限度地减少了对应用程序的干扰,但与完全收集相比,最终可能无法返回尽可能多的内存。

任何由这种机制触发的垃圾收集都会用 G1 Periodic Collection 作为原因进行标记。下面是一个此类日志可能看起来的例子:

txt
(1) [6.084s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [6.086s][info ][gc          ] GC(13) 暂停 年轻代 (并发开始) (G1 Periodic Collection) 37M->36M(78M) 1.786ms
(2) [9.087s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [9.088s][info ][gc          ] GC(15) 暂停 年轻代 (准备混合) (G1 Periodic Collection) 9M->9M(32M) 0.722ms
(3) [12.089s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [12.091s][info ][gc          ] GC(16) 暂停 年轻代 (混合) (G1 Periodic Collection) 9M->5M(32M) 1.776ms
(4) [15.092s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [15.097s][info ][gc          ] GC(17) 暂停 年轻代 (混合) (G1 Periodic Collection) 5M->1M(32M) 4.142ms
(5) [18.098s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [18.100s][info ][gc          ] GC(18) 暂停 年轻代 (并发开始) (G1 Periodic Collection) 1M->1M(32M) 1.685ms
(6) [21.101s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [21.102s][info ][gc          ] GC(20) 暂停 年轻代 (并发开始) (G1 Periodic Collection) 1M->1M(32M) 0.868ms
(7) [24.104s][debug][gc,periodic ] 检查是否需要执行定期 GC。
    [24.104s][info ][gc          ] GC(22) 暂停 年轻代 (并发开始) (G1 Periodic Collection) 1M->1M(32M) 0.778ms

在上面的例子中,使用 G1PeriodicGCInterval 设置为 3000ms 进行运行,在步骤 (1) 中,G1 在应用程序不活跃一段时间后启动了一个并发周期,这由 (Concurrent Start)(G1 Periodic Collection) 标记所指示。这个并发周期最初返回了一些内存,这通过从 (1) 到 (2) 的容量数字 (78M)(32M) 的减少来显示。在 (2) 到 (4) 之间的间隔内,触发了更多的定期收集,这次触发了混合收集来压缩堆。接下来的定期垃圾收集 (5) 到 (7) 启动了一个并发周期,因为 G1 策略确定在那个时候老年代中的垃圾不足以启动混合 GC 阶段。在这种情况下,定期垃圾收集 (5) 到 (7) 不会进一步缩小堆的大小,因为已经达到最小堆大小。

在应用程序不活跃期间对象存活状态的变化(例如,由于软引用过期)可能会在空闲时间期间进一步减少已提交的 Java 堆大小。

备选方案

类似的功能可以从 VM 外部实现,例如通过 jcmd 工具或将某些代码注入 VM。但这存在隐形成本:假设检查是通过基于 cron 的任务进行的,那么在节点上有数百或数千个容器的情况下,这可能意味着许多容器会同时执行堆压缩操作,导致主机上出现非常大的 CPU 峰值。

另一个备选方案是自动附加到每个 Java 进程的 Java 代理。然后,由于容器在不同的时间启动,检查的时间自然地被分散开来,而且它不会增加 CPU 负担,因为你不会启动任何新进程。然而,这种方法增加了用户的复杂性,可能会阻碍其采用。

给定的用例——及时缩小 Java 堆大小——被认为是一个相当常见的用例,值得在 VM 中提供特别支持。

风险与假设

在配置的默认值中,我们禁用了此功能。这导致对延迟或吞吐量敏感的应用程序在 VM 行为上不会出现意外更改。当启用该功能时,我们假设通常将 Java 堆内存返回给操作系统是可取的,并且由此产生的并发周期或其延续对应用程序吞吐量的影响可忽略不计。

当启用此功能时,VM 会在上述条件下运行这些周期性收集,而不考虑其他选项。例如,VM 可能会假设如果用户将 -Xms 设置为 -Xmx 并设置其他(组合)选项以获得最小且一致的垃圾收集暂停,但这不会出于一致性原因而这样做。

如果周期性垃圾收集仍然对程序执行造成过多干扰,我们提供了控制选项,以便在决策时考虑整体系统 CPU 负载,或者让用户完全禁用周期性垃圾收集。