天天看點

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

一、背景

實際工作中,有一應用A,每隔幾個月就會出現記憶體告警,甚至OOM,持續一年多,一直以來解決辦法是重新開機。

最後發現問題在于堆外記憶體洩漏,通過JVM将使用的記憶體配置設定器ptmalloc2替換為jemalloc修複。

為分析和解決該堆外記憶體洩露問題,經曆兩個月,文章中的截圖對應這兩個月的不同時間線,大家看截圖裡的時間可能有些歧義。

是以,大家可以忽略截圖裡的時間,重點關注問題分析的思路。

二、問題分析

1 問題現狀

一共有7個4c8g節點,該應用作用是ELT,大量消費kafka中的所有應用的日志、鍊路等資料,再存儲到ES。

空閑記憶體慢慢在下降,我們選擇了其中1台進行排查。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

2 記憶體分析

之前寫過一篇記憶體問題排查思路的文章:《一起探秘JVM記憶體問題(OOM、記憶體洩漏、堆外記憶體等》。

下面就按照文章的思路逐漸排查。

2.1 确定到底是哪個程序占用記憶體高

ps aux --sort=-%mem

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

實體記憶體占用第一的是應用A 約6.5G,第二的是X-agent應用,是以确認了應用A JVM程序占用記憶體過高導緻。

2.2 堆内、堆外記憶體逐漸排查

結合JVM記憶體分布圖,先排查堆内記憶體、再排查堆外記憶體。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

2.3 堆外到底占用多少?

jcmd GC.heap_info

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

heap:啟動參數裡指定了Xmx是4G,從指令結果也可以看出約等于 new generation 1.8G + old geration 2G。

Metaspace: committed 約0.1G左右

通過pmap指令可以看到最真實的JVM heap的實體記憶體的占有量,因為Heap本質是一個記憶體池,池子的總大小4G,但是實際實體記憶體一般達不到4G,而Heap的used也隻是池子中使用部分的記憶體。是以還是要通過作業系統的pmap來查詢:

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

如上圖,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指令檢視:

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

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

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

網上查詢資料以及對比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方法)

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

定位到了源碼處是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消費沒有阻塞的情況,是以應該不會被長期引用。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

是以,該猜想不成立。

2.Inflater對象在新生代中存活了一段時間,之後晉升到老年代了,但是我們長期都沒有一次FullGC,是以擠壓了很多這樣的對象,導緻堆外的記憶體沒釋放。

這個可以通過手工觸發一次FullGC來驗證:

jmap -histo:live 1904

但是結果不盡人意,雖然FullGC後,Inflater對象雖然有所減少,但是還是挺多的。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

并且,記憶體占用量還是很高,占用量還有6G多。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

3.那會不會是JNI Memory釋放了,但是記憶體沒有回收呢?

Linux作業系統預設使用的記憶體配置設定器是ptmalloc2,該記憶體配置設定器内部有一個記憶體池。當Inflater釋放記憶體時,ptmalloc2會不會緩存了這部分空閑記憶體?

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

查閱了較多資料,發現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

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

确實存在大量的64M記憶體占用的記憶體塊,把占用的實體記憶體(Rss)統計一下,一共約3.4G左右

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

随意選取幾個64M記憶體的位址,dump出記憶體

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題
【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

執行strings 103.dump将記憶體裡的資料轉為string字元串

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

通過列印記憶體中的資料發現,有大量的log、鍊路的資料,有的資料甚至是幾個月前的,可見曆史積壓了很久。

2.11 手動釋放ptmalloc記憶體

ptmalloc2因為自身設計的原因,在高并發情況下會存在大量記憶體碎片不釋放,通過調用 malloc_trim() 函數可以整理malloc記憶體池中的記憶體塊,合并、釋放空閑記憶體,可以簡單了解為JVM 的GC。

多次執行;gdb --batch --pid 1904 --ex 'call malloc_trim()'

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

效果非常明顯,立馬釋放出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%左右了

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

(2) 修改參數

bash複制代碼export MALLOC_MMAP_THRESHOLD_=131072 
export MALLOC_TRIM_THRESHOLD_=131072 
export MALLOC_TOP_PAD_=131072 
export MALLOC_MMAP_MAX_=65536
           

結果:觀察數日,上面的參數還是有效果的,雖然還是會降低到10%以下,但是總體還是在13%左右

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

3.【推薦,效果明細】替換記憶體配置設定器為google的tcmalloc或facebook的jemalloc

将記憶體配置設定器的庫打包到項目部署包中,再用export LD_PRELOAD指定函數庫

結果:記憶體使用量非常穩定,長期都是40%多。由7台機器縮減為4台,也足夠支撐現有資料。

【JVM案例篇】堆外記憶體(JNI Memory)洩漏 經典的Linux 64M記憶體塊問題

四、補充說明

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

繼續閱讀