spring boot 引起的 “堆外記憶體洩漏”
spring boot 引起的 “堆外記憶體洩漏”
背景
組內一個專案最近一直報swap區域使用過高異常,筆者被叫去幫忙檢視原因。
發現配置的4G堆內記憶體,但是實際使用的實體記憶體高達7G,確實有點不正常,JVM引數配置是“-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+AlwaysPreTouch -XX:ReservedCodeCacheSize=128m -XX:InitialCodeCacheSize=128m, -Xss512k -Xmx4g -Xms4g,-XX:+UseG1GC -XX:G1HeapRegionSize=4M”,但是使用的虛擬記憶體和實體記憶體使用情況如下:
排查過程
步驟一:先使用java層面的工具定位是不是堆內記憶體、code區域或者使用unsafe.allocateMemory和DirectByteBuffer申請的堆外記憶體
筆者在專案中新增“-XX:NativeMemoryTracking=summary ”JVM引數重啟專案,檢視查到的記憶體分佈如下:
發現這個命令顯示的committed的遠記憶體小於實體記憶體。
因為之前就對NativeMemoryTracking有所瞭解和測試,知道NativeMemoryTracking可以追蹤到堆內記憶體、code區域、通過unsafe.allocateMemory和DirectByteBuffer申請的記憶體,但是追蹤不到其他native code(c程式碼)申請的堆外記憶體。這一步也可以使用arthas去檢視:
為了防止誤判,筆者適應了pmap檢視記憶體分佈,發現大量的64M的地址,而這些地址空間不在NativeMemoryTracking所給出的地址空間裡面。基本上就斷定就是這些64M的記憶體導致的。
步驟二:使用系統層面的工具定位堆外記憶體
因為基本上確定是native code引起之後,java層面的工具基本上就失效了,只能使用系統層面的工具去查詢問題。首先使用了gperftools去檢視,截圖如下:
上圖可以看出,使用malloc申請的的記憶體最高到3G之後就釋放了,之後始終維持在700M-800M。第一反應就是難道native code 中沒有使用malloc申請,直接使用mmap/brk申請的?(gperftools原理就使用動態連結的方式替換了作業系統預設的記憶體分配器(glibc))
直接使用strace對mmap/brk進行追蹤發現,並沒有申請記憶體,此時陷入了比較迷茫的狀態。
於是想著能不能看看記憶體裡面是啥東西,就用gdb去dump這些64M的記憶體下來看看,內容如下:
從內容上來看像解壓後的jar資訊。讀取jar資訊應該是在專案啟動的時候,那麼在專案啟動之後使用strace作用就不是很大了,於是在專案啟動的時候就使用strace,發現確實申請了很多64M記憶體空間,截圖如下:
使用該mmap申請的地址空間在pmap對應如下:
根據strace顯示的執行緒Id,去jstack一下java程序,找到執行緒棧如下:
這裡基本上就可以看出問題來了,這裡使用了Reflections進行掃包,底層使用了spring boot loader去載入了jar。因為需要解壓jar肯定需要Inflater類,這個需要用到堆外記憶體,然後使用btrace去追蹤這個方法如下:
在程式碼中找到掃包的地方,發現沒有配置掃包路徑,預設的是掃描所有jar,修改為掃描特定的jar路徑。上線測試,記憶體正常,問題修復。
步驟三:為什麼堆外記憶體沒有釋放掉呢
到步驟二的時候,問題已經解決了,但是有幾個疑問:
- 為什麼堆外記憶體沒有釋放
- 為什麼記憶體大小都是64M,jar大小不可能這麼大,而且都是一樣大
- 為什麼gperftools最終顯示使用的的記憶體大小是700M左右,解壓包真的沒有使用malloc申請記憶體嗎?
直接看了一下spring boot loader那一塊原始碼,發現spring對jdk的JarFile的進行了包裝。他使用Inflater卻沒有手動去釋放,依賴於Inflater中的finalize機制,在gc的時候釋放。於是懷疑gc的時候沒有呼叫finalize。
帶著這樣的懷疑,我把Inflater進行包裝在spring loader裡面替換成我包裝的Inflater,在finalize進行打點監控,發現finalize在young gc 的時候確實被呼叫了啊。去看了一下Inflater對應的C程式碼,初始化的使用了malloc 申請記憶體,呼叫end的時候呼叫了free去釋放記憶體了。
於是懷疑free的時候沒有真正釋放記憶體。然後想著把spring boot包裝JarFile 替換成jdk 自帶的 JarFile,發現替換之後記憶體問題解決。然後再返過來看gperftools的記憶體分佈情況。發現使用spring loader的時候,記憶體使用一直在增加,突然某個點記憶體使用下降了好多。
這個點應該就是gc引起的,記憶體應該釋放了。但是作業系統層面沒有看到記憶體變化,懷疑沒有釋放到作業系統,被記憶體分配器持有了。
發現和不使用gperftools記憶體地址分佈差別很明顯,2.5G地址使用smaps發現他是屬於native stack。實體記憶體地址分佈如下:
到此基本上可以確定是記憶體分配器在搗鬼,搜尋了一下glibc 64M,發現從glibc 從2.11 開始對每個執行緒引入記憶體池(64位機器大小就是64M記憶體),原文如下:
按照文中所說去修改MALLOC_ARENA_MAX環境變數,發現沒什麼效果,去檢視tcmalloc(gperftools使用的記憶體分配器)也使用了記憶體池方式。
因為glibc 記憶體分配器程式碼太多,懶得去看,為了驗證就自己簡單寫個記憶體分配器。使用動態連結替換掉glibc 的記憶體分配器,程式碼如下(因為都是從main中分配記憶體,沒有考慮執行緒安全,realloc,calloc程式碼類似沒截圖了):
通過在自定義分配器當中埋點可以發現其實程式啟動之後程式實際申請的堆外記憶體其實始終在700M-800M之前,tcmalloc 也有相關埋點也是在700M-800M左右。但是從作業系統角度來看程序佔用的記憶體差別很大(這裡只是監控堆外記憶體)。
筆者做了一下測試,使用不同分配器進行不同程度的掃包,佔用的記憶體如下:
為什麼自定義的malloc 申請800M,最終佔用的實體記憶體在1.7G呢?
因為自定義記憶體採用的是mmap分配記憶體,mmap分配記憶體的單位是page,也就是page的整數倍,筆者使用的系統pagesize=4k,也就說如果使用者申請了1一個位元組,也會分配一個page,存在著巨大的空間浪費,可以通過埋點檢視系統申請了多少頁。埋點發現最終在536k左右吧。
那實際上向系統申請的記憶體 = 512k * 4k = 2G,為什麼這個資料由大於1.7G內,因為作業系統採取的是延遲載入的方式,也就是說通過mmap向系統申請記憶體的時候系統僅僅返回地址並沒有分配真實的實體地址,只有在使用的時候系統產生一個缺頁中斷然後在載入這個page到記憶體當中,這也是使用pmap看到的物理和虛擬記憶體的區別。
總結
整個記憶體分配的流程如上圖。在掃描包的時候,spring loader不會主動去釋放堆外記憶體,導致在掃描過程中,堆外記憶體佔用量一直持續飆升。當發生gc 的時候會依賴於finalize機制一併去釋放了堆外記憶體。但是glibc為了效能考慮,並沒有真正把記憶體歸返到作業系統,而是留下來當做記憶體池了,導致應用層以為發生了“記憶體洩漏”。
補充一下:定位問題用的工具有:top、jstack、arthas、pmap,gperftools,btrace、strace、gdb等。
-END-