↓推薦關注↓
導讀
本文記錄最近一例Java應用OOM問題的排查過程,希望可以給遇到類似問題的同學提供參考。
前言:此文記錄最近一例Java應用OOM問題的排查過程,希望可以給遇到類似問題的同學提供參考。在本地集團,大多數情況下Java堆的大小會設定為容器規格的50%~70%,但如果你設定為50%時還是遇到了OS OOM的問題,會不會無法忍受進而想要知道這是為什麼?沒錯,我也有一樣的好奇。
背景
某核心應用的負責同學反饋應用存在少量機器OOM被OS kill的問題。看sunfire監控資訊,的確如此。


初步收集到的資訊:
容器記憶體=8G,Java 11,G1 GC=4G,MaxDirectMemorySize=1G。詳見下圖:

業務同學已經做過Java dump,可以看到堆外物件幾乎沒有,堆內的使用量也不大,<3G。上機器檢視Java程序的記憶體使用量的確很大:

透過目前掌握到的資訊來看,4G(Java堆)+1G(堆外)+512M(元空間)+250M(CodeCache)+其它,離6.8G還是有不少差距,無法簡單的明確原因,需要深入排查分析了。
問題結論
省流版
中介軟體中多個不同的ClassLoader載入了多個netty的io.netty.buffer.PooledByteBufAllocator,每一個都有1G的記憶體配額,所以存在實際使用的堆外記憶體超出1G限制的問題。
透過Arthas可以看到存在這個類的7個不同的例項:

而其中rocketmq-client的這一個,已經基本用完1G的記憶體(其它幾個使用量大多在100多M的樣子):

詳細版
中介軟體中多個不同的ClassLoader載入了多個netty的io.netty.buffer.PooledByteBufAllocator,每個Allocator都用自己的計數器在限制堆外記憶體的使用量,這個限制值大多數情況下取值至MaxDirectMemorySize,所以會存在無法限制堆外記憶體使用量在1G以內的問題。(這個設計是否合理,還請中介軟體的同學幫忙補充了)
這個應用是餓了麼彈內的應用,io.netty.buffer.PooledByteBufAllocator,有7個ClassLoader載入了它,分別是:
sentinel's ModuleClassLoader、rocketmq-client's ModuleClassLoader、tair-plugin's ModuleClassLoader、hsf's ModuleClassLoader、XbootModuleClassLoader、pandora-qos-service's ModuleClassLoader、ele-enhancer's ModuleClassLoader。
相比彈內應用的4個(資料來自淘天集團的核心應用ump2,如下圖),多了3個。

在Java8,以及Java11中(JVM引數設定了-Dio.netty.tryReflectionSetAccessible=true過後),netty會直接使用unsafe的方法申請堆外記憶體,不透過Java的DirectMemory分配API,所以透過監控看不到堆外記憶體的佔用量,也不受JVM MaxDirectMemorySize的管控。
檢視DirectByteBuffer實現程式碼可以發現,它限制MaxDirectMemorySize的方法是在Java層(程式碼標記處1),實際上在JVM底層是沒有任何限制的,netty是直接用了這裡程式碼標記處2的API分配記憶體。

排查過程
1.1.透過NativeMemoryTracking看Native記憶體的佔用分佈
透過在JVM引數上加上-XX:NativeMemoryTracking=detail,就可以打印出詳細的記憶體分類的佔用資訊了,觀察了一整天,發現主要的可疑變化是在Other部分,即堆外的部分,如下圖。( Java NMT的詳細使用可以參考相應的技術文章)

明明是限制的堆外1G,怎麼超過了這麼多。再多觀察一會,發現它還會繼續緩慢上漲的,最高達到接近1.5GB。這就和最開始檢視Java程序的RSS佔用對上了。
1.2.native記憶體洩漏了嗎
JVM使用什麼native分配器
透過檢視機器上安裝的JDK的資訊,可以看到使用的是jemalloc的記憶體分配器。是不是它有洩漏、記憶體碎片、歸還不及時的問題?
網上搜索,發現的有一篇文章講的場景和我們這裡的有一些類似。(https://blog.csdn.net/liulilittle/article/details/137535634)
嘗試重新下載jemalloc的原始碼,並進行其引數的調整:
export MALLOC_CONF="dirty_decay_ms:0,muzzy_decay_ms:0"
觀察發現記憶體的佔用量有少量的下降,但還是會超過1個G,看起來核心問題不在這裡。
誰在分配記憶體
同時還透過perf工具監控了下呼叫記憶體分配的呼叫棧,想看看有什麼線索沒有,然而並沒有什麼線索。畢竟這個記憶體的增長比較緩慢,perf也不可能抓太長時間了,遂放棄這個思路。
sudo perf probe -x /opt/taobao/install/ajdk11_11.0.23.24/lib/libjemalloc.so.2 malloc
sudo perf record -e probe_libjemalloc:malloc -p `pidof java` -g — sleep 10

記憶體裡面裝了什麼
透過 sudo pmap -x `pidof java` | sort -k 3 -n 命令檢視程序的所有記憶體塊資訊,如下圖示:

排除最大的4G的這一個(這是Java堆),以及記憶體標誌帶x的兩個(可執行程式碼標誌,那是CodeCache),把其它的塊都dump下來,看看裡面都放了啥,有沒有什麼不平凡的。
使用gdb命令:gdb –batch –pid `pidof java` -ex "dump memory mem1.log 0x7f0109800000 0x7f0109800000+0x200000"
然後將dump下的記憶體以字串的方式輸出觀察下:cat mem1.log | strings


如圖所示,發現裡面大量的內容都和RocketMQ有關。不過我發現我早率了,這些dump內容我看了快一天,根本沒有發現什麼不太對的地方,看起來都是正常的佔用。(不過明顯能看出來這裡面存了一堆消費者資訊,表達的比較冗餘)
求助JVM專家
還真是從入門到放棄,到這個時候已經沒啥信心啦。遂求助於JVM的專家毛亮,他給了大的方向,一是這裡不太可能有native的記憶體洩漏,二是既然懷疑是堆外,把堆外記憶體減少一點看看情況,明確下是不是native記憶體分配器的回收特性就是這樣。往往native的記憶體分配器都有自己的管理策略,他會有自己的回收拐點,比應用看到的高一點是合理的。
的確,那麼接下來的策略就是把MaxDirectMemeorySize調低到512M觀察下效果吧。
1.3.堆外記憶體調小影響業務了
在堆外記憶體從1G調小到512M過後,過了個週末,週一的時候業務同學就反饋,調小遇到問題了,存在MQ訊息消費不及時而導致訊息擠壓的問題。結合之前看到的native記憶體的資訊,突然想到,MQ客戶端一定是佔用了超過512M的記憶體,內心裡出現了兩個問題:
1.MQ底層依賴netty,那麼netty實際使用的記憶體是多少?以及這個記憶體佔用量和native的堆外佔用量是什麼關係?
2.為啥Java的DirectMemory佔用這麼少,netty的記憶體佔用似乎並沒有被看到,這是怎麼回事?
帶著這兩個問題,查看了netty記憶體管理的核心類 io.netty.buffer.PooledByteBufAllocator,以及機器上啟動過程中打印出的資訊。

結合這裡面涉及的另一個核心類io.netty.util.internal.PlatformDependent,大概明白了這裡面的邏輯,netty是直接使用(是有前提條件的,但這個應用透過JVM引數[-Dio.netty.tryReflectionSetAccessible=true]開啟了這個特性,這也是大多數應用上面的行為)UNSAFE.allocateMemory分配記憶體,完全繞過Java的直接記憶體API。然後它自己實現了記憶體佔用空間的限制,這個值等於JVM引數中的MaxDirectMemorySize。到這裡,似乎發現了曙光,莫非就是netty?(netty這麼做的原因是為了不依賴JVM機制而加速記憶體的釋放,同時也是為了解決在堆外記憶體不足時JVM的糟糕的回收機制設計。)
1.4.Netty到底佔用了多少記憶體
好在netty的類中有一個靜態變數是可以很容易的看到這個資訊的:
io.netty.buffer.PooledByteBufAllocator#DEFAULT。
那麼這個時候就是需要上機器去執行它了。Arthas是個不錯的工具,可以直接在機器執行表示式看任何靜態變數的值,並不需要我們改程式碼然後去呼叫上面的物件做日誌列印。
登入機器後,透過命令查詢netty Allocator的類定義:
sc -d io.netty.buffer.PooledByteBufAllocator

發現有不止一個Allocator,來自於不同的ClassLoader,以及不同的jar包。一共有7個。
然後一個一個的看他們實際佔用的大小:
getstatic -c d5bc00 io.netty.buffer.PooledByteBufAllocator DEFAULT


然後把他們佔用的記憶體逐項加起來,發現的確超過了1G,同時和前面透過NMT看到的Other類別的記憶體大小是比較吻合的。到這裡大概就明確具體是怎麼回事了,記憶體是netty用掉的。
1.5.業務應該怎麼做呢
到目前為此,問題是明確了,但似乎並沒有什麼太好的解法。一個是rocketmq-client的記憶體佔用是不是太大了,有沒有什麼可以最佳化的地方?(從前面看native記憶體看到的內容來看,還是有很大的最佳化空間的,一大堆地址資訊都是以字串的形式寫在記憶體裡面),另一個是中介軟體的調整肯定是長期的,短期業務要怎麼辦呢?
思考再三,短期來看只能是先讓業務把Java堆調小(透過Java dump以及JVM監控可以看出來堆的使用率並不高),來適應當前的現狀了。
至於堆外記憶體大小沒有限制住的問題,我感覺並不是中介軟體同學的預期之中的,這塊後面也找相關同學聊一聊。
後記
以後排查Java堆外記憶體過大的問題,優先看netty的佔用。

線上閱讀:https://talk.gitee.com/report/china-open-source-2024-annual-report.pdf