當前位置: 妍妍網 > 碼農

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 中反饋。幹杯!