ZGC 如何为 Java 堆分配内存

这篇文章探讨了 OpenJDK 中的垃圾收集器之一 ZGC 如何为 Java 堆分配内存,重点关注了 Mapped Cache 在 JDK-8350441 中引入的增强功能。垃圾收集器不仅仅是收集垃圾,这正是我希望在这篇文章中阐述的内容。无论您是渴望了解细节的 Java 爱好者、GC 爱好者,还是只是对 ZGC 在幕后如何使用内存感到好奇,这篇深入的文章都适合您。

这篇文章中描述的特性将会在 OpenJDK 25 版本中发布。 您可以从这里下载最新版本的 OpenJDK 。要查看最新版本的 ZGC,您应该查看主线源代码。一些特别相关的源文件包括:

这篇文章中的内容通常适用于 ZGC 运行的所有操作系统和平台。 在相关的地方提到了其他特定于 Linux 的详细信息。 例如,ZGC 仅在 Linux 上支持 NUMA,而在 Windows 和 BSD 上不支持。

Background

在 ZGC 中,Java 堆的内存被组织成称为 pages 的逻辑区域。 这些不应与操作系统 (OS) 页面混淆。 从现在开始,除非另有明确说明,否则对 pages 的任何引用都特指 ZGC pages。

Pages 分为三个大小类别: SmallMediumLarge 。 这种分类有助于优化基于对象大小的内存使用和分配策略。

Java 堆的内存(进而pages的内存)由 Page Allocator 管理。 分配 page 的主要部分是获取其底层内存,page 使用该内存来存储对象。 内存是一种有限的资源,并且使用了许多策略来确保可以成功分配内存。 ZGC 用于其底层内存的最小大小为 2 MB,我们将其称为 Granule,也是 Small page 内存的大小。

在讨论 ZGC 上下文中的堆大小时,我们通常使用术语 capacity 而不是堆大小,即使大多数文档都使用堆大小。 当前 capacity 定义为当前堆大小,它可能会在一些定义的边界之间增长和缩小。 这些边界可以通过命令行显式设置,也可以由 JVM 根据系统资源隐式设置。 有两个关键标志可用于显式配置堆大小: Minimum Heap Size (-Xms) 和 Maximum Heap Size (-Xmx)。 在主机计算机上的所有可用内存中,Java 堆允许在这些边界内使用内存。

[---------|-----------------------------|-----------]
     ^               ^
  Minimum Heap Size      Maximum Heap Size
    (-Xms)            (-Xmx)

注意: 使用 -Xms 设置堆大小会同时设置初始堆大小 AND 最小堆大小。 在大多数情况下,这可能是所需的配置。 如果您想要不同的最小堆大小和初始堆大小,您可以使用 -XX:MinHeapSize 标志。 确保在 -Xms 之后设置它,以便 -Xms 不会覆盖 -XX:MinHeapSize 设置的值。

最小堆大小(最小 capacity)和最大堆大小(最大 capacity)可以设置为相同的值,在这种情况下,堆是固定的,永远不会调整大小。

[-------------------------|-------------------------]
             ^
     Fixed Heap Size (min = max)
            (-Xms = -Xmx)

例如,以下命令使用最小(和初始)堆大小 512 MB 和最大堆大小 8 GB 启动 ZGC:

$ java -XX:+UseZGC -Xms512M -Xmx8G <java file>

Allocating Memory for a Page

现在我们已经了解了如何设置堆大小以及如何将其组织成pages,让我们深入了解 Page Allocator 如何管理和分配这些pages。

Partitions

管理堆的 Page Allocator 维护着多个 partitions 。 每个 partition 代表 Java 堆的一个子集。 在大多数系统上,整个堆都作为一个 partition 进行管理。 但是,某些系统可能会将堆分成多个 partitions,每个 partition 管理总堆 capacity 的一部分。

Single Partition

单个 partition 覆盖整个 Java 堆。 它将具有由 -Xms-Xmx 设置的最小和最大 capacity。

Heap Capacity: from -Xms to -Xmx
+--------------------------------------------------+
|          Partition 0          |
|     (entire heap managed as one)      |
+--------------------------------------------------+

Multiple Partitions

使用多个 partitions,Java 堆被均匀划分,以便所有 partitions 的总和等于总堆大小。 每个 partition 获得最小和最大 capacity 的均等份额,并允许在其自己的边界内独立增长和缩小。

Heap Capacity: from -Xms to -Xmx (evenly divided)
+------------------+------------------+------------------+
|  Partition 0  |  Partition 1  |  Partition 2  |
|  min = Xms/3  |  min = Xms/3  |  min = Xms/3  |
|  max = Xmx/3  |  max = Xmx/3  |  max = Xmx/3  |
+------------------+------------------+------------------+

目前,仅在具有 NUMA (Non-Uniform Memory Access) 架构的系统上运行时启用多个 partitions。 NUMA 是一种内存设计,可提供对本地连接到处理器(或 NUMA 节点)的内存的更快访问,而对连接到其他处理器(远程节点)的内存的访问速度较慢。 在 NUMA 系统上运行并且启用了 NUMA(使用 -XX:+UseNUMA 标志,默认情况下启用)时,每个 partition 将对应于一个特定的 NUMA 节点。 因此,partitions 的数量将与 NUMA 节点的数量匹配。 例如,如果 NUMA 系统有 4 个 NUMA 节点,则 ZGC 会将堆分成 4 个 partitions。 这种方法允许 ZGC 在最靠近请求内存的处理器的 NUMA 节点上本地分配内存,这可能会提高内存访问速度并提高性能。

partition 和特定 NUMA 节点之间的连接只是一个逻辑连接,可能并不总是反映事实。 在某些极端情况下,内存可能在与进行分配的 partition 匹配的 NUMA 节点上不可用。 这可能是由于系统上的其他进程在 NUMA 节点上不均匀地使用内存,从而导致不平衡。 在这种情况下,将在另一个 NUMA 节点上分配内存,以便成功进行分配。 此策略称为 preferred allocation ,有关更多详细信息,请参阅 Linux kernel docs 中的 MPOL_PREFERRED

ZGC 中对 NUMA 的支持并不是什么新鲜事物,但是随着 Mapped Cache 的引入,它已被重新设计 (JDK-8350441)。 当 NUMA 关闭时(无论是用户显式关闭还是因为系统不支持 NUMA),仅使用一个 partition。

Memory

让我们进一步深入了解如何为 partition 分配和使用内存。 ZGC 是 HotSpot 中 GC 里面比较独特的一个,因为它分离了物理内存和虚拟内存。 以下各节重点介绍了如何以不同方式处理物理内存和虚拟内存,以及它们在用于分配之前分别面临哪些问题。

Physical Memory

物理内存通常是指系统上可用的硬件 RAM,这是一种有限的资源。 它可以被人为地限制,也可以被计算机上使用内存的其他任务限制。 在 ZGC 中,物理内存与堆 capacity 直接相关,堆 capacity 在 partition 中表示。 物理内存可以存在于三种关键状态之一:已提交并已映射、已提交和未提交。 下图显示了物理内存可能转换到的状态以及从这些状态转换出的状态。

(Committed + Mapped) <-> (Committed) <-> (Not Committed)

Committed memory 是指已为应用程序保留使用的内存,并保证由物理存储(通常是这种情况下的 RAM)支持。 当提交内存时,系统会确保有足够的物理空间可用并将其保留给应用程序进程。 要访问已提交的物理内存,必须将其映射到虚拟内存。 ZGC 仅跟踪已提交并已映射或未提交的内存。 已提交但尚未映射的内存被认为是中间状态,此后不久将被映射或取消提交。 分配内存时,ZGC 会确保在映射内存之前具有相应的虚拟内存。 这意味着任何成功提交的内存都会立即映射,并且已提交的内存在分配期间永远不必取消提交。 映射内存预计总是会成功,有关更详细的说明,请参阅 Additional Notes

partition 中跟踪的 capacity 表示 partition 允许提交多少内存。 如果是单个 partition,将最小堆大小 (-Xms) 设置为 1 GB 意味着必须提交至少 1 GB。 相反,将最大堆大小 (-Xmx) 设置为 8 GB 意味着不能提交超过 8 GB。 下面是 partition 中跟踪的与 capacity 相关的术语的图示。 除了前面提到的最小和最大 capacity 之外,partition 还跟踪当前 capacity 和当前最大 capacity,它们都在最小和最大边界之间移动。 增加 capacity 意味着正在提交新内存,并且当前 capacity 正在增加,而减少 capacity 意味着正在取消提交内存,并且当前 capacity 正在减少。 当前最大 capacity 始终减小,从不增长,并且最初设置为与最大 capacity 相同的值。 如果提交新内存失败,则会减小。

[--------|--------#-------------@----------|--------]
    min  current   current_max  max

Virtual Memory

ZGC 将物理内存和虚拟内存分离的主要原因是为了对抗碎片,碎片会阻碍分配连续虚拟内存的能力。 通过分离内存,可以过度保留虚拟内存,这意味着保留的虚拟内存多于可用物理内存(即 capacity)。 这增加了在分配期间找到可以映射到物理内存的连续虚拟内存范围的可能性。 默认情况下,ZGC 保留的虚拟内存高达最大堆大小的 16 倍,并在所有 partitions 中平均分配。

保留虚拟内存的最低要求是_至少_获得与最大堆大小一样多的虚拟内存。 这确保了物理内存可以与虚拟内存一对一映射,任何更少都会阻止所有物理内存被使用。

在具有多个 partitions 的系统上(即启用了 NUMA 的 NUMA 系统),ZGC 尝试保留最大堆大小的 32 倍。 额外的 16 倍用于一种特殊的分配,称为多 partition 分配(在 Multi-Partition Allocation 中描述)。

过度保留虚拟内存使得处理碎片更容易,但在某些情况下,可能无法保留我们想要的那么多虚拟内存。 这在具有大量物理内存的系统上尤其明显,在这些系统上,32 倍甚至 16 倍都可能无法实现。 作为在这些情况下缓解碎片的一种方法,对于具有对抗性分配模式的程序,ZGC 会在释放pages时主动整理堆。 碎片整理的工作原理是将物理内存重新映射到位于较低内存区域中的新虚拟地址,其目标是“填充孔”以创建更大的连续范围。 目前,仅在释放 Large pages 时才进行碎片整理。

Mapped Cache

Physical Memory 中前面提到的,partition 跟踪未提交的内存或已提交和已映射的内存。 未提交的内存被隐式地跟踪为未使用的 capacity,可以通过增加 capacity 和提交新内存来“分配”。 已提交并映射但当前未被任何 page 使用的内存存储在 Mapped Cache 中。 Mapped Cache 中的术语“Mapped”指的是它存储已映射的内存。

Mapped Cache 使用自平衡二叉搜索树(红黑树)来存储已映射内存的范围。 由于树存储未使用的已映射内存,因此它可以使用此内存来存储有关自身的数据,从而无需动态内存分配 (malloc),这可能会对 page 分配期间的延迟产生负面影响。 这种类型的存储称为 intrusive storage

Mapped Cache 旨在通过在插入时合并相邻的虚拟内存来尽可能地保持最大的连续内存范围。 此外,为了加快分配期间搜索连续内存的速度,Mapped Cache 跟踪多个大小类别。 每个大小类别都包含树中大于指定大小的条目。 大小类别的影响在 Medium 和/或 Large pages 的内存分配中很明显。

与 ZGC 的所有其他区域一样,Mapped Cache 中的最小工作大小是一个 Granule(即 2 MB)。 因此,从缓存中删除 Small page 的内存时,只要树不为空,就可以始终使用树中的第一个节点。 这意味着 Small pages 的分配永远不需要在树中搜索足够大的条目。

Allocation

分配内存的第一步是声明 capacity,这主要包括确保有足够的物理内存。 如果我们设法从 Mapped Cache 中获取已映射和已提交的内存(从此处开始称为缓存),它还可能包括虚拟内存。

声明 capacity 时,我们首先尝试从缓存中获取连续内存,该内存已经映射、提交并且大小正确,可以立即用于 page。 这是结果 (1),这是 Small pages 最常见的快速路径,并且当缓存不为空时总是成功的。

(1) Contiguous memory from the cache
|-----------------------------------------|
|         cache          |

如果缓存不包含足够大的连续范围,我们将继续增加 capacity。 增加 capacity 意味着我们需要提交新内存。 这是结果 (2)。

(2) Only increased capacity
|-----------------------------------------|
|      increased capacity      |

如果根本无法增加 capacity,这意味着我们已经提交了允许的所有内存(如当前最大 capacity 所示)。 并且由于我们没有从缓存中获得足够大的连续内存,我们将继续从缓存中删除较小的范围,以便它们加起来达到请求的大小。 这称为“harvesting”,是结果 (3)。

(3) Only harvested
|-----------------------------------------|
|-|-|-|-|----------------|-|-|------|-----|
|        harvested         |

如果当前 capacity 接近当前最大 capacity,并且我们已将 capacity 增加到其限制,但它没有覆盖请求的大小,我们将在增加 capacity 的同时执行 harvesting。 这意味着一些内存将被提交,作为增加 capacity 的一部分,并且一些部分将从缓存中的内存中harvesting。 这是结果 (4)。

(4) Combination of harvested and increased capacity
|-----------------------------------------|
|-|-|-----|---------|---------------------|
|   harvested   | increased capacity | 

如果在缓存中或从增加 capacity 中没有足够的 capacity 可用,则分配失败。 第一次声明 capacity 失败时,会触发所谓的“allocation stall”。 allocation stall 意味着分配线程将触发次要 GC 并暂停,直到(希望)释放内存,此时可以再次尝试声明 capacity。 如果分配第二次失败,则会抛出 OutOfMemoryError (OOME)。

在继续提交新内存或harvesting之前,ZGC 会确保虚拟内存可用。 这样可以在提交后立即映射内存,因为 ZGC 不跟踪已提交但未映射的内存。 在大多数情况下,声明虚拟内存是成功的,但可能会由于碎片而无法找到足够大的连续范围而失败。 这通常只是 Large pages 的问题。 如果无法获取虚拟内存,则会抛出 OOME。

Increasing Capacity (Committing)

如果分配增加了 capacity,无论是整个分配还是仅部分分配,都会提交新内存。 从操作系统提交内存通常是成功的,但可能会失败。 如果提交内存失败,我们将通过降低当前最大 capacity 来记录这一点,从而阻止将来尝试提交该内存。 重要的是,ZGC 不会假设内存将来会再次可用。 一旦降低了最大 capacity,它就会保持降低。 这种保守的方法基于这样一个事实,即很难预测系统上其他应用程序的行为,这些应用程序可能会或可能不会在以后释放内存。

因为增加 capacity 发生在从缓存中harvesting之前,所以 ZGC 使用更新后的当前最大 capacity “重试”分配。 这使得后续分配从缓存中声明物理内存而不是增加 capacity。 如果缓存包含足够的内存来成功harvesting,则重试可能会成功,请参阅下面的部分。

如果提交成功,则新提交的内存会立即映射到之前声明的虚拟内存,从而完成 capacity 的增加。

Harvesting (Remapping)

在这种情况下,harvesting指的是从多个内存范围声明物理内存(即 capacity),这些内存范围已经映射并提交,但在虚拟内存中不连续。 由于 page 的虚拟内存必须是连续的,因此需要将harvesting的区域重新映射(即取消映射然后再映射)到单个连续范围内。

为了提高harvesting成功的可能性,在声明虚拟内存时会“重用”harvesting范围中的虚拟内存。 首先,取消映射harvesting的范围以将其虚拟内存插入到可用虚拟内存池中。 随后,声明一个连续的虚拟内存范围。 如果声明成功,则将未映射的范围映射到新的连续虚拟内存范围,从而完成harvesting。

如果无法声明连续范围,则必须将harvesting的物理内存映射回虚拟内存并插入到缓存中,因为 ZGC 不跟踪已提交但未映射的已提交内存。 如果harvesting失败,则会抛出 OOME。

harvesting的行为可能会对分配延迟产生负面影响,特别是对于 Medium pages。 虽然 Small pages 永远不会harvesting,并且 Large pages 已经涉及高分配成本,但 Medium pages 的影响更大,因为它们的大小较小,最坏情况下,多个系统调用的相对成本(取消映射许多 2 MB 范围)可能会变得很大。

Sorting Physical Memory

正如虚拟内存会变得碎片化一样,物理内存也会变得碎片化,尤其是在harvesting时,无论是否增加了 capacity。 这通常不是问题,因为物理内存完全有可能(并且在某种程度上是合理的)变得碎片化。 只有在访问时才需要连续的虚拟内存范围。 当物理内存变得碎片化时,操作系统会将连续的虚拟内存以增量映射到连续的物理内存。 即使连续的虚拟内存呈现为单个映射,但事实可能是操作系统存储了许多映射(在 Linux 中称为 VMA)。

为了减少操作系统上的压力,在将虚拟内存映射到物理内存之前对其进行排序。 在最佳情况下,这会减少操作系统所需的映射数量。 这对于具有大堆的系统尤其有影响,在这些系统上,默认情况下可能未正确配置最大映射数量。 有关更多信息,请参阅 Additional Notes

Multi-Partition Allocation

上面部分中描述的过程是所谓的单 partition 分配中发生的事情,其中 capacity 仅从 Page Allocator 中的单个 partition 声明。 但是,可能存在任何单个 partition 中没有足够的 capacity 来满足分配的情况。 在这种情况下,总内存可能可用,分布在多个 partitions 中。 为了不提前抛出 OOME(当堆实际上包含足够的内存时),会尝试进行所谓的多 partition 分配。

注意: 仅当 Page Allocator 具有多个 partitions(即启用了 NUMA)并且成功进行了完整的 32 倍虚拟内存保留时,才会启用多 partition 分配(有关更多详细信息,请参阅 Virtual Memory)。

在多 partition 分配中,完成相同的声明 capacity 操作,但每个 partition 完成一次。 最初,尝试从每个 partition 声明相同的 capacity 量,以便不对任何特定 partition 产生偏差。 如果不可能进行均匀分割,则会以循环方式贪婪地从每个 partition 声明剩余的 capacity,直到满足分配为止。

下图显示了已从三个 partitions(0、1 和 2)声明 capacity 的多 partition 分配:

|-----------------------------------------|
|----(0)----|-------(1)-------|----(2)----|
|    multi-partition allocation    |

Uncommitting

默认情况下,ZGC 会定期取消提交当前未被pages使用的内存,即存储在缓存中的内存。 此行为在内存占用空间是一个问题的应用程序或环境中特别有用。 但是,在大多数用例中,取消提交是有代价的:它有效地撤消了提交和映射内存的昂贵工作,如前面的章节中所述。 因此,将来的分配可能会遭受延迟增加的影响,因为必须重新完成所有这些工作。

可以使用 -XX:-ZUncommit 标志显式禁用取消提交,或者通过将最小堆大小 (-Xms) 和最大堆大小 (-Xmx) 设置为相同的值来禁用取消提交。 在这种情况下,堆的大小将固定,取消提交不再有意义(请参阅 Background)。

取消提交是根据 -XX:ZUncommitDelay=<seconds> 标志定期执行的,默认情况下设置为 300 秒(5 分钟)。 取消提交时,Page Allocator 会考虑自上次取消提交以来缓存中的内存使用情况,并且可能会释放其认为在此期间未使用的任何数量的内存。

Discussion on Latency

在 ZGC 中讨论内存时,在启动时间和程序执行期间的延迟之间存在权衡。 将最小堆大小和最大堆大小设置为相同的值会导致在启动时提交所有内存。 如果最小堆大小和最大堆大小未设置为相同的值,则可能会在运行时提交内存,这将对分配延迟产生负面影响,因为需要即时完成提交。

此外,提交内存仅保证它将在将来的_某个时间点_由物理内存支持。 为了确保在提交时实际支持内存,必须“触摸”它。 通过使用 -XX:+AlwaysPreTouch 标志在启动期间预先触摸内存,所有最初提交的内存都会在启动时被触摸。 这再次在更快的启动和执行期间的潜在分配延迟之间引入了权衡。

在 Linux 上使用共享内存的一个影响是,无论是否启用预先触摸,ZGC 都必须立即支持物理内存。 这使得在 Linux 上使用 -XX:+AlwaysPreTouch 标志变得多余,但我仍然建议启用它,以防将来此行为发生更改。

如果延迟是一个关键问题,您应该禁用取消提交,无论是使用 -XX:-ZUncommit 标志显式禁用,还是通过将最小堆大小和最大堆大小设置为相同的值隐式禁用。

Additional Notes

$ java -XX:+UseZGC -Xlog:gc+init=info --version
...
[0.001s][info][gc,init] Medium Page Size: 32M
...