Java垃圾回收 - ZGC

2024-05-16T16:52:00+08:00 | 11分钟阅读 | 更新于 2026-10-26T00:00:00+08:00

@
Java垃圾回收 - ZGC

ZGC 概览

本文以 JDK 27 为准(截至2026年10月的最新正式版本),涉及早期实现的地方会单独说明。

Z Garbage Collector,也称为ZGC,是一种可扩展的低延迟垃圾收集器,旨在满足以下目标:

  • 最大暂停时间在亚毫秒级
  • 暂停时间不会随着堆、live-set 或 root-set 的大小而增加
  • 适用于几百MB到16TB的堆

ZGC 最初是作为 JDK 11 中的一项实验性功能引入的,并在 JDK 15 中被宣布为Production Ready。JDK 21 引入分代ZGC,JDK 23 将分代模式设为ZGC的默认模式,JDK 24 起移除了不分代模式。这里的“默认”指的是选择ZGC后的运行模式,使用ZGC仍需要显式开启。

ZGC 的核心是一个并发垃圾收集器,这意味着所有繁重的工作都在Java 线程继续执行的同时完成。这极大地限制了垃圾收集对应用程序响应时间的影响。不过,亚毫秒级是GC暂停时间的设计目标,并不代表应用响应时间的上限,也不代表完全没有Stop-The-World(STW)暂停。

这个OpenJDK项目由HotSpot Group赞助。

Features

  • 分代的垃圾回收器,将新分配的对象与长时间存活的对象分别放在年轻代和老年代,更频繁地回收年轻代,减少反复扫描长寿命对象的开销。分代依据是对象的年龄,而不是数据访问的冷热。
  • 支持Linux、macOS和Windows上的多种64位平台,不支持32位平台。
  • 对象引用使用64位指针,不支持压缩对象指针(Compressed Oops)。这与对象头中的类指针是两回事。
  • 内存分区管理,且支持不同的分区粒度,在ZGC中分区称为页面(page),有小页面、中页面、大页面3种。
  • 具有颜色指针(colored pointer),在对象引用中同时保存地址和GC状态信息,用于判断引用是否需要标记、修正或记录。旧版不分代ZGC使用多个虚拟地址映射到同一物理内存,分代ZGC已不再使用这种多重映射。
  • 设计了读屏障和写屏障,配合颜色指针完成并发标记、跨代引用记录和并发转移时的引用修正。
  • 支持NUMA,尽量把对象分配在访问速度比较快的地方。

颜色指针与读写屏障

可以把颜色指针理解为带有GC状态的对象引用。分代ZGC在堆内的引用中保存这些状态,读屏障在应用读取引用时去除颜色信息;如果对象已经移动,还会找到新地址并修正引用。这样,应用线程就不必等到所有引用都更新完成后才能继续运行。

写屏障有两项重要工作。一项是记录老年代中可能指向年轻代的字段位置,形成记忆集(Remembered Set),这样回收年轻代时就不用扫描整个老年代。另一项是在并发标记期间记录即将被覆盖的旧引用,配合SATB(Snapshot At The Beginning,原始快照)保证标记开始时可达的对象不会被漏掉。与旧版不分代ZGC不同,分代ZGC把读屏障中的标记职责移到了写屏障。

回收时,GC先并发标记存活对象,再选择需要整理的页面,把存活对象转移出去并回收空间。标记开始、标记结束和转移开始等阶段仍有短暂暂停,但扫描对象和搬移对象这些耗时工作主要与应用线程并发执行。年轻代和老年代的大小、对象晋升年龄以及GC线程数量都可以自动调整,通常不需要手动划分固定比例。具体设计可以参考 JEP 439。

不足

  • 并发回收需要CPU和内存余量。如果对象分配速度持续超过回收速度,应用线程仍可能等待内存,出现分配停顿(Allocation Stall)。低GC暂停并不等于没有分配停顿。
  • 读写屏障会给应用带来额外开销;不支持压缩对象指针也可能增加对象引用的内存占用。是否能改善吞吐量和整体响应时间,需要结合实际负载测试。
  • 部分诊断功能仍有边界。例如,JDK 27 在分代ZGC下禁用了JFR的 jdk.OldObjectSample 事件,依赖该事件的内存泄漏分析需要注意。

早期版本“不支持类卸载”的限制已在JDK 12解除,ZGC也早已在JDK 15达到生产可用状态,不应再把这些列为当前版本的不足。

支持的平台

下面保留各平台最初支持ZGC的版本;具体可用性还取决于JDK发行版的构建配置。JDK 27 的支持范围也可参考OpenJDK构建配置。

platformSince
Linux/x64JDK 15(自 JDK 11 起是实验性的)
Linux/AArch64JDK 15(自 JDK 13 起实验性)
Linux/ppc64leJDK 17
macOS/x64JDK 15(自 JDK 14 以来的实验性版本)
macOS/AArch64JDK 17
Windows/x64JDK 15(自 JDK 14 以来的实验性版本)
Windows/AArch64JDK 16

此外,JDK 27 也支持Linux/riscv64。

配置和调优

常见参数

通用的GC 选项

  • -Xms<size>、-Xmx<size>:最小/初始堆大小和最大堆大小
  • -XX:SoftMaxHeapSize
  • -XX:ConcGCThreads
  • -XX:ParallelGCThreads
  • -XX:UseDynamicNumberOfGCThreads
  • -XX:UseLargePages
  • -XX:UseTransparentHugePages
  • -XX:UseNUMA
  • -XX:SoftRefLRUPolicyMSPerMB

ZGC选项

  • -XX:ZAllocationSpikeTolerance
  • -XX:ZFragmentationLimit
  • -XX:ZCollectionIntervalMinor
  • -XX:ZCollectionIntervalMajor
  • -XX:ZProactive
  • -XX:ZUncommit
  • -XX:ZUncommitDelay

其中 ZCollectionIntervalMinor 和 ZCollectionIntervalMajor 分别控制年轻代回收和包含老年代回收的触发间隔,单位为秒;旧的 ZCollectionInterval 是后者的兼容别名。一般先使用默认的自适应策略,不必指定固定回收间隔。

ZGC 诊断选项(-XX:+UnlockDiagnosticVMOptions)

  • -XX:ZStatisticsInterval
  • -XX:ZVerifyForwarding
  • -XX:ZVerifyMarking
  • -XX:ZVerifyObjects
  • -XX:ZVerifyRoots

旧版的 ZMarkStackSpaceLimit、ZVerifyViews 已不适用于JDK 27。参数会随版本变化,可以用 java -XX:+UseZGC -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version 查看当前JVM实际支持的选项,诊断选项通常不需要在生产环境中开启。

启用ZGC

-XX:+UseZGC

JDK 21、22需要额外指定 -XX:+ZGenerational 才会启用分代模式;JDK 23只指定 -XX:+UseZGC 就会使用分代模式。JDK 24移除不分代模式后,ZGenerational 先变为忽略并警告的旧选项,JDK 26起已被移除,继续传入会导致启动失败。

设置堆大小

ZGC 最重要的调优选项是设置最大堆大小 ( -Xmx<size>)。由于 ZGC 是并发收集器,因此必须选择最大堆大小,以便

  • 堆可以容纳应用程序的存活对象集(live-set)。
  • 堆中有足够的余量,在GC运行期间继续为应用分配对象。

需要多大的余量在很大程度上取决于应用程序的分配率和存活对象集的大小。一般来说,给ZGC更多内存可以降低回收压力。但同时,浪费内存也是不可取的,所以关键是要在内存使用和GC需要运行的频率之间找到一个平衡。

还可以通过 -XX:SoftMaxHeapSize=<size> 设置软上限。例如,-Xmx5g -XX:SoftMaxHeapSize=4g 会让ZGC尽量把堆控制在4G以内,但在需要避免分配停顿时仍允许增长到5G。软上限不能代替 -Xmx 的硬限制。

设置并发 GC 线程

另一个调整选项是设置并发的GC线程数量(-XX:ConcGCThreads=<number>)。当前ZGC默认会自动选择线程数量,并根据负载动态调整实际使用的线程,通常先保留默认值。手动设置主要是在限制并发GC可用的线程资源。给它太多,GC会从应用中占用过多的CPU时间。给得太少,应用程序分配对象的速度可能比GC回收内存的速度快。

注意!如果低延迟(即低应用响应时间)对你的应用很重要,需要给并发GC保留CPU余量,避免持续饱和。“CPU利用率不超过70%”只能作为经验参考,并不是ZGC的硬性要求,实际应结合分配速率、容器CPU配额和响应时间测试来判断。

将未使用的内存返回给操作系统

默认情况下,ZGC会取消提交(uncommit)不再使用的堆内存,将其归还给操作系统。这对于那些需要考虑内存占用的应用程序和环境是很有用的。这个功能可以用 -XX:-ZUncommit 来禁用。此外,归还内存不会让堆的大小缩小到最小堆大小(-Xms)以下。这意味着如果最小堆大小(-Xms)被配置为等于最大堆大小(-Xmx),这个功能将被隐式禁用。

可以用 -XX:ZUncommitDelay=<seconds>(默认是300秒)来配置取消提交的延迟。这个延迟指定了内存在多长时间内未被使用才有资格被归还。

如果更关注极低延迟,可以考虑将 -Xms 与 -Xmx 设为相同值,并使用 -XX:+AlwaysPreTouch 在启动时预先触碰堆内存。代价是更高的常驻内存占用和启动成本,应根据实际需求选择。

注意 !在Linux上,取消提交未使用的内存需要fallocate(2)支持FALLOC_FL_PUNCH_HOLE,它首次出现在内核版本3.5(针对tmpfs)和4.3(针对hugetlbfs)。

在 Linux 上启用大页面

将ZGC配置为使用大页面通常有助于改善吞吐量和延迟,但需要预留大页池,也可能影响系统中其他进程的可用内存。设置过程通常需要root权限,这就是为什么它在默认情况下不启用,具体收益仍需要测试。

在Linux/x86上,大页面(也被称为 “巨大页面”)的大小为2MB。

让我们假设你想要一个16G的Java堆。这意味着你需要16G / 2M = 8192个巨大的页面。

首先将至少16G(8192页)的内存分配给巨大页池。“至少 “这个部分很重要,因为在JVM中启用大页面意味着不仅GC会尝试将这些页面用于Java堆,而且JVM的其他部分也会尝试将其用于各种内部数据结构(代码堆、标记位图等)。因此,在这个例子中,我们将保留9216个页面(18G),允许2G的非Java堆分配使用大页面。

配置系统的巨大页面池,使其拥有所需的页面数量(需要root权限)。

echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

注意,如果内核不能找到足够的空闲的巨大页面来满足请求,上述命令不保证成功。还要注意,内核可能需要一些时间来处理这个请求。在继续之前,检查分配给池子的巨大页面的数量,以确保请求是成功的并且已经完成。

$ cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
9216 

现在使用-XX:+UseLargePages选项启动JVM。

$ java -XX:+UseZGC -Xms16G -Xmx16G -XX:+UseLargePages ...

注意 !以上步骤按当前JDK和现代Linux环境说明,不再需要照搬早期版本挂载hugetlbfs并指定 AllocateHeapAt 的流程。除非采取适当的措施,否则大页面池的配置在重启后是不持久的。

在Linux上启用透明的巨大页面

使用显式大页面(如上所述)的一个替代方法是使用透明的大页面。对于延迟敏感的应用,通常不推荐使用透明的大页面,因为它往往会导致不必要的延迟峰值。然而,这可能值得试验一下,看看你的工作负载是否/如何受到它的影响。但请注意,你的里程可能会有所不同。

注意!在Linux上,使用ZGC并启用透明的巨大页面需要内核>=4.7。

使用以下选项在虚拟机中启用透明的巨大页面。

-XX:+UseLargePages -XX:+UseTransparentHugePages

这些选项告诉JVM为其映射的内存发出madvise(…, MADV_HUGEPAGE)调用,这在madvise模式下使用透明的巨大页面时很有用。

为了启用透明的巨大页面,你还需要配置内核,启用madvise模式。

$ echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

和

$ echo advise > /sys/kernel/mm/transparent_hugepage/shmem_enabled

启用NUMA支持

在支持NUMA的平台和系统配置上,ZGC会尽力把Java堆分配到NUMA的本地内存。ZGC默认启用NUMA感知,实际效果取决于操作系统以及进程可访问的NUMA节点。一般来说,你不需要手动调整;如果需要明确控制,可以使用 -XX:+UseNUMA 或 -XX:-UseNUMA,并通过GC初始化日志确认实际配置。

启用 GC 日志记录

GC日志是通过以下命令行选项启用的。

-Xlog:<tag set>,[<tag set>, ...]:<log file>

关于这个选项的一般信息/帮助。

-Xlog:help

启用基本的日志记录。

-Xlog:gc:gc.log

启用对调整/性能分析有用的GC日志。

-Xlog:gc*:gc.log

其中gc*意味着记录所有包含gc标签的标签组合,而:gc.log意味着将日志写入一个名为gc.log的文件。

分代ZGC会分别记录年轻代和老年代的回收活动,分析时除了暂停时间,也要关注整个并发周期的耗时、堆占用和 Allocation Stall,避免只看暂停很短就认为没有内存压力。

迭代日志

JDK 27

  • 分代ZGC下禁用JFR的 jdk.OldObjectSample 事件,避免该组合带来的过高开销。

JDK 26

  • 移除已过时的 ZGenerational 选项,启动参数中应删除该选项。
  • AOT对象缓存支持任意GC,包括ZGC(JEP 516)。

JDK 24

  • 移除不分代ZGC,仅保留分代模式(JEP 490)。

JDK 23

  • 默认使用分代ZGC,弃用不分代模式(JEP 474)。

JDK 21

  • 引入分代ZGC,通过 -XX:+UseZGC -XX:+ZGenerational 启用(JEP 439)。

JDK 17

  • 动态的GC线程数量
  • 减少了标记堆栈内存的使用
  • 支持macOS/aarch64
  • 暂停和循环的GarbageCollectorMXBeans
  • 快速的JVM终止

JDK 16

  • 并发线程堆栈扫描(JEP 376)
  • 支持原地重定位
  • 性能改进(转发表的分配/初始化等)。

JDK 15

  • 生产就绪 (JEP 377)
  • 改进了NUMA意识
  • 改进了分配并发性
  • 支持类数据共享(CDS)
  • 支持将堆放在NVRAM上
  • 支持压缩的类指针
  • 支持增量取消提交内存
  • 修复了对透明的巨大页面的支持
  • 额外的JFR事件

JDK 14

  • 支持macOS (JEP 364)
  • 支持Windows(JEP 365)
  • 支持小堆(最低可配置到8M,不代表推荐的生产堆大小)。
  • 支持JFR泄漏分析器
  • 支持有限和不连续的地址空间
  • 并行预触摸(当使用-XX:+AlwaysPreTouch)。
  • 性能改进(克隆本征,等等)。
  • 稳定性改进

JDK 13

  • 将最大堆大小从4TB增加到16TB
  • 支持取消提交未使用的内存(JEP 351)。
  • 支持 -XX:SoftMaxHeapSize
  • 对Linux/AArch64平台的支持
  • 缩短了进入安全点的时间(Time-To-Safepoint)

JDK 12

  • 支持并发的类卸载
  • 进一步减少了暂停时间

JDK 11

  • ZGC的初始版本
  • 不支持类卸载(使用 -XX:+ClassUnloading 无效)。

FAQ

ZGC中的 “Z “代表什么?

它并不代表什么,ZGC只是一个名字。它最初是受ZFS(文件系统)的启发,或者说是对它的致敬,ZFS在很多方面都是革命性的。最初,ZFS是 “Zettabyte File System “的首字母缩写,但这个意思被放弃了,后来据说它不代表任何东西。它只是一个名字而已。

它的发音是 “zed gee see “还是 “zee gee see”?

没有首选的发音,两种都可以。

参考文献