JEP 346:G1 及时返回未使用的已提交内存
概述
增强 G1 垃圾收集器,以便在空闲时自动将 Java 堆内存返回给操作系统。
非目标
-
在 Java 进程之间共享已提交但为空的页面。内存应返还(未提交)给操作系统。
-
返回内存的过程不需要在 CPU 资源上过于节省,也不需要瞬间完成。
-
使用不同的方法来返还内存,而不是依赖可用的内存未提交功能。
-
支持除 G1 以外的其他垃圾收集器。
成功指标
如果应用程序的活动非常少,G1 应该在合理的时间内释放未使用的 Java 堆内存。
动机
目前,G1 垃圾收集器可能无法及时将已提交的 Java 堆内存返回给操作系统。G1 仅在完全 GC 或并发周期期间从 Java 堆中返回内存。由于 G1 极力避免完全 GC,并且仅根据 Java 堆占用率和分配活动触发并发周期,因此在很多情况下,除非外部强制执行,否则它不会返回 Java 堆内存。
这种行为在按使用量付费的容器环境中尤其不利。即使在虚拟机由于不活跃而仅使用其分配内存资源的一小部分时,G1 仍会保留整个 Java 堆。这导致客户始终为所有资源付费,并且云提供商无法充分利用其硬件。
如果虚拟机能够检测到 Java 堆未被充分利用的阶段(“空闲”阶段),并在此期间自动减少堆使用量,那么两者都会受益。
Shenandoah 和 OpenJ9 的 GenCon 收集器 已经提供了类似的功能。
在 Bruno et al., 第 5.5 节 中使用原型进行的测试表明,基于 Tomcat 服务器在白天处理 HTTP 请求、夜间基本空闲的真实使用情况,该解决方案可以将 Java 虚拟机提交的内存量减少 85%。
描述
为了实现将最大量的内存返还给操作系统的这一目标,G1 将在应用程序处于非活动状态期间,定期尝试继续或触发并发周期,以确定整个 Java 堆使用情况。这将会使 G1 自动将 Java 堆中未使用的部分返还给操作系统。或者,也可以在用户控制下执行 Full GC,以最大限度地增加返还的内存量。
如果同时满足以下两个条件,则认为该应用程序处于非活动状态,并且 G1 会触发定期垃圾回收:
-
自上一次垃圾回收暂停以来,已经过去了超过
G1PeriodicGCInterval
毫秒,并且当前没有任何并发周期在进行中。值为零表示禁用了定期垃圾回收以快速回收内存的功能。 -
JVM 主机系统(例如容器)上通过
getloadavg()
调用返回的一分钟平均系统负载值低于G1PeriodicGCSystemLoadThreshold
。如果G1PeriodicGCSystemLoadThreshold
为零,则此条件将被忽略。
如果其中任何一个条件未满足,当前预期的定期垃圾回收将被取消。当下一次经过 G1PeriodicGCInterval
时间时,会重新考虑进行定期垃圾回收。
定期垃圾收集的类型由 G1PeriodicGCInvokesConcurrent
选项的值决定:如果设置,G1 将继续或启动并发周期,否则 G1 将执行一次完整的 GC。在每次收集结束时,G1 会调整当前的 Java 堆大小,可能会将内存返还给操作系统。新的 Java 堆大小由现有的调整 Java 堆大小的配置决定,包括但不限于 MinHeapFreeRatio
、MaxHeapFreeRatio
以及最小和最大堆大小的配置。
默认情况下,G1 在此定期垃圾回收期间启动或继续一个并发周期。这可以最大程度地减少对应用程序的干扰,但与完全收集相比,最终可能无法返回同样多的内存。
此机制触发的任何垃圾回收都会标记为 G1 Periodic Collection
原因。以下是此类日志可能呈现的样例:
<G1 Periodic Collection>
(1) [6.084s][debug][gc,periodic ] Checking for periodic GC.
[6.086s][info ][gc ] GC(13) Pause Young (Concurrent Start) (G1 Periodic Collection) 37M->36M(78M) 1.786ms
(2) [9.087s][debug][gc,periodic ] Checking for periodic GC.
[9.088s][info ][gc ] GC(15) Pause Young (Prepare Mixed) (G1 Periodic Collection) 9M->9M(32M) 0.722ms
(3) [12.089s][debug][gc,periodic ] Checking for periodic GC.
[12.091s][info ][gc ] GC(16) Pause Young (Mixed) (G1 Periodic Collection) 9M->5M(32M) 1.776ms
(4) [15.092s][debug][gc,periodic ] Checking for periodic GC.
[15.097s][info ][gc ] GC(17) Pause Young (Mixed) (G1 Periodic Collection) 5M->1M(32M) 4.142ms
(5) [18.098s][debug][gc,periodic ] Checking for periodic GC.
[18.100s][info ][gc ] GC(18) Pause Young (Concurrent Start) (G1 Periodic Collection) 1M->1M(32M) 1.685ms
(6) [21.101s][debug][gc,periodic ] Checking for periodic GC.
[21.102s][info ][gc ] GC(20) Pause Young (Concurrent Start) (G1 Periodic Collection) 1M->1M(32M) 0.868ms
(7) [24.104s][debug][gc,periodic ] Checking for periodic GC.
[24.104s][info ][gc ] GC(22) Pause Young (Concurrent Start) (G1 Periodic Collection) 1M->1M(32M) 0.778ms
在上面的例子中,运行时 G1PeriodicGCInterval
设置为 3000 ms,在步骤 (1) 中,G1 在应用程序一段时间不活动后启动了一个并发周期,这由 (Concurrent Start)
和 (G1 Periodic Collection)
表示。这个并发周期最初释放了一些内存,表现为容量数字从 (1) 到 (2) 的减少,即 (78M)
和 (32M)
。在 (2) 到 (4) 的间隔中,触发了更多的定期垃圾回收,这次触发了一次混合回收来压缩堆内存。接下来的定期垃圾回收 (5) 到 (7) 启动了一个并发周期,因为 G1 策略判断此时老年代中的垃圾不足以启动混合 GC 阶段。在这种情况下,由于已经达到了最小堆大小,因此定期垃圾回收 (5) 到 (7) 不会进一步缩减堆内存。
在应用程序不活动期间,对象存活状态的更改(例如,由于软引用到期)可能会触发在此空闲时间内进一步减少已提交的 Java 堆。
替代方案
类似的功能也可以从虚拟机外部实现,例如,通过 jcmd
工具或注入到虚拟机中的某些代码来实现。但这会带来隐藏的成本:假设检查是通过基于 cron 的任务执行的,在一个节点上有数百或数千个容器的情况下,这可能意味着许多容器会同时执行堆压缩操作,从而导致主机上出现非常大的 CPU 峰值。
另一个替代方案是使用一个自动附加到每个 Java 进程的 Java agent。然后,随着容器在不同时间启动,检查的时间也会自然分布,并且由于没有启动任何新进程,它对 CPU 的消耗也较少。然而,这种方法增加了用户使用的复杂性,可能会阻碍其被采用。
所给定的用例,即及时缩减 Java 堆,被认为是一个相当常见的用例,值得在虚拟机中获得专门的支持。
风险与假设
在配置的默认值中,我们禁用了此功能。这样做的结果是,对于延迟或吞吐量敏感的应用程序,不会出现 VM 行为的意外变化。启用后,我们通常认为将 Java 堆内存归还给操作系统是可取的,并且由此产生的并发周期或其继续进行对应用程序吞吐量的影响可以忽略不计。
启用此功能后,虚拟机 (VM) 将在上述条件下运行这些周期性收集,而不管其他选项如何。例如,虚拟机可以假设用户将 -Xms
设置为 -Xmx
,并设置其他(组合)选项以获得最小且一致的垃圾回收暂停时间。但由于一致性原因,实际情况并非如此。
如果周期性的垃圾回收仍然对程序执行造成太大干扰,我们提供了控制选项,可以将系统整体 CPU 负载纳入决策考量,或者让用户完全禁用周期性垃圾回收。