流計算 Oceanus | Flink JVM 記憶體超限的分析方法總結

語言: CN / TW / HK

作者:董偉柯,騰訊 CSIG 高階工程師

問題背景

前段時間,某客戶線上執行的大作業(並行度 200 左右)遇到了 TaskManager JVM 記憶體超限問題(實際記憶體用量 4.1G > 容器設定的最大閾值 4.0G),被 YARN 的 pmem-check 機制檢測到併發送了 SIGTERM(kill)訊號終止該 container,最終導致作業出現崩潰。這個問題近期出現了好幾次,客戶希望能找到解決方案,避免國慶期間線上業務受到影響。

在 Flink 配置項中,提供了很多記憶體引數設定。我們逐一檢查了客戶作業的配置,發現各個記憶體配置的最大值之和也只有 3.75GB 左右(不含 JVM 自身 Native 記憶體區),離設定的 4.0GB 閾值還有 256MB 的空間。

使用者作業 並沒有用到 RocksDB、GZip 等常見的需要使用 Native 記憶體且容易造成記憶體洩漏的第三方庫,而且從 GC 日誌來看,堆內各個區域遠遠沒有用滿,說明餘量還是比較充足的。

那究竟是什麼原因造成實際記憶體用量(RSS)超限了呢?

Flink 記憶體模型

要分析問題,首先要了解 Flink 和 JVM 的記憶體模型。官方文件 [1] 和很多第三方部落格 [2] [3] 都對此有較為詳盡的分析,這裡只做流程的簡單說明,不再詳盡描述每個區域的具體計算過程。

下圖展示了 Flink 記憶體各個區域的配置引數,其中左邊是 Flink 配置項中的記憶體引數,中間是引數對應的記憶體區域,右邊是這個作業配置的引數值。

圖中深綠色的文字(taskmanager.memory.process.size)表示 JVM 所在容器記憶體的硬限制,例如 Kubernetes Pod YAML 的 resource limits。它的相關類為 ClusterSpecification ,裡面描述了 JobManager、TaskManager 容器所允許的最大記憶體用量,以及每個 TaskManager 的 Slot(執行槽)數等。

TaskManager 各個區域的記憶體用量是由 TaskExecutorProcessSpec 類來描述的。首先 Flink 的 ResourceManager 會呼叫 TaskExecutorFlinkMemoryUtils 工具類,從使用者和系統的各項配置 Configuration 中獲取各個記憶體區域的大小( TaskExecutorFlinkMemory 物件,不含 Metaspace 和 Overhead 部分)。這中間要考慮到舊版本引數的 相容性 ,所以有很多繞來繞去的封裝程式碼。總而言之,優先順序是 新配置 > 舊配置 > 無配置(計算推導 + 預設值)。隨後再根據配置和上述的計算結果,推匯出 JvmMetaspaceAndOverhead ,最終封裝為包含各個區域記憶體大小定義的 TaskExecutorProcessSpec 物件。

圖中最右邊淺綠色文字表示 Flink 記憶體引數最終翻譯成的 JVM 引數(例如堆區域的 -Xmx、-Xms,Direct 記憶體區的 -XX:MaxDirectMemorySize 等),他們是 JVM 程序最終執行時的記憶體區域劃分依據,是 ProcessMemoryUtils 這個工具類從上述的 TaskExecutorProcessSpec 物件中生成的。

堆內記憶體的分析

堆內記憶體(JVM Heap),指的是上圖的 Framework Heap 和 Task Heap 部分。Task Heap 是 Flink 作業記憶體分配的重點區域,也是 JVM OutOfMemoryError: Java heap space 問題的發生地,當 OOM 問題發生時如下圖:

如果這個區域記憶體佔滿了,也會出現不停的 GC,尤其是 Full GC。這些可以從監控指標面板看到,也可以通過 jstat 等命令檢視。如果我們通過 Arthas、async-profiler [4] 等工具對 JVM 進行執行時火焰圖取樣的話,也可以看到類似下面的結果:GC 相關的執行緒佔了很大的時間片比例:

對於堆內記憶體的洩漏分析,如果程序即將崩潰但是還存活,可以使用 jmap 來獲取一份堆記憶體的 dump:

jmap -dump:live,format=b,file=/tmp/dump.hprof 程序PID(先做一次 Full GC 再 dump
jmap -dump:format=b,file=/tmp/dump.hprof 程序PID(直接 dump

如果程序崩潰難以捕捉,可以在 Flink 配置的 JVM 啟動引數中增加:

env.java.opts.taskmanager: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/taskmanager.hprof

這樣 JVM 在發生 OOM 的時刻,會將堆記憶體 dump 儲存到指定路徑後再退出。

拿到堆記憶體 dump 檔案以後,我們可以使用 MAT [5] 這個開源的小工具來分析潛在的記憶體洩漏情況,並輸出報表。

如果 MAT 不能滿足需求,還有 JProfiler 等更全面的工具可以進行堆記憶體的高階分析。

當然,很不幸的是,這個出問題的作業的堆記憶體區域並沒有用滿,GC 日誌看起來一切正常,堆記憶體洩漏的可能性排除。那麼還需要進一步涉足堆外記憶體的各個神祕區域。

堆外記憶體的分析

JVM 堆外記憶體又分為多個區域,例如 Flink HybridMemorySegment 會用到 Java NIO 的 DirectByteBuffer 使用的 Direct 記憶體區(MaxDirectMemorySize 引數限制的區域),類載入等使用的 Metaspace 區(MaxMetaspaceSize 引數限制的區域,JDK 8 以前叫做 PermGen)。

如果 Direct 記憶體區發生了 OOM,JVM 會報出 OutOfMemoryError: Direct buffer memory 錯誤;而 Metaspace 區 OOM 則會報出 OutOfMemoryError: Metaspace 錯誤。但是這個作業日誌中並沒有看到任何 OutOfMemoryError 的錯誤,因此這些地方記憶體洩漏的可能性也不大。

使用 Native Memory Tracking 檢視 JVM 的各個記憶體區域用量

JVM 自帶了一個很有用的詳細記憶體分配追蹤工具:Native Memory Tracking(NMT)[6],可以通過配置 JVM 啟動引數來開啟(可能造成 10% ~ 20% 的效能下降,線上慎用):

-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics -XX:NativeMemoryTracking=summary

隨後可以對執行中的 JVM 程序執行:

jcmd 程序 VM.native_memory summary

來獲取此時此刻的 JVM 各區域的記憶體用量報表。

下面是一個典型的返回結果(// 為本文備註內容,標出了佔用較多記憶體的區域含義):

Total: reserved=5249055KB, committed=3997707KB // 總實體記憶體申請量為 3.81G
- Java Heap (reserved=3129344KB, committed=3129344KB) // 堆記憶體佔了 2.98G 實體記憶體
(mmap: reserved=3129344KB, committed=3129344KB)


- Class (reserved=1130076KB, committed=90824KB) // 類的元資料佔用了 88.7M 實體記憶體
(classes #13501) // 載入的類數
(malloc=1628KB #17097)
(mmap: reserved=1128448KB, committed=89196KB)


- Thread (reserved=136084KB, committed=136084KB) // 執行緒棧佔用了 132.9M 實體記憶體
(thread #132) // 執行緒數
(stack: reserved=135504KB, committed=135504KB)
(malloc=425KB #692)
(arena=155KB #249)


- Code (reserved=256605KB, committed=44513KB)
(malloc=7005KB #11435)
(mmap: reserved=249600KB, committed=37508KB)


- GC (reserved=69038KB, committed=69038KB)
(malloc=58846KB #618)
(mmap: reserved=10192KB, committed=10192KB)


- Compiler (reserved=394KB, committed=394KB)
(malloc=263KB #811)
(arena=131KB #18)


- Internal (reserved=432708KB, committed=432704KB) // Direct 記憶體等部分佔了 422.6M 實體記憶體
(malloc=432672KB #31503)
(mmap: reserved=36KB, committed=32KB)


- Symbol (reserved=23801KB, committed=23801KB)
(malloc=21875KB #165235)
(arena=1926KB #1)


- Native Memory Tracking (reserved=3582KB, committed=3582KB)
(malloc=20KB #226)
(tracking overhead=3563KB)


- Arena Chunk (reserved=1542KB, committed=1542KB)
(malloc=1542KB)


- Unknown (reserved=65880KB, committed=65880KB)
(mmap: reserved=65880KB, committed=65880KB)

可以看到,堆記憶體、Direct 記憶體等區域佔據了 JVM 的大部分記憶體,其他區域佔用量相對較小。這個 JVM 被統計到的實時記憶體申請量為 3.81GB.

但是,使用 top 命令檢視這個 JVM 程序的實時用量時,發現 RSS(實體記憶體佔用)已經升高到了 4.2G 左右,與上述結果不符,說明還是有部分記憶體沒有追蹤到:

使用 jemalloc 替代 ptmalloc 並統計記憶體動態分配

既然 JVM 自己統計的記憶體分配與實際佔用仍然有較多偏差,而搜尋了網上的各種資料時,經常會遇到因為 glibc malloc 64M 快取造成記憶體超標的問題 [7]。

由於 jemalloc 並沒有這個 64M 的問題,而且可以通過 profiler 來統計 malloc 呼叫的動態分配情況,因此決定先使用 jemalloc [8] 來替換 glibc 自帶的分配函式,並進行統計。當然,使用 strace 等命令也可以攔截記憶體分配和釋放情況(追蹤 mmap、munmap、brk 等系統呼叫),不過結果太多了,分析起來並不方便。

下載解壓 jemalloc 的發行包以後,進入相關目錄,編譯並安裝它:

./configure --enable-prof --enable-stats --enable-debug --enable-fill && make && make install

隨後在 Flink 引數里加上這些內容:

containerized.taskmanager.env.LD_PRELOAD: "/usr/local/lib/libjemalloc.so.2"
containerized.taskmanager.env.MALLOC_CONF: "prof:true,lg_prof_interval:29,lg_prof_sample:17"

重新執行作業,就可以不斷地採集記憶體分配情況,並輸出 .heap 檔案到 JVM 程序的工作目錄(例如 jeprof.951461.7.i7.heap)。

隨後可以安裝 graphviz,再使用 jemalloc 自帶的 jeprof 命令對結果進行繪圖(儘量在程序退出前繪圖,避免地址無法解析):

yum install -y graphviz
jeprof --svg `which java` 採集的.heap檔名 > ~/result.svg

結果如下:

從左邊的分支來看,71.1% 的記憶體分配請求主要由 Unsafe.allocateMemory() 呼叫的(例如 Flink MemoryManager 分配的堆外 MemorySegments),中間分支的 init 是 JVM 啟動期間分配的,也是正常範圍。右邊分支主要是 JVM 內部的 ParNew & CMS GC、Class 解析所需的符號表、程式碼快取所需的記憶體,也是正常的。因此並未觀察到較大的第三方庫造成的記憶體洩漏情況,因此間接引入第三方庫造成記憶體洩漏的可能性也基本排除了。

使用 pmap 命令定期取樣記憶體區塊分配

既然 JVM NMT 上報的記憶體分割槽快照、jemalloc 統計的動態分配情況都沒有找到準確的問題根源,我們還可以從底層出發,使用 pmap 命令來檢視 JVM 程序的各個記憶體區域的分配情況,看是否有異常的條目。

可以使用下面的命令,從 Flink TaskManager 啟動開始取樣:

while true
do
pmap -x JVM程序的PID > /tmp/pmap.`date +%Y-%m-%d-%H-%M-%S`.log
sleep 30s
done

隨後可以使用檔案比較工具,對比不同時間點的記憶體分配情況(例如下圖是剛啟動和崩潰前的最後一個記錄),看是否有大塊的不能解釋的分配區段:

上圖中,除了 堆記憶體區 有大幅增長(只是稍微超出一些 Xmx 的限制),其他區域的增長都比較小,因此說明 JVM 記憶體超限基本上是因為堆記憶體區域隨著使用自然擴充套件 + JVM 自身較大的 Overhead(內部所需記憶體)造成的。並且這部分記憶體在 NMT 報告裡統計的並不準確,還需要進一步跟進。

初步總結

在上面的分析中,我們先從最容易分配也是佔比最大的堆記憶體區域開始分析,逐步進入堆外記憶體的深水區。由於堆外記憶體除了 Java 自帶的 NMT 機制外,並沒有綜合的分析工具可用,因此這裡的分析過程往往繁雜而耗時,且較難得到準確原因。

本次問題的初步結論是 JVM 自身執行所需的記憶體(Overhead)佔用較大,而使用者對 Flink 的引數 taskmanager.memory.jvm-overhead.{min,fraction,max} 設定值過小(為了給堆記憶體留出更大空間,在這裡只設置了 256MB 的閾值,而實際的記憶體佔用不止這些)。

需要注意的是 ,這個引數並不意味著 Flink 能“限制”JVM 內部的記憶體用量。相反,它的用途是令 Flink 在計算各區域(Heap、Off-Heap、Network 等)的記憶體空間時,能考慮到 JVM 這部分 Overhead 空間並不能被自己使用,應當減去這部分不受控的餘量後再分配。

特別地,當用到了 RocksDB 等 JNI 呼叫的原生庫時,請務必繼續調大 taskmanager.memory.jvm-overhead.fraction taskmanager.memory.jvm-overhead.max 引數的值(例如給到 1~2GB),避免餘量不夠而造成的總記憶體用量超標的問題。

下表總結了本文所用的工具和適用場景:

工具名 簡介 常用使用場景
jstat Java 自帶的命令,可以檢視 JVM 的統計資訊,例如各類 GC 次數、時長等,各記憶體區域的使用量等 檢視各區域記憶體用量,定位 GC 問題等
jmap Java 自帶的命令,可以生成 JVM 堆內記憶體的 Dump 檔案,也可以檢視記憶體物件分佈直方圖等 獲取堆內記憶體 Dump、檢視堆記憶體中物件分佈
jcmd Java 自帶的命令,可以對正在執行的 JVM 傳送命令,例如開啟和關閉特定引數、觸發 GC、檢視某些統計資訊等 開啟內建 Flight Recorder、檢視 NMT 統計資訊等
Arthas 包含了一系列問題定位和 JVM 操控小工具,可用來攔截執行時呼叫現場,動態程式碼替換、檢視 Classloader、Dump 記憶體等多種用途 各類場景,通常線上使用
JProfiler 非常強大的 JVM 剖析和問題排查工具,可以線上 attach 到遠端 JVM,也可以離線分析 Dump 檔案 各類場景,圖形化診斷 JVM 問題
MAT Eclipse 推出的自動堆記憶體洩露分析工具 堆記憶體洩露分析
NMT Java 自帶的功能,可以追蹤 JVM 內部各區域的記憶體分配和使用情況 堆外記憶體分析
jemalloc + jeprof 一個通用的記憶體管理庫,可以替代 glibc 中的 malloc,可以避免很多記憶體碎片問題,支援記錄呼叫次數和分配量等資訊等用於後續分析 底層 malloc 呼叫分析和剖析
pmap Linux 自帶命令,檢視某個程序的記憶體對映資訊 程序記憶體對映情況分析

後續計劃

由於人工排查堆外記憶體問題的過程相當繁瑣,十分依賴定位者的直覺和經驗,可複製性弱,工具不統一,效率很 低。

我們正在規劃將這些定位流程標準化地整合到我們的流計算 Oceanus 平臺上,做到自助、自動診斷,逐步實現我們的願景:打造大資料產品生態體系的實時化分析利器,成為一個基於 Apache Flink 構建的具備一站開發、無縫連線、亞秒延時、低廉成本、安全穩定的企業級實時大資料分析平臺,實現企業資料價值最大化,加速企業實時化、數字化的建設程序。

參考閱讀

[1] Flink 官方文件 · 記憶體模型詳解 https://ci.apache.org/projects/flink/flink-docs-release-1.10/zh/ops/memory/mem_detail.html

[2] Flink記憶體配置 https://www.jianshu.com/p/a29b7b7feaaf

[3] Flink記憶體設定思路 https://www.cnblogs.com/lighten/p/13053828.html

[4] jvm-profiling-tools/async-profiler https://github.com/jvm-profiling-tools/async-profiler

[5] Memory Analyzer (MAT) https://www.eclipse.org/mat/

[6] Native Memory Tracking https://docs.oracle.com/javase/8/docs/technotes/guides/vm/nmt-8.html

[7] 疑案追蹤:Spring Boot記憶體洩露排查記 https://mp.weixin.qq.com/s/aYwIH0TN3nSzNaMR2FN0AA

[8] jemalloc https://github.com/jemalloc/jemalloc/releases

流計算 Oceanus  限量秒殺專享活動火爆進行中↓↓

點選文末 「閱讀原文」 ,瞭解騰訊雲流計算 Oceanus 更多資訊 ~

騰訊雲大資料

長按二維碼
關注我們