https://heapdump.cn/article/2660684
編者按:筆者使用JDK自帶的記憶體跟蹤工具NMT和Linux自帶的pmap解決了一個非常典型的資源洩漏問題。這個資源洩漏是由於Java程式設計師不正確的使用Java API導致的,使用Files.list開啟的檔案描述符必須關閉。本案例一方面介紹了怎麼使用NMT解決JVM資源洩漏問題,如果讀者遇到類似問題,可以嘗試用NMT來解決;另一方面也提醒Java開發人員使用Java API時需要必須弄清楚API使用規範,希望大家透過這個案例有所收穫。
背景知識:
NMT
NMT是Native Memory Tracking的縮寫,一個JDK自帶的小工具,用來跟蹤JVM本地記憶體分配情況(本地記憶體指的是non-heap,例如JVM在執行時需要分配一些輔助資料結構用於自身的執行)。
NMT功能預設關閉,可以在Java程式啟動引數中加入以下引數來開啟:
-XX:NativeMemoryTracking=[summary | detail]
其中,“summary”和“deatil”的差別主要在輸出資訊的詳細程度。
開啟NMT功能後,就可以使用JDK提供的jcmd命令來讀取NMT採集的資料了,具體命令如下:
jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown]
NMT引數的含義可以透過“jcmd <pid> help VM.native_memory”命令查詢。透過NMT工具,我們可以快速區分記憶體洩露是否源自JVM分配。
pmap
對於非JVM分配的記憶體,經常需要用到pmap這個工具了,這是一個linux系統自帶工具,能夠從系統層面輸出目標程序記憶體使用的詳細情況,用法非常簡單:
pmap [引數] <pid>
常用的選項是“-x”或“-X”,都是用來控制輸出資訊的詳細程度。
上圖是pmap部分輸出資訊,每列含義為
Address | 每段記憶體空間起始地址 |
---|---|
Kbytes | 每段記憶體空間大小(單位KB) |
RSS | 每段記憶體空間實際使用記憶體大小(單位KB) |
Dirty | 每段記憶體空間髒頁大小(單位KB) |
Mode | 每段記憶體空間許可權屬性 |
Mapping | 可以對映到檔案,也可以是“anon”表示匿名記憶體段,還有一些特殊名字如“stack” |
現象:
某業務叢集中,多個節點出現業務程序記憶體消耗緩慢增長現象,以其中一個節點為例:
如圖所示,這個業務程序當前佔用了4.7G的虛擬記憶體空間,以及2.2G的實體記憶體。已知正常狀態下該業務程序的實體記憶體佔用量不超過1G。
分析:
使用命令“jcmd <pid> VM.native_memory detail”可以看到所有受JVM監控的記憶體分佈情況:
上圖只是擷取了nmt(Native Memory Tracking)命令展示的概覽資訊,這個業務程序佔用的2.2G實體記憶體中,受JVM監控的大概只佔了0.7G(上圖中的committed),意味著有1.5G實體記憶體不受JVM管控。JVM可以監控到Java堆、元空間、CodeCache、直接記憶體等區域,但無法監控到那些由JVM之外的Native Code申請的記憶體,例如典型的場景是,一個第三方so庫中呼叫malloc了一片記憶體的行為就無法被JVM感知到。
nmt除了會展示概覽之外,還會詳細羅列每一片受JVM監控的記憶體,包括其地址,將這些JVM監控到的記憶體佈局跟用pmap得到的完整的程序記憶體佈局做一個對比篩查,這裡忽略nmt和pmap(下圖pmap命令中25600是程序號)詳細記憶體地址的資訊,直接給出最可疑的那塊記憶體:
由圖可知,這片1.7G左右的記憶體區域屬於系統層面的堆區。
備註:這片系統堆區之所以稍大於上面計算得到的差值,原因大概是nmt中顯示的committed記憶體並不對應真正佔用的實體記憶體(linux使用Lazy策略管理程序記憶體),實際通常會稍小。
系統堆區主要就是由libc庫介面malloc申請的記憶體組合而成,所以接下來就是去跟蹤業務程序中的每次malloc呼叫,上GDB:
實際上會有大量的干擾項,這些干擾項一方面來自JVM內部,比如:
這部分干擾項很容易被排除,凡是呼叫棧中存在“os::malloc”這個棧幀的干擾項就可以直接忽視,因為這些malloc行為都會被nmt監控到,而上面已經排除了受JVM監控記憶體洩漏的可能。
另一部分干擾項則來自JDK,比如:
有如上圖所示,不少JDK的本地方法中直接或間接呼叫了malloc,這部分malloc行為通常是不受JVM監控的,所以需要根據具體情況逐個排查,還是以上圖為例,排查過程如下:
注意圖中臨時中斷的值(0x0000ffff5fc55d00)來自於第一個中斷b malloc中斷髮生後的結果。
這裡稍微解釋一下上面GDB在做的排查過程,就是檢查malloc返回的記憶體地址後續是否有透過free釋放(透過tb free if $x0 ==$X3這個命令,具體用法可以參考gdb除錯),顯然在這個例子中是有釋放的。
透過這種排查方式,幾經篩選,最終找到了一個可疑的malloc場景:
從呼叫棧資訊可以知道,這是一個JDK中的本地方法sun.nio.fs.UnixNativeDispatcher.opendir0,作用是開啟一個目錄,但後續始終沒有進行關閉操作。進一步分析可知,該可疑opendir操作會週期性執行,而且都是操作同一個目錄“/xxx/nginx/etc/nginx/conf”,看來,是有個業務執行緒在定時訪問nginx的配置目錄,每次訪問完卻沒有關閉開啟的目錄。
分析到這裡,其實這個問題已經差不多水落石出。跟業務方確認,存在一個定時器執行緒在週期性讀取nginx的配置檔案,程式碼大概是這樣子的:
翻了一下相關JDK原始碼,Files.list方法是有在末尾註冊一個關閉鉤子的:
也就是說,Files.list方法返回的目錄資源是需要手動釋放的,否則就會發生資源洩漏。
由於這個目錄資源底層是會關聯一個fd的,所以洩漏問題還可以透過另一個地方進行佐證:
該業務程序目前已經消耗了51116個fd!
假設這些fd都是opendir關聯的,每個opendir消耗32K,則總共消耗1.6G,顯然可以跟上面洩漏的記憶體值基本對上。
總結:
稍微瞭解了一下,發現幾乎沒人知道JDK方法Files.list是需要關閉的,這個案例算是給大家都提了個醒。
後記
如果遇到相關技術問題(包括不限於畢昇JDK),可以進入畢昇JDK社群查詢相關資源(點選原文進入官網),包括二進位制下載、程式碼倉庫、使用教學、安裝、學習資料等。畢昇JDK社群每雙週週二舉行技術例會,同時有一個技術交流群討論GCC、LLVM、JDK和V8等相關編譯技術,感興趣的同學可以新增如下微信小助手,回覆Compiler入群。