記一次堆外記憶體洩漏分析

翎野君發表於2024-06-03

原文連結

https://blog.csdn.net/zhuqiuhui/article/details/128513480

1. 背景
系統上線前壓測發現某個圖片分類服務頻繁呼叫時出現:“記憶體一直不斷上漲,到一段時間後逐漸平穩”,記憶體如下圖所示:

該介面內部邏輯比較簡單(示例程式碼如下):

Step 1:先將圖片 resize 成演算法要求的尺寸 224x224x3
Step 2:構造演算法分類引數
Step 3:演算法分類並返回結果

/**
* 功能:對圖片對進行分類
* imageUrl:圖片 url, 如 http://alicdn.imag.cn/i2/fdsafsdafsfs.png
**/
public Boolean isSizeImage(String imageUrl) {
  // 1. 對圖片進行 resize
  float[] content = resize(imageUrl);
  
  // 2. 構造圖片分類引數
  ImageClsRequest request = buildImageClsRequest(content);
  
  // 3. 圖片分類
  return algorithmClient.predict(request);
}

private float[] resize(String imageUrl){
  // 讀取圖片
  BufferedImage image = ImageIO.read(imageUrl);
  // 對圖片進行 resize
  BufferedImage newImage = new BufferedImage(..);
  // 圖片進行重新繪製
  float[] content = new float[224*224*3];
  //.....
  return content;
}

圖片的處理很容易產生記憶體洩漏的問題,透過系統監控發現負載、cpu、jvm 堆記憶體隨著該介面的呼叫結束而恢復正常,GC頻率和範圍也在正常範圍內,唯有記憶體上漲後未進行回收, 難道是堆外記憶體產生了洩漏?

2. JVM 記憶體分佈與分析
2.1 JVM 記憶體分佈

Metaspace:儲存被虛擬機器載入的型別資訊(Java8將方法區的型別資訊遷移到 metaspace)。

程式計數器:當前執行緒所執行的位元組碼的行號指示器,各執行緒之間計數器互不影響,獨立儲存。

本地方法棧: 作用與虛擬機器棧發揮作用類似,本地方法棧是為虛擬機器使用的 native 方法服務。

可以認為是 Native 方法相當於 C/C++ 暴露給 Java 的一個介面,Java 透過呼叫這個介面從而呼叫到 C/C++ 方法。當執行緒呼叫 Java 方法時,虛擬機器會建立一個棧幀並壓入 Java 虛擬機器棧。然而當它呼叫的是 native 方法時,虛擬機器會保持 Java 虛擬機器棧不變,也不會向 Java 虛擬機器棧中壓入新的棧幀,虛擬機器只是簡單地動態連線並直接呼叫指定的 native 方法(如下圖)。

Code Cache: JVM 將位元組碼編譯成彙編指令時,將這些指令儲存在一個稱為 Code Cache 的特殊非堆資料區域中。可使用-XX:InitialCodeCacheSize (預設160KB)和 -XX:ReservedCodeCacheSize (預設48MB)調整初始值和最大值。

Thread Stack: 所有執行緒分配的記憶體大小,可使用 -Xss 調整,64位作業系統中預設1MB。

GC演算法使用的堆空間和 Compiler 自身操作需要的空間

Internal: 如命令列解析器使用的記憶體、JVMTI等。

Direct Memory(直接記憶體): jdk1.4 引入NIO,NIO 使用Native 函式庫直接分配堆外記憶體,然後透過一個儲存在 Java 堆裡面的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作,這樣能避免在 Java 堆和 Native 堆中來回複製資料。直接記憶體受本地總記憶體大小以及處理器定址空間的限制(可使用 -XX:MaxDirectMemorySize 配置最大記憶體)。

Mapped Memory: 透過系統呼叫 mmap 函式將某個檔案對映到記憶體中,真正分配在作業系統核心,使用場景如 java 的零複製,將減少使用者態和核心態的資料複製次數。

JNI Memory: 透過 java JNI 呼叫的 native 方法分配的記憶體。

附:透過 JVM NMT(native memory tracking)來追蹤分析堆外記憶體,只能 Track JVM 自身的記憶體分配,第三方的 Native 庫記憶體無法 track,不能 Trace JNI 裡直接呼叫 malloc 時的記憶體分配,典型場景如 ZipInputStream 場景。

2.2 堆外記憶體洩漏分析思路

2.3 伺服器 JVM 引數配置及實際記憶體分佈

透過檢視機器 jvm 引數如下(機器4c8g):

-Xms4g -Xmx4g #初始堆記憶體與最大堆記憶體4GB
-Xmn2g -XX:SurvivorRatio=10 #年輕代大小2GB,Eden 區與 Survivor 區的大小比值為10,即 Eden 區 1706.75MB,兩個 Survior 各 170.625MB
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m #元空間256MB,最大512MB
-XX:MaxDirectMemorySize=1g  #直接記憶體大小 1GB
-XX:+UseConcMarkSweepGC  # 使用CMS作為老年代垃圾回收器

其中 NewRatio 的預設值是 2,即年輕的和老年代的記憶體比例1:2,但實際上不一定生效。記憶體實際分佈圖如下圖:

透過 jmap 命令檢視(會自動觸發一次 full gc,慎用)記憶體分佈如下:

2.4 JVM native 記憶體檢視
JVM 具有 Native Memory Tracking 功能,透過 JVM 加上啟動引數 -XX:NativeMemoryTracking=detail 可以統計記憶體使用詳細資訊。透過以下命令檢視 jvm native 記憶體如下(NMT 的 committed 實際並非都是 JVM 程序已經佔用的記憶體,而是系統分配了這麼多記憶體):

jcmd 1942 VM.native_memory detail scale=MB

由上圖看NMT 並無監控到大量的 native 記憶體分配的資訊( 透過這點可知,NMT 只能追蹤到部分 navtive 記憶體,追蹤不到如直接記憶體等分佈)。

2.5 手動觸發 Full GC
手動 Full GC 觸發前後機器記憶體並無發生變化,且堆記憶體表現正常,附常用 Full GC 方式:

 jcmd pid GC.run  # 方式一
 jmap -histo:live <pid> # 方式二 
 jmap -dump:live,file=dump_001.bin PID  # 方式三(jvm在執行該命令前會自動進行一次 full gc)

這時初步猜測: jvm 層面的監控與作業系統層面的監控存在差異,難道是 jvm 已經做回收了,而作業系統層面並無實質釋放?

3. 問題排查經歷
3.1 定位記憶體洩漏的位置及初步猜想
1)定位 RES 區域存在記憶體洩漏
找到 java 程序發現:RES 區域的記憶體大小(5GB)明顯多於正常機器(2.7G),經過計算 RES 區域多的 2.3G 剛好是記憶體上漲的 29%(因為機器標準是 8G 記憶體,2.3G/8G=28.75%,監控上看是從37.41%上漲到66.94%)

ps -ef | grep "java"  # 找到執行的 java 服務程序,以1898為例
top -Hp 1898   # 檢視該進行下執行緒記憶體監控情況(如下圖)

這裡對 VIRT 和 RES 兩個區域做下解釋:

VIRT(virtual memory usage):程序“需要的”虛擬記憶體大小,包括程序使用的庫、程式碼、資料,以及malloc、new分配的堆空間和分配的棧空間等,另外 VIRT = SWAP + RES,其中 swap 區域是需要記憶體置換的區域(可參考作業系統虛擬記憶體原理)。
RES(resident memory usage):又稱為 RSS,是程序在 RAM 中真正佔用的記憶體大小。RES 包含了它所連結的動態庫被載入到實體記憶體中的記憶體、棧記憶體和堆記憶體。動態連結庫佔用的記憶體會被多個程序共享,所以RSS並不能準確反映單程序的記憶體佔用情況。
有人對 RES 區域做了詳細的解釋如下圖:

即 RSS 記憶體區域包括了執行緒堆疊、直接記憶體、透過mmap對映的檔案和JVM位元組碼等。詳細說明參考:https://stackoverflow.com/questions/38597965/difference-between-resident-set-size-rss-and-java-total-committed-memory-nmt

2)分析 RES 記憶體區域
pmap 命令用於顯示程序的記憶體對映關係,如下圖所示,最大塊記憶體使用了4225732KB(4.03GB,正常機器只使用了1.97GB),其中 Address 列表示記憶體的開始位置, Mapping 列表示佔用記憶體的檔案(“anon”表示匿名對映記憶體)。

匿名對映就是使用者空間需要分配一定的實體記憶體來儲存資料,這部分記憶體不屬於任何檔案,核心就使用匿名對映將記憶體中的某段實體地址與使用者空間一一對映,這樣使用者就可用直接操作虛擬地址來範圍這段實體記憶體。比如使用 malloc 申請記憶體。

pmap -x 1898 | less # 顯示程序的記憶體對映關係

經過查閱資源發現大多數 RES 區域記憶體洩漏總是是因 Linux glibc 記憶體碎片引起的,為了消除心中的疑慮,需要進一步排查是否因 glibc 原因引起。

3.2 解決 Linux glibc 記憶體碎片問題(66.94.12%下降到64.16%)
glibc 使用記憶體池為 java 應用透過 malloc 申請堆外記憶體,當 jvm 回收記憶體歸還作業系統時,作業系統在 free 的時候並不會真正釋放記憶體,而是維護到 glibc 的記憶體池中供下次使用,從而避免重複申請(當時也有個想法 jvm 和 linux 層面的記憶體監控不一致,是不是 linux 並未真正釋放記憶體),為了驗證想法,去機器上檢視使用的 glibc 庫,如下圖:

gdb --batch --pid=1898 -ex "call (int)malloc_trim(0)"

驗證結果返回1表明有記憶體釋放(0表示無記憶體釋放),說明存在記憶體最佳化空間,經查閱資料作業系統更好的記憶體管理是 jemalloc,有兩種方式進行替換(這裡使用的第二種):

方式一:官網下載 jemalloc 安裝更換 java 程式連結庫即可,實踐行得通(jemalloc 文件介紹:http://jemalloc.net/jemalloc.3.html#tuning)

sudo bash -c "sudo su"
sudo yum install git autoconf gcc make
git clone https://github.com/jemalloc/jemalloc
cd jemalloc
sh autogen.sh
./configure --enable-prof
make
make install

export LD_PRELOAD=/usr/local/lib/libjemalloc.so.2
  • 方式二:升級應用啟動的 ajdk 版本(新版本使用 jemalloc 來進行記憶體分配和釋放)
# 舊的,底層記憶體分配使用 glibc
http://***/7/x86_64/current/ali-jdk/ali-jdk-8.4.7-1519273.alios7.x86_64.rpm
# 新的,底層記憶體分配使用 jemalloc
http://***/7/x86_64/current/ajdk/ajdk-8.10.15_fp13-20210830151435.alios7.x86_64.rpm

  經過替換後,如下圖所示已經成功替換為 jemalloc:

3.3 使用 gdb dump並分析RES 記憶體區域,嘗試解決流未關閉問題,未奏效
1)安裝 gdb 並 dump 下 RES 記憶體
vim /proc/1984/smaps # 檢視分配的記憶體地址空間 6c0000000-7c1f00000
sudo yum install gdb  # 安裝 gdb
gdb attach 1984 
dump memory /tmp/fc-01.dump 0x6c0000000 0x700000000 # 分三次進行dump
dump memory /tmp/fc-02.dump 0x700000000 0x7b1f00000
dump memory /tmp/fc-03.dump 0x7b1f00000 0x7c1f00000
strings /tmp/fc-03.dump # 檢視記憶體內容

2)分析 RES 記憶體記憶體,猜測可能流未關閉

透過檢視 RES 記憶體內容(如下圖),發現裡面很多位元組流內容:

懷疑是不是在進行 http 請求時流未關閉,核心程式碼如下(左邊依賴中介軟體原始碼,右邊改造後程式碼):

使用 arthas 熱部署工具 ArthasHotSwap 修改程式碼後,再次壓測堆外記憶體上漲問題仍然存在,排除依賴http流未關閉問題。

3.4 使用 jemalloc 工具進行記憶體引用分析,並定位問題
上面探索無果後,開始深挖堆外記憶體分配原理,堆外記憶體的分配一般是透過以下兩種方式進行分配:

方式一:java.nio.ByteBuffer#allocateDirect
方式二:sun.misc.Unsafe#allocateMemory
第一種方式使用直接記憶體,從監控上在整個壓測過程只分配2MB左右,忽略不計。所以猜測大部分來自 sun.misc.Unsafe#allocateMemory 分配,為了進一步分析記憶體分配情況,以下在壓測過程中結合 jemalloc 工具來進行具體分析。

1)使用 jemalloc 工具生成記憶體引用
升級 ajdk 後便自動完成 jemalloc 的安裝,這裡增加配置每分配1MB記憶體就自動 dump 記憶體檔案,手動配置如下:

export MALLOC_CONF="prof:true,lg_prof_interval:30,lg_prof_sample:17,prof_prefix:/home/admin/fc/prof_prefix"

  

上述配置命令引數如下:

lg_prof_interval:比如值是 20 表示1MB(2^20),即程式在執行時,每分配(大約)1MB就會 dump 產生一個檔案
prof:true,表示開啟profiling
lg_prof_sample:分配樣本之間的平均間隔(以log2為基數),以分配活動的位元組數衡量。增加取樣間隔不僅會降低輪廓保真度,而且會降低計算開銷。預設取樣間隔為 512 KiB (2^19 B)。
prof_prefix:profile dump 檔名字首。如果字首設定為空字串,則不會發生自動轉儲。示例:jeprof.25851.42.i42.heap

壓測過程中生成的 dump 檔案如下:

然後對 dump 檔案轉換成 svg 圖片,再上傳到 oss 上下載到本地進行分析(如下圖所示):

yum install graphviz
jeprof --svg /***/java jeprof.25959.0.f.heap > 25959.svg # 伺服器上生成 svg 圖片

jemalloc 工具分配記憶體的方式是使用 jemalloc 系統呼叫函式來分配的物件記憶體,整個壓測過程中jemalloc 分配記憶體的大小從 220MB 上升到 863MB(上漲643MB),所以有以下結論:

sun.misc.Unsafe#allocateMemory:分配記憶體的主要來源,重點分析
com.sun.imageio.plugins.jpeg.JPEGImageReader#readImage:本身不是透過 sun.misc.Unsafe#allocateMemory 分配的記憶體,可忽略
java.util.zip.Inflater#inflateBytes :檔案壓縮使用,佔比不是特別大
2)定位 sun.misc.Unsafe#allocateMemory 上層記憶體分配函式
由於 sun.misc.Unsafe#allocateMemory 是 JNI 函式,Arthas 和 Btrace 工具都無法對其進行追蹤,只得採用本地模擬線上進行不間斷請求(另一種方式可使用 arthas 或者 btrace 對 JNI 上游函式進行追蹤),結合 JProfiler 外掛對 sun.misc.Unsafe#allocateMemory 進行 incoming refrence 分析的方式,引用關係如下圖所示:

逐一排查(檢視哪個類內部呼叫了 sun.misc.Unsafe#allocateMemory)發現以下三個引用:

sun.java2d.pipe.RenderBuffer:圖片resize時呼叫
sun.nio.ch.NativeObject:NIO執行緒分配
java.nio.DirectByteBuffer:NIO執行緒分配
透過本地 debug 方式發現每次呼叫圖片 resize 都會建立 Graphics2D ,同時會建立 GraphicsEnvironment,而在初始化GraphicsEnvironment的例項CGraphicsEnvironment時, 會建立並初始化 OGLRenderQueue,這裡一次性分配了32000 byte(約32KB,粗估算了下壓測請求共請求約 16888 次,不包含後續記憶體的寫入,該類初始化分配需要的堆外記憶體約 16888*32/1024MB = 527MB) ,原始碼如下:

GraphicsEnvironment為Java應用程式提供了特定平臺的GraphicsDevice物件和Font 物件集合。 這些GraphicsDevice可以是各種本機和遠端機器的資源,如螢幕、印表機或者是Image Buffer,甚至是Graphics2D繪圖方法的目標物件

OGL-specific implementation of RenderQueue. This class provides a single (daemon) thread that is responsible for periodically flushing
the queue, thus ensuring that only one thread communicates with the native OpenGL libraries for the entire process.

3.5 原因最終分析與解決方案
​ 經上述分析產生堆外記憶體上漲的原因是由於使用 java 原生 jdk 重新繪圖引起的,理論上不會存在程式碼漏洞,所以猜測一次請求(一個執行緒)使用的堆外記憶體大小是有限的,壓測過程中會不斷複用執行緒的上下文資訊,再加上棧幀對垃圾回收的影響(執行緒變數槽複用,垃圾回收不掉),所以得出結論:給定壓力(執行緒數)情況下,使用的堆外記憶體是一定,不存在堆外記憶體洩漏的情況,也就是說在當前壓力下,必須要使用這麼大的堆外記憶體。 後來為了驗證進行反覆壓測,發現堆外記憶體並不會隨著壓測的次數增加而上漲,而是穩定在一個固定值上。

1)程式碼顯示呼叫 Sytem.gc() 釋放記憶體,記憶體由原來的 64.16%下降到 50.11%
​ JNI 申請的記憶體分配在 jvm 堆外,不受垃圾回收器的管理,需要手動呼叫 Sytem.gc() 方法來釋放記憶體,但 java 程式碼中一般不建議直接使用 Sytem.gc() 方法來釋放記憶體,觸發 Sytem.gc() 方法會 stop the world,從而影響 java 程序正常工作,這裡可以結合 -XX:+ExplicitGCInvokesConcurrent 引數,表示可以進行併發的混合回收,這是 jvm 對 Native memory 的一個最佳化(jvm引數中不能帶 -XX:+DisableExplicitGC)

事實上顯式呼叫 System.gc ,是希望透過 Full GC 來強迫已經無用的 DirectByteMemory 物件釋放掉它們關聯的 Native Memory,HotSpot VM 只會在 Old GC 的時候才會對 Old 中的物件做 Reference Processing,而在 Young GC 時只會對 Young 裡的物件做 Reference Processing。Young 中的 DirectByteBuffer 物件會在 Young GC 時被處理,也就是說,做 CMS GC 的話會對 Old 做 Reference Processing,進而能觸發 Cleaner 對已死的 DirectByteBuffer 物件做清理工作。但如果很長一段時間裡沒做過 GC 或者只做了 Young GC 的話則不會在 Old 觸發 Cleaner 的工作,那麼就可能讓本來已經死亡,但已經晉升到 Old 的 DirectByteBuffer 關聯的 Native Memory 得不到及時釋放。這幾個實現特徵使得依賴於 System.gc 觸發 GC 來保證 DirectByteMemory 的清理工作能及時完成。

摘自美團技術部落格:Java中9種常見的CMS GC問題分析與解決:https://tech.meituan.com/2020/11/12/java-9-cms-gc.html

2)壓測穩定後,設定介面限流
為了防止過大流量耗盡機器堆外記憶體產生 OOM,所以需要對介面限流。


相關文章