当前位置: 欣欣网 > 码农

GraalVM Native Image 原生内存跟踪【译】

2024-05-29码农

GraalVM Native Image 最近新增了对原生内存跟踪(Native Memory Tracking, NMT)的初步支持。这项功能目前可在早期访问构建中使用,并将在未来的 GraalVM JDK 23 版本中提供。NMT 的加入将允许 Native Image 的用户更好地理解他们的应用程序是如何使用堆外内存的。

一、背景

「原生内存」这个术语有时与「堆外内存」或「非管理内存」交替使用。当 Java 应用程序在 JVM 上运行时,「原生内存」指的是 Java 堆上未分配的任何内存。由于 JVM 是用 C++ 实现的,这包括 JVM 用于其内部操作的内存,以及使用 Unsafe#allocateMemory(long) 或从外部库请求的内存。

相比之下,当相同的应用程序作为 Native Image 可执行文件部署时,VM(SubstrateVM)是用 Java 实现的,而不是 C++。与 Hotspot 不同,这意味着 VM 内部使用的大部分内存是与应用程序级别的请求内存一起在「Java 堆」上分配的。然而,SubstrateVM 中仍然有地方使用了不在「Java 堆」上的非管理内存。Native Image 中 NMT 的目标是跟踪这些使用情况。

图 1 展示了一个高层次的说明,说明了在哪里进行分配。

二、用例

NMT 之所以有用,一个主要原因是它暴露了 Java 堆转储无法看到的内存信息。例如,假设你的应用程序的常驻集大小(RSS)远高于预期值。你生成并检查了堆转储,但没有发现任何可以解释高 RSS 的内容。这可能表明原生内存是罪魁祸首。但是,是什么导致了意外的原生内存使用?对你的应用程序代码的更改是否影响了原生内存使用?或者 GraalVM 的 SubstrateVM 运行时组件的更改?没有 NMT,很难确定发生了什么。

由于 Native Image 的运行时组件 SubstrateVM 在「Java 堆」上使用内存,VM 级别的大部分内存使用可以通过堆转储来暴露。这使得 Native Image 中的堆转储在某些方面比普通 Java 更具信息量。然而,正如前面提到的,SubstrateVM 仍然为其大量操作使用非管理内存。一些例子包括垃圾回收和 JDK Flight Recorder。

相比之下,Native Image 构建时间较慢,导致大多数开发人员在常规 Java 中进行大部分开发和测试。但是,如果切换到原生模式时性能有显著差异怎么办?NMT 在比较在常规 Java 中运行应用程序和 Native Image 之间的差异时可能特别有用。这是因为 Native Image 中的底层 VM 与 Hotspot 大不相同,即使 Java 应用程序代码相同,也可能导致完全不同的资源消耗。NMT 可以帮助你在这种情况下了解发生了什么,以便你可以为原生执行优化你的代码。

三、在 Native Image 中使用

你可以通过在构建时包含 --enable-monitoring=nmt 功能来在你的 Native Image 可执行文件中使用 NMT。默认情况下,NMT 不包含在镜像构建中。一旦在构建时包含了 NMT,它将在运行时始终启用。

$ native-image --enable-monitoring=nmt MyApp

在运行时添加 -XX:+PrintNMTStatistics 将导致在程序关闭时将 NMT 报告转储到标准输出。

$ ./myapp -XX:+PrintNMTStatistics

转储的报告如下所示:

Native memory tracking
Peak total used memory: 13632010 bytes
Total alive allocations at peak usage: 1414
Total used memory: 2125896 bytes
Total alive allocations: 48
Compiler peak used memory: 0 bytes
Compiler alive allocations at peak: 0
Compiler currently used memory: 0 bytes
Compiler currently alive allocations: 0
Code peak used memory: 0 bytes
Code alive allocations at peak: 0
Code currently used memory: 0 bytes
Code currently alive allocations: 0
GC peak used memory: 0 bytes
GC alive allocations at peak: 0
GC currently used memory: 0 bytes
GC currently alive allocations: 0
Heap Dump peak used memory: 0 bytes
Heap Dump alive allocations at peak: 0
Heap Dump currently used memory: 0 bytes
Heap Dump currently alive allocations: 0
JFR peak used memory: 11295082 bytes
JFR alive allocations at peak: 1327
JFR currently used memory: 24720 bytes
JFR currently alive allocations: 3
JNI peak used memory: 80 bytes
JNI alive allocations at peak: 1
JNI currently used memory: 80 bytes
JNI currently alive allocations: 1
jvmstat peak used memory: 0 bytes
jvmstat alive allocations at peak: 0
jvmstat currently used memory: 0 bytes
jvmstat currently alive allocations: 0
Native Memory Tracking peak used memory: 23584 bytes
Native Memory Tracking alive allocations at peak: 1474
Native Memory Tracking currently used memory: 768 bytes
Native Memory Tracking currently alive allocations: 48
PGO peak used memory: 0 bytes
PGO alive allocations at peak: 0
PGO currently used memory: 0 bytes
PGO currently alive allocations: 0
Threading peak used memory: 3960 bytes
Threading alive allocations at peak: 30
Threading currently used memory: 1088 bytes
Threading currently alive allocations: 8
Unsafe peak used memory: 2310280 bytes
Unsafe alive allocations at peak: 57
Unsafe currently used memory: 2099240 bytes
Unsafe currently alive allocations: 36
Internal peak used memory: 1024 bytes
Internal alive allocations at peak: 1
Internal currently used memory: 0 bytes
Internal currently alive allocations: 0

报告显示了按类别组织的瞬时内存使用情况、瞬时计数、峰值使用情况和峰值计数。类别与 Hotspot 中的类别不完全相同。这是因为 Hotspot 中有许多类别不适用于 SubstrateVM。还有一些类别在 SubstrateVM 中有用,但在 Hotspot 中不存在。

此报告是单个瞬间的快照,因此仅反映生成时的原生内存使用情况。这就是为什么建议通过 JFR 消费 NMT 数据的方式,如下所述。

四、NMT JDK Flight Recorder (JFR) 事件

Native Image 支持 OpenJDK JFR 事件 jdk.NativeMemoryUsage jdk.NativeMemoryUsageTotal jdk.NativeMemoryUsage 按类别公开使用情况,而 jdk.NativeMemoryUsageTotal 公开总体使用情况。两个事件都不包括计数信息。

还有两个特定的 Native Image JFR 事件可以访问: jdk.NativeMemoryUsagePeak jdk.NativeMemoryUsageTotalPeak 。这些自定义事件已被创建,以公开 JFR 事件中未公开的峰值使用数据,这些事件是从 OpenJDK 移植过来的。在常规 Java 中,用户将使用 jcmd 来获取峰值使用信息。然而, jcmd 在 Native Image 中不受支持。

4.1 启用 JFR 和 NMT

为了访问这些公开 NMT 数据的 JFR 事件,只需在构建时包含 JFR 和 NMT:

$ native-image --enable-monitoring=nmt,jfr MyApp

然后在启动应用程序时启用 JFR。如果包含在构建时,NMT 将在运行时自动启用。

$ ./myapp -XX:StartFlightRecording=filename=recording.jfr

4.2 示例

以下是使用 jfr 工具查看 jdk.NativeMemoryUsage 内容的示例。

$ jfr print --events jdk.NativeMemoryUsage recording.jfr 
jdk.NativeMemoryUsage {
startTime = 15:47:33.392 (2024-04-25)
type = "JFR"
reserved = 10.1 MB
committed = 10.1 MB
}
jdk.NativeMemoryUsage {
startTime = 15:47:33.392 (2024-04-25)
type = "Threading"
reserved = 272 bytes
committed = 272 bytes
}
...

图 2 是使用 JDK Mission Control 查看 jdk.NativeMemoryUsageTotal 内容的示例。 下面是使用该工具查看的一个示例。类似于NMT报告转储,值以 Bytes.jdk.NativeMemoryUsagePeakjfr 显示。

$jfrprint --events jdk.NativeMemoryUsagePeak recording.jfr 
jdk.NativeMemoryUsagePeak {
startTime = 15:47:35.221 (2024-04-25)
type = "Threading"
peakReserved = 424
peakCommitted = 424
countAtPeak = 4
eventThread = "JFR Shutdown Hook" (javaThreadId = 64)
}
jdk.NativeMemoryUsagePeak {
startTime = 15:47:35.221 (2024-04-25)
type = "Unsafe"
peakReserved = 14336
peakCommitted = 14336
countAtPeak = 2
eventThread = "JFR Shutdown Hook" (javaThreadId = 64)
}
...

4.3 显示实验性 JFR 事件

需要注意的是,这些事件被标记为实验性。这意味着像 VisualVM 和 JDK Mission Control 这样的软件默认会隐藏它们。

jdk.NativeMemoryUsagePeak jdk.NativeMemoryUsageTotalPeak 是 JDK 中的两个事件,它们用于跟踪Java虚拟机(JVM)使用的本地内存的峰值。

要在 VisualVM 中显示这些事件,请在 Browser 标签页中选中 Display experimental items 复选框,如图3所示。

图3:VisualVM中显示的实验性事件。

要在 JDK Mission Control 中显示这些事件,在 Preferences > JDK Mission Control > Flight Recorder ,选择 Include experimental events... ,如图4所示。 图4:使用「首选项」菜单来切换显示实验性事件。

五、实现细节

Native Image 中的 NMT 与 Hotspot 中的对应功能非常相似。具体来说,它通过插桩 malloc / calloc / realloc 调用点以及 mmap 调用点来工作。总体上,这种插桩被分为 malloc 跟踪和虚拟内存跟踪。虚拟内存跟踪组件尚未集成(在下一节中阅读更多)。 malloc 跟踪利用在每个 malloc 分配上添加小标题。这些标题存储有关分配的元数据,并允许 NMT 系统保持对现有原生内存块的准确理解。实际的内存使用记录本质上是一系列连续更新的集中计数器。这意味着在生成 NMT 报告时,只报告原生内存使用的瞬时快照。

六、性能影响

与在即时编译模式下运行 Java 应用程序的 JVM 类似,Native Image NMT 在大多数场景中的性能影响非常小。当前实现每个分配有 16B 的开销,以容纳 malloc 标题。可能会有对用于跟踪使用量的原子计数器的争用,但在大多数情况下,预计没有足够的并发原生内存分配来产生任何影响。

使用基于基本入门快速启动的简单 Quarkus 应用程序,运行了一个测试以收集下表中的性能指标。Hyperfoil 被用来驱动对 Quarkus 原生镜像可执行文件的请求。每秒发送 50 个请求,持续 5 秒,最多同时发送 25 个请求。每个请求在生成响应之前都会导致 1000 个连续的 1KB 原生分配。这是最坏情况的例子,因为有大量的小分配而不是较少的大分配。 malloc 标题开销与分配计数成比例,而不是分配大小。因此,这应该被解释为夸大的案例,大多数情况下的性能会更好。

这些结果是 10 次运行的平均值。表中还包括了 Java JIT 执行的结果进行比较。

Measurement With NMT (NI) Without NMT (NI) With NMT (Java) Without NMT (Java)
RSS (KB) 53,030 53,390 151,460 148,952
启动时间 (ms) 147 144 1,378 1,364
平均响应时间 (us) 4,040 3,766 4,881 4,613
P50 响应时间 (us) 3,846 3,615 4,695 4,440
P90 响应时间 (us) 4,925 4,544 6,337 5,924
P99 响应时间 (us) 13,772 11,347 12,497 12,602
Image size (B) 47,292,872 47,290,704 N/A N/A

从结果表中我们可以看到,NMT 对 RSS(在启动时测量)和启动时间的影响微乎其微。这是意料之中的,因为 NMT 不需要任何特别的设置或预先分配(与 JFR 不同)。NMT 内存开销与本地分配的数量成比例。镜像大小的增加也非常小 (增加 <3KB)。尽管示例夸大了,但响应延迟也受到了最小的影响。

后来进行了第二次测试,以确定 NMT 和 JFR 的综合影响。结果如下。

Measurement With NMT and JFR (NI) Without NMT or JFR (NI) With NMT and JFR (Java) Without NMT or JFR (Java)
RSS (KB) 72,366 52,388 191,504 149,756
启动时间 (ms) 193 138 1,920 1,315
平均响应时间 (us) 5,038 4,451 5,990 4,452
P50 响应时间 (us) 4,882 3,548 4,793 4,325
P90 响应时间 (us) 6,704 4,662 9,525 5,826
P99 响应时间 (us) 12,320 9,591 30,644 10,623
Image size (B) 50,938,336 47,290,272 N/A N/A

我们可以看到,一旦 JFR 出现,性能影响就大得多。实际上,JFR 的影响在所有测量类别中都大大超过了 NMT 的影响。

我们还可以使用 NMT 本身来确定正在使用的资源数量。下面显示了 NMT 报告的相关部分:

JFR peak used memory: 11295082 bytes
JFR alive allocations at peak: 1327
Native Memory Tracking peak used memory: 23584 bytes
Native Memory Tracking alive allocations at peak: 1474

从这些信息中我们可以看到,JFR 使用的原生内存完全超过了 NMT 所需的内存。这是因为 JFR 要复杂得多。例如,JFR 需要为每个平台线程提供线程本地缓冲区,并为内部数据提供存储表。你还可以看到,NMT 的峰值分配计数乘以 malloc 头的大小等于峰值使用大小。

七、限制和规划

Native Image 中 NMT 的当前实现仅跟踪 malloc / calloc / realloc ,缺少虚拟内存跟踪。虚拟内存会计组件的初始迭代已完成,但仍在审查过程中。然而, malloc 跟踪可能是大多数情况下发生大多数有趣分配的地方。当前的 SubstrateVM 实现仅使用虚拟内存操作,如 mmap,来支持「Java 堆」并映射「镜像堆」(Native Image 的「Java 堆」的只读部分)。缺少虚拟内存跟踪意味着像 jdk.NativeMemoryUsage 这样的 JFR 事件将报告保留和提交的内存相等。一旦集成了虚拟内存跟踪,并且我们除了 malloc 之外还跟踪个别的保留/提交,某些类别中的保留和提交值可能会有所不同。

Native Image NMT 与 OpenJDK 共享的一个限制是,它只能跟踪 VM 级别的分配和使用 Unsafe#allocateMemory(long) 进行的分配。例如,如果库代码或应用程序代码直接调用 malloc,则该调用将绕过 NMT 会计并且不会被跟踪。

Native Image 特有的一个限制是,如果没有 JFR,就没有办法在程序执行期间的任意点收集 NMT 报告数据。你必须等到程序完成才能转储报告。正在研究的一个解决方案是能够在接收到信号时转储报告(类似于堆转储)。

在 OpenJDK 中,NMT 有两种不同的模式。「摘要」模式类似于目前在 Native Image 中实现的,而「详细」模式还跟踪分配调用站点。「详细」模式尚未在 Native Image 中支持,但未来可能会添加。

还需要知道的是,每个内部 NMT 计数器都是独立且非原子地与其他计数器更新的。这与 OpenJDK 中的情况相同。然而,这意味着「总计」或「峰值时计数」测量桶的报告可能会因请求报告的时间而暂时与其他计数器不同步。

八、总结

添加到 Native Image 的新 NMT 功能应该有助于用户了解他们的可执行文件是如何使用原生内存的。NMT 加入了 JFR、JMX、堆转储和调试信息的行列,成为 Native Image 可观测性和调试工具箱中的另一个组件。我希望你将尝试这个新功能,并报告你拥有的任何建议或你发现的任何问题。你可以在 GraalVM GitHub 的 Issues 中反馈。干杯!