一、背景
實際工作中,有一應用A,每隔幾個月就會出現記憶體告警,甚至OOM,持續一年多,一直以來解決辦法是重新開機。
最後發現問題在于堆外記憶體洩漏,通過JVM将使用的記憶體配置設定器ptmalloc2替換為jemalloc修複。
為分析和解決該堆外記憶體洩露問題,經曆兩個月,文章中的截圖對應這兩個月的不同時間線,大家看截圖裡的時間可能有些歧義。
是以,大家可以忽略截圖裡的時間,重點關注問題分析的思路。
二、問題分析
1 問題現狀
一共有7個4c8g節點,該應用作用是ELT,大量消費kafka中的所有應用的日志、鍊路等資料,再存儲到ES。
空閑記憶體慢慢在下降,我們選擇了其中1台進行排查。
2 記憶體分析
之前寫過一篇記憶體問題排查思路的文章:《一起探秘JVM記憶體問題(OOM、記憶體洩漏、堆外記憶體等》。
下面就按照文章的思路逐漸排查。
2.1 确定到底是哪個程序占用記憶體高
ps aux --sort=-%mem
實體記憶體占用第一的是應用A 約6.5G,第二的是X-agent應用,是以确認了應用A JVM程序占用記憶體過高導緻。
2.2 堆内、堆外記憶體逐漸排查
結合JVM記憶體分布圖,先排查堆内記憶體、再排查堆外記憶體。
2.3 堆外到底占用多少?
jcmd GC.heap_info
heap:啟動參數裡指定了Xmx是4G,從指令結果也可以看出約等于 new generation 1.8G + old geration 2G。
Metaspace: committed 約0.1G左右
通過pmap指令可以看到最真實的JVM heap的實體記憶體的占有量,因為Heap本質是一個記憶體池,池子的總大小4G,但是實際實體記憶體一般達不到4G,而Heap的used也隻是池子中使用部分的記憶體。是以還是要通過作業系統的pmap來查詢:
如上圖,JVM heap是4G的虛拟記憶體,啟動實體記憶體約占用3144764K即約3G,50M左右置換到了swap空間。
是以heap實際占用約3G。
2.4 堆外記憶體占用多少?
堆外實體記憶體 = 總占用實體記憶體 - heap占用的實體記憶體 = 6.5G - 3G = 3.5G
疑點: 怎麼會占用這麼多堆外記憶體,是哪塊記憶體占用這麼多?
2.5 分析堆外記憶體大戶
逐個分析堆外記憶體的大戶:Metaspace、Direct Memory、JNI Memory、code_cache
通過pmap指令确定每一塊空間的記憶體占用是最真實的,但是比較麻煩,是以通過Arthas的memory指令檢視:
Metaspace總量 + Direct Memory總量 + code_cache總量 = 103M + 1M + 59M 約 0.2G,這裡計算的是總量,實際實體記憶體占用沒那麼大,為友善計算,我們就按0.2G來算。
是以其他堆外記憶體總量 = 總堆外實體記憶體 - (Metaspace總量 + Direct Memory總量 + code_cache總量) = 3.5G - 0.2G - 3.3G
那大機率是JNI Memory這塊占用了大量記憶體。
2.6 分析JNI Memory記憶體
JNI Memory的記憶體是因為JVM記憶體調用了Native方法,即C、C++方法,是以需要使用C、C++的思路去解決。
排查過程是兩個大方向:
方向1:
1.gpertools分析誰沒有釋放記憶體:定位C、C++的函數
2.确認C、C++的函數對應的Java 方法
3.jstack或arthas的stack指令:Java方法對應的調用棧
方向2:
1.pmap定位記憶體塊的分布:檢視哪些記憶體塊的Rss、Swap占用大
2.dump出記憶體塊,列印出記憶體資料:把記憶體中的資料,列印成字元串,分析是什麼資料
2.7 通過google的gperftools工具排查誰在配置設定記憶體
gperftools工具會攔截記憶體配置設定和釋放等場景的函數,然後記錄調用的堆棧和記憶體配置設定、釋放的情況。
使用方式
1.安裝gperftools:
bash複制代碼yum install gperftools;
yum install gperftools-devel
yum -y install graphviz
yum -y install ghostscript
2.設定環境變量:
可以在應用的啟動腳本上
bash複制代碼vim app_start.sh
### 加 上 gperftools
export LD_PRELOAD=/usr/lib64/[libtcmalloc.so](http://libtcmalloc.so)
export HEAPPROFILE=/home/admin/gperftools/heap/hprof
..... start app ......
3.重新開機應用
kill app,再運作app_start.sh
4.持續觀察幾小時,執行指令:
bash複制代碼pprof --pdf --base=/home/admin/gperftools/heap/hprof_27126.1617.heap /usr/local/jdk8/bin/java /home/admin/gperftools/heap/hprof_27126.1619.heap> mem-diff.pdf
其中 /home/admin/gperftools/heap/hprof_27126.1617.heap和/usr/local/jdk8/bin/java /home/admin/gperftools/heap/hprof_27126.1619.heap是gperftools生成的Heap Profiling快照。
diff方式可以對比出哪些函數申請了記憶體,沒有釋放。
最大頭的是Java_java_util_zip_Inflater_inflateBytes函數在申請堆外記憶體,共680M,占比680M
網上查詢資料以及對比jdk源碼,發現對應于java的java.util.zip.Inflater#inflateBytes()方法,該方法是JVM的gzip壓縮工具中的。
2.8 确定是被誰調用的Java_java_util_zip_Inflater_inflateBytes呢?
每隔一秒通過jstack不停列印線程堆棧,過濾出java.util.zip.Inflater#inflateBytes方法相關的堆棧
(PS: 也可以通過arthas的stack指令攔截,注意java.util.zip.Inflater#inflateBytes是native方法,無法攔截,但是他是private方法,隻會被非java方法java.util.zip.Inflater#inflate調用,是以可以攔截java.util.zip.Inflater#inflate方法)
定位到了源碼處是XXKafkaConsumerService類,該類是在大量消費kafka消息,其中kafka的資料壓縮方式是gzip,當拉取到消息後,需要解壓gzip資料,會調用到Native函數Java_java_util_zip_Inflater_inflateBytes。
java複制代碼public void consume() {
.......
while (true) {
...kafkaConsumer.poll(timeout);
......
}
......
}
網上有類似的kafka gzip壓縮導緻的堆外記憶體洩漏問題:一次堆外記憶體洩露的排查過程 developer.aliyun.com/article/657…
2.8 GC後不釋放記憶體?
按理說,JDK肯定考慮到Java_java_util_zip_Inflater_inflateBytes會申請JNI Memory,同時必定也方式去釋放記憶體。
根據網上資料和看源碼,發現如果Inflater對象被GC回收時,會調用繼承于Object類的finalize()方法,該方法會釋放資源,包括釋放native的記憶體。
java複制代碼public class Inflater {
public void end() {
synchronized (zsRef) {
//
long addr = zsRef.address();
zsRef.clear();
if (addr != 0) {
end(addr);
buf = null;
}
}
}
/**
* 當垃圾回收的時候會執行finalize(),同時會釋放資源,包括釋放native的記憶體
*/
protected void finalize() {
end();
}
private native static void initIDs();
// ...
private native static void end(long addr);
}
2.9 GC後不釋放記憶體的幾種猜想
那就存在幾種可能:
1.Inflater對象沒有被回收,例如被其他對象長期引用着,沒能被回收。
但是從源碼看,Inflater對象的引用鍊中,父級對象是局部變量,且kafka消費沒有阻塞的情況,是以應該不會被長期引用。
是以,該猜想不成立。
2.Inflater對象在新生代中存活了一段時間,之後晉升到老年代了,但是我們長期都沒有一次FullGC,是以擠壓了很多這樣的對象,導緻堆外的記憶體沒釋放。
這個可以通過手工觸發一次FullGC來驗證:
jmap -histo:live 1904
但是結果不盡人意,雖然FullGC後,Inflater對象雖然有所減少,但是還是挺多的。
并且,記憶體占用量還是很高,占用量還有6G多。
3.那會不會是JNI Memory釋放了,但是記憶體沒有回收呢?
Linux作業系統預設使用的記憶體配置設定器是ptmalloc2,該記憶體配置設定器内部有一個記憶體池。當Inflater釋放記憶體時,ptmalloc2會不會緩存了這部分空閑記憶體?
查閱了較多資料,發現ptmalloc2在高并發配置設定記憶體時,會存在較多記憶體碎片無法釋放的情況,碎片積壓到一定程度甚至會導緻程序記憶體不夠用,最終OOM。
網上資料發現MySQL、TFS、Tair、Redis這些中間件部署時,指定jemlloc記憶體配置設定器替代ptmalloc2可更好地管理記憶體。
2.10 經典的Linux的64M記憶體塊問題
ptmalloc2記憶體洩漏洩漏有一個明顯的現象,是存在大量的64M的記憶體塊(虛拟記憶體)。
參考:一次大量 JVM Native 記憶體洩露的排查分析(64M 問題) juejin.cn/post/707862…
我們通過pmap指令把JVM程序中記憶體塊的分布列印出來:
(pmap -X 1904 | head -2; pmap -X 1904 | awk 'NR>2' | sort -nr -k6) > pmap_1.log
确實存在大量的64M記憶體占用的記憶體塊,把占用的實體記憶體(Rss)統計一下,一共約3.4G左右
随意選取幾個64M記憶體的位址,dump出記憶體
執行strings 103.dump将記憶體裡的資料轉為string字元串
通過列印記憶體中的資料發現,有大量的log、鍊路的資料,有的資料甚至是幾個月前的,可見曆史積壓了很久。
2.11 手動釋放ptmalloc記憶體
ptmalloc2因為自身設計的原因,在高并發情況下會存在大量記憶體碎片不釋放,通過調用 malloc_trim() 函數可以整理malloc記憶體池中的記憶體塊,合并、釋放空閑記憶體,可以簡單了解為JVM 的GC。
多次執行;gdb --batch --pid 1904 --ex 'call malloc_trim()'
效果非常明顯,立馬釋放出2G(6G減少到4G)左右的實體記憶體。
3 記憶體分析的結論
原因在于ptmalloc2存在大量記憶體碎片,積壓了很多曆史的資料,沒有及時釋放。
至于ptmalloc2在高并發的情況會導緻記憶體碎片不釋放,這個看過很多資料,嘗試了好多工具去驗證記憶體配置設定的情況,但是目前還沒有得出一個确切的根因分析。
三、解決方案
1.經試驗發現,kafka的consumer、provider端處理gzip壓縮算法時,都是可能出現JNI Memory記憶體洩露問題。
如果将kafka消息的壓縮算法gzip改為其他算法,例如Snappy、LZ4,這些壓縮算法可以規避掉JVM的gzip解、壓縮使用JNI Memory的問題。
關于Kafka的壓縮算法,可以參考,kakka不同壓縮算法的性能對比:www.cnblogs.com/huxi2b/p/10…
2.優化ptmalloc2的參數:
(1). export MALLOC_ARENA_MAX=8
結果:未解決問題,最終記憶體剩餘10%左右了
(2) 修改參數
bash複制代碼export MALLOC_MMAP_THRESHOLD_=131072
export MALLOC_TRIM_THRESHOLD_=131072
export MALLOC_TOP_PAD_=131072
export MALLOC_MMAP_MAX_=65536
結果:觀察數日,上面的參數還是有效果的,雖然還是會降低到10%以下,但是總體還是在13%左右
3.【推薦,效果明細】替換記憶體配置設定器為google的tcmalloc或facebook的jemalloc
将記憶體配置設定器的庫打包到項目部署包中,再用export LD_PRELOAD指定函數庫
結果:記憶體使用量非常穩定,長期都是40%多。由7台機器縮減為4台,也足夠支撐現有資料。
四、補充說明
1 為什麼linux使用ptmalloc2,而不是jemalloc、tcmalloc?
比如,單線程下配置設定 257K 位元組的記憶體,Ptmalloc2 的耗時不變仍然是 32 納秒,但 TCMalloc 就由 10 納秒上升到 64 納秒,增長了 5 倍以上!**現在 TCMalloc 反過來比 Ptmalloc2 慢了 1 倍!**這是因為 TCMalloc 特意針對小記憶體做了優化。
多少位元組叫小記憶體呢?TCMalloc 把記憶體分為 3 個檔次,小于等于 256KB 的稱為小記憶體,從 256KB 到 1M 稱為中等記憶體,大于 1MB 的叫做大記憶體。TCMalloc 對中等記憶體、大記憶體的配置設定速度很慢,比如我們用單線程配置設定 2M 的記憶體,Ptmalloc2 耗時仍然穩定在 32 納秒,但 TCMalloc 已經上升到 86 納秒,增長了 7 倍以上。
是以,如果主要配置設定 256KB 以下的記憶體,特别是在多線程環境下,應當選擇 TCMalloc;否則應使用 Ptmalloc2,它的通用性更好。
而一般的應用沒有那麼高的并發(申請記憶體tps高、并發線程多),對于普通應用而言,ptmalloc性能、穩定性足夠。
參考:【記憶體】記憶體池:如何提升記憶體配置設定的效率? www.cnblogs.com/hochan100/p…
2 注意事項
(1) 網上說malloc_trim有很小的機率會導緻JVM Crash,使用時需要小心,但是我目前未遇到過。
(2) google的gperftools分析記憶體用到了tcmalloc配置設定器,即上面配置的export LD_PRELOAD=/usr/lib64/libtcmalloc.so,一旦配置了tcmalloc配置設定器,就解決上面的JNI Memory存在記憶體碎片不釋放的問題!
如果要驗證64M記憶體塊問題,就得去掉export LD_PRELOAD=/usr/lib64/libtcmalloc.so