Categories
程式開發

一次Java 進程OOM 的排查分析(glibc 篇)


遇到了一個glibc 導致的內存回收問題,查找原因和實驗的的過程是比較有意思的,主要會涉及到下面這些:

Linux 中典型的大量64M 內存區域問題glibc 的內存分配器ptmalloc2 的底層原理如何寫一個自定義的malloc hook 動態鏈接庫soglibc 的內存分配原理(Arena、Chunk 結構、bins 等)malloc_trim 對內存真正回收的影響gdb 的heap 調試工具使用jemalloc 庫的介紹與應用

背景

前段時間有同學反饋一個java RPC 項目在容器中啟動完沒多久就因為容器內存超過配額1500M 被殺,我幫忙一起看了一下。

一次Java 進程OOM 的排查分析(glibc 篇) 1

在本地Linux 環境中跑了一下,JVM 啟動完通過top 看到的RES 內存就已經超過了1.5G,如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 2

首先想到查看內存的分佈情況,使用arthas 是一個不錯的選擇,輸入dashboard 查看當前的內存使用情況,如下所示。

一次Java 進程OOM 的排查分析(glibc 篇) 3

可以看到發現進程佔用的堆內存只有300M 左右,非堆(non-heap)也很小,加起來才500 M 左右,那內存被誰消耗了。這就要看看JVM 內存的幾個組成部分了。

JVM 的內存都耗在哪裡

JVM 的內存大概分為下面這幾個部分

堆(Heap):eden、metaspace、old 區域等線程棧(Thread Stack):每個線程棧預留1M 的線程棧大小非堆(Non-heap):包括code_cache、metaspace 等堆外內存:unsafe.allocateMemory和DirectByteBuffer申請的堆外內存native (C/C++ 代碼)申請的內存還有JVM 運行本身需要的內存,比如GC 等。

接下來懷疑堆外內存和native 內存可能存在洩露問題。堆外內存可以通過開啟NMT(NativeMemoryTracking) 來跟踪,加上 -XX:NativeMemoryTracking=detail 再次啟動程序,也發現內存佔用值遠小於RES 內存佔用值。

因為NMT 不會追踪native (C/C++ 代碼)申請的內存,到這里基本已經懷疑是native 代碼導致的。我們項目中除了rocksdb 用到了native 的代碼就只剩下JVM 自己了。接下來繼續排查。

Linux 熟悉的64M 內存問題

使用pmap -x 查看內存的分佈,發現有大量的64M 左右的內存區域,如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 4

這個現象太熟悉了,這不是linux glibc 中經典的64M 內存問題嗎?

ptmalloc2 與arena

Linux 中malloc 的早期版本是由Doug Lea 實現的,它有一個嚴重問題是內存分配只有一個分配區(arena),每次分配內存都要對分配區加鎖,分配完釋放鎖,導致多線程下並發申請釋放內存鎖的競爭激烈。 arena 單詞的字面意思是「舞台;競技場」,可能就是內存分配庫表演的主戰場的意思吧。

於是修修補補又一個版本,你不是多線程鎖競爭厲害嗎,那我多開幾個arena,鎖競爭的情況自然會好轉。

Wolfram Gloger 在Doug Lea 的基礎上改進使得Glibc 的malloc 可以支持多線程,這就是ptmalloc2。在只有一個分配區的基礎上,增加了非主分配區(non main arena),主分配區只有一個,非主分配可以有很多個,具體個數後面會說。

當調用malloc 分配內存的時候,會先查看當前線程私有變量中是否已經存在一個分配區arena。如果存在,則嘗試會對這個arena 加鎖

如果加鎖成功,則會使用這個分配區分配內存如果加鎖失敗,說明有其它線程正在使用,則遍歷arena 列表尋找沒有加鎖的arena 區域,如果找到則用這個arena 區域分配內存。

主分配區可以使用brk 和mmap 兩種方式申請虛擬內存,非主分配區只能mmap。 glibc 每次申請的虛擬內存區塊大小是 64MB,glibc 再根據應用需要切割為小塊零售。

這就是linux 進程內存分佈中典型的64M 問題,那有多少個這樣的區域呢?在64 位系統下,這個值等於 8 * number of cores,如果是4 核,則最多有32 個64M 大小的內存區域。

難道是因為arena 數量太多了導致的?

設置MALLOC_ARENA_MAX=1 有用嗎?

加上這個環境變量啟動java 進程,確實64M 的內存區域就不見了,但是集中到了一個大的接近700M 的內存區域中,如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 5

是誰在分配釋放內存

接下來,寫一個自定義的malloc 函數hook。 hook 實際上就是利用LD_PRELOAD 環境變量替換glibc 中的函數實現,在malloc、free、realloc、calloc 這幾個函數調用前先打印日誌然後再調用實際的方法。以malloc 函數的hook 為例,部分代碼如下所示。

// 获取线程 id 而不是 pid
static pid_t gettid() {
return syscall(__NR_gettid);
}
static void *(*real_realloc)(void *ptr, size_t size) = 0;

void *malloc(size_t size) {
void *p;
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
if (!real_malloc) return NULL;
}
p = real_malloc(size);
printLog("[0x%08x] malloc(%u)t= 0x%08x ", GETRET(), size, p);
return p;
}

設置LD_PRELOAD 啟動JVM

LD_PRELOAD=/app/my_malloc.so java -Xms -Xmx -jar ....

在JVM 啟動的過程中同時開啟jstack 打印線程堆棧,當jvm 進程完全啟動以後,查看malloc 的輸出日誌和jstack 的日誌。

這裡輸出了一個幾十M 的malloc 日誌,內容如下所示。日誌的第一列是線程id。

一次Java 進程OOM 的排查分析(glibc 篇) 6

使用awk 處理上的日誌,統計線程處理的次數。

cat malloc.log | awk '{print $1}' | less| sort | uniq -c | sort -rn | less

284881 16342
135 16341
57 16349
16 16346
10 16345
9 16351
9 16350
6 16343
5 16348
4 16347
1 16352
1 16344

可以看到線程16342 分配釋放內存最為凶殘,那這個線程在做什麼呢?在jstack 的輸出日誌中搜索16342(0x3fd6)線程,可以看到很多次都在處理jar 包的解壓。

一次Java 進程OOM 的排查分析(glibc 篇) 7

java 處理zip 使用的是java.util.zip.Inflater 類,調用它的end 方法會釋放native 的內存。看到這裡我以為是end 方法沒有調用導致的,這種的確是有可能的,java.util.zip.InflaterInputStream 類的close 方法在一些場景下是不會調用Inflater.end 方法,如下所示。

一次Java 進程OOM 的排查分析(glibc 篇) 8

高興的有點早了。實際上並非如此,就算上層調用沒有調用Inflater.end,Inflater 類的finalize 方法也調用了end 方法,我強行GC 試一下。

jcmd `pidof java` GC.run

通過GC 日誌確認確實觸發了FullGC,但內存並沒有降下來。通過valgrind 等工具查看內存洩露,也沒有什麼發現。

如果說JVM 本身的實現沒有內存洩露,那就是glibc 自己的問題了,調用free 把內存還給了glibc,glibc 並沒有最終釋放,這個內存二道販子自己把內存截胡了。

glibc 的內存分配原理

這是一個很複雜的話題,如果這一塊完全不熟悉,建議你先看看下面這幾個資料。

了解glibc malloc“淘寶華庭大師的《Glibc 內存管理- Ptmalloc2 源代碼分析“》

總體來看,需要理解下面這幾個概念:

內存分配區Arena內存chunk空閒chunk 的回收站(bins)

內存分配區Arena

內存分配區Arena 的概念在前面介紹過,也比較簡單。為了更直觀的了解heap 的內部結構,可以使用gdb 的heap 擴展包,比較常見的有:

libheapngpwndbg

這些也是打CTF 堆相關的題目可以使用的工具,接下來使用的是Pwngdb 工具來介紹。輸入arenainfo 可以查看Arena 的列表,如下所示。

一次Java 進程OOM 的排查分析(glibc 篇) 9

在這個例子中,有1 個主分配區Arena 和15 個非主分配區Arena。

內存chunk 的結構

chunk 的概念也比較好理解,chunk 的字面意思是「大塊」,是面向用戶而言的,用戶申請分配的內存用chunk 表示。

可能這樣說還是不好理解,下面一個實際的例子來說明。

#include
#include
#include

int main(void) {
void *p;

p = malloc(1024);
printf("%pn", p);

p = malloc(1024);
printf("%pn", p);

p = malloc(1024);
printf("%pn", p);

getchar();
return (EXIT_SUCCESS);
}

這段代碼分配了三次1k 大小的內存,內存地址是:

./malloc_test

0x602010
0x602420
0x602830

pmap 輸出的結果如下所示。

一次Java 進程OOM 的排查分析(glibc 篇) 10

可以看到第一次分配的內存區域地址0x602010 在這塊內存區域的基址(0x602000)偏移量16(0x10) 的地方。

再來看第二次分配的內存區域地址0x602420 與0x602010 的差值是1,040 = 1024 + 16(0x10)

第三次分配的內存以此類推是一樣的,每次都空了0x10 個字節。這中間空出來的0x10 是什麼呢?

使用gdb 查看一下就很清楚了,查看這三個內存地址往前0x10 字節開始的32 字節區域。

一次Java 進程OOM 的排查分析(glibc 篇) 11

可以看到實際上存儲的是0x0411,

0x0411 = 1024(0x0400) + 0x10(block size) + 0x01

其中1024 很明顯,是用戶申請的內存區域大小,0x11是什麼?因為內存分配都會對齊,實際上最低3 位對內存大小沒有什麼意義,最低3 位被借用來表示特殊含義。一個使用中的chunk 結構如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 12

最低三位的含義如下:

A:表示該chunk 屬於主分配區或者非主分配區,如果屬於非主分配區,將該位置為1,否則置為0M:表示當前chunk 是從哪個內存區域獲得的內存。 M 為1 表示該chunk 是從mmap 映射區域分配的,否則是從heap 區域分配的P:表示前一個塊是否在使用中,P 為0 則表示前一個chunk 為空閒,這時chunk 的第一個域prev_size 才有效

這個例子中最低三位是b001,A = 0表示這個chunk 不屬於主分配區,M = 0,表示是從heap 區域分配的,P = 1 表示前一個chunk 在使用中。

從glibc 源碼中可以看的更清楚一些。

#define PREV_INUSE 0x1
/* extract inuse bit of previous chunk */
#define prev_inuse(p) ((p)->size & PREV_INUSE)

#define IS_MMAPPED 0x2
/* check for mmap()'ed chunk */
#define chunk_is_mmapped(p) ((p)->size & IS_MMAPPED)

#define NON_MAIN_ARENA 0x4
/* check for chunk from non-main arena */
#define chunk_non_main_arena(p) ((p)->size & NON_MAIN_ARENA)

#define SIZE_BITS (PREV_INUSE|IS_MMAPPED|NON_MAIN_ARENA)
/* Get size, ignoring use bits */
#define chunksize(p) ((p)->size & ~(SIZE_BITS))

前面介紹的是allocatd chunk 的結構,被free 以後的空閒chunk 的結構不太一樣,還有一個稱為top chunk 的結構,這裡不再展開。

chunk 的回收站bins

bin 的字面意思是「垃圾箱」。內存在應用調用free 釋放以後chunk 不一定會立刻歸還給系統,而是就被glibc 這個二道販子截胡。這也是為了效率的考量,當用戶下次請求分配內存時,ptmalloc2 會先嘗試從空閒chunk 內存池中找到一個合適的內存區域返回給應用,這樣就避免了頻繁的brk、mmap 系統調用。

為了更高效的管理內存分配和回收,ptmalloc2 中使用了一個數組,維護了128 個bins。

一次Java 進程OOM 的排查分析(glibc 篇) 13

這些bin 的介紹如下。

bin0 目前沒有使用bin1 是unsorted bin,主要用於存放剛剛釋放的chunk 堆塊以及大堆塊分配後剩餘的堆塊,大小沒有限制bin2~bin63 是small bins,用於維護1024B 的堆塊,同一條鍊錶中的堆塊大小不一定相同,具體的規則不是本篇介紹的重點,不再展開。

具體到本例中,在Pwngdb 中可以查看每個arena 的bins 信息。如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 14

Fastbin

一般情況下,程序在運行過程中會頻繁和分配一些小的內存,如果這些小內存被頻繁的合併和切割,效率會比較低下,因此ptmalloc 在除了上面的bin 組成部分,還有一個非常重要的結構fastbin,專門用來管理小的內存堆塊。

64 位系統中,不大於128 字節的內存堆塊被釋放以後,首先會被放到fastbin 中,fastbin 中的chunk 的P 標記始終為1,fastbin 的堆塊會被當做使用中,因此不會被合併。

在分配小於128 字節的內存時,ptmalloc 會首先在fastbin 中查找對應的空閒塊,如果沒有才去其它bins 中查找。

換個角度來看,fastbin 可以看做是smallbin 的一道緩存。

內存碎片與回收

接下來我們來做一個實驗,看看內存碎片如何影響glibc 的內存回收,代碼如下所示。

#include
#include
#include

#define K (1024)
#define MAXNUM 500000

int main() {

char *ptrs[MAXNUM];
int i;
// malloc large block memory
for (i = 0; i < MAXNUM; ++i) { ptrs[i] = (char *)malloc(1 * K); memset(ptrs[i], 0, 1 * K); } //never free,only 1B memory leak, what it will impact to the system? char *tmp1 = (char *)malloc(1); memset(tmp1, 0, 1); printf("%sn", "malloc done"); getchar(); printf("%sn", "start free memory"); for(i = 0; i < MAXNUM; ++i) { free(ptrs[i]); } printf("%sn", "free done"); getchar(); return 0; }

程序中先malloc 了一塊500M 的內存,然後再malloc 了1B 的內存(實際上比1B 要大一點,不過不影響說明),接下來free 掉那500M 的內存。

在free 之前的內存佔用如下所示。

一次Java 進程OOM 的排查分析(glibc 篇) 15

在調用free 以後,使用top 查看RES 的結果如下。

一次Java 進程OOM 的排查分析(glibc 篇) 16

可以看到實際上glibc 並沒有把內存歸還給系統。而是放到了它自己的unsorted bin 中,使用gdb 的arenainfo 工具可以看得很清楚。

一次Java 進程OOM 的排查分析(glibc 篇) 17

0x1efe9200 用十進製表示是520,000,000,正是我們剛剛釋放的500M 左右的內存。

如果我把代碼中的第二次malloc 註釋掉,glibc 是可以立刻釋放內存的。

一次Java 進程OOM 的排查分析(glibc 篇) 18

這個實驗已經比較能證明內存碎片對glibc 內存消耗的影響了。

glibc 與malloc_trim

glibc 中提供了malloc_trim 函數,文檔內容"在這裡。

從文檔來看,應該只是歸還堆頂上全部的空餘內存給系統,沒有辦法歸還堆頂內存中的空洞。但是實際上並非如此,在本例中,調用malloc_trim 真正歸還了500M 以上的內存給系統。

gdb --batch --pid `pidof java` --ex 'call malloc_trim()'

一次Java 進程OOM 的排查分析(glibc 篇) 19

看glibc 的源碼,malloc_trim 的底層實現已經做了修改,是遍歷所有的arena,然後對每個arena 遍歷所有的bin,執行madvise 系統調用告知MADV_DONTNEED,通知內核這塊可以回收了。

一次Java 進程OOM 的排查分析(glibc 篇) 20

通過Systemtap 腳本可以同步確認這一點。

probe begin {
log("begin to proben")
}

probe kernel.function("SYSC_madvise") {
if (ppid() == target()) {
printf("nin %s: %sn", probefunc(), $$vars)
print_backtrace();
}
}

執行 malloc_trim 時,有大量的madvise 系統調用,如下圖所示。

一次Java 進程OOM 的排查分析(glibc 篇) 21

這裡的behavior=0x4 表示是MADV_DONTNEED,len_in 表示長度,start 表示內存開始地址。

malloc_trim 對前一個小節中的內存碎片實驗同樣是生效的。

jemalloc 登場

既然是因為glibc 的內存分配策略導致的碎片化內存回收問題,導致看起來像是內存洩露,那有沒有更好一點的對碎片化內存的malloc 庫呢?業界常見的有google 家的tcmalloc 和facebook 家的jemalloc。

這兩個我都試過,jemalloc 的效果比較明顯,使用LD_PRELOAD 掛載jemalloc 庫。

LD_PRELOAD=/usr/local/lib/libjemalloc.so

重新啟動Java 程序,可以看到內存RES 消耗降低到了1G 左右

一次Java 進程OOM 的排查分析(glibc 篇) 22

使用jemalloc 比glibc 小了500M 左右,只比malloc_trim 的900 多M 多了一點點。

至於為什麼jemalloc 在這個場景這麼厲害,又是一個複雜的話題,這裡先不展開,有時間可以詳細介紹一下jemalloc 的實現原理。

經多次實驗,malloc_trim 有概率會導致JVM Crash,使用的時候需要小心。

经过替换 ptmalloc2 为 jemalloc,进程的内存 RES 占用显著下降,至于性能、稳定性还需进一步观察。

番外篇

最近也在寫一個簡單的malloc 庫,可能真正寫過才知道tcmalloc、jemalloc 到底想解決什麼痛點,複雜設計的背後的權衡是如何做的。

小結

內存相關的問題是相對而言比較複雜的,影響的因素很多,如果排除是應用層自己的問題,那是最簡單的,如果是glibc 或者內核本身的問題,那就只能通過大膽假設,一點點驗證了。關於內存的分配和管理是一個比較複雜的話題,希望可以在後面的文章中再詳細介紹介紹。

上面的內容可能都是錯的,看看思路就好。

作者:挖坑的張師傅出處:https://club.perfma.com/article/1709425

希望可以對大家有幫助,喜歡的小伙伴可以關注公眾號:小遷不禿頭,每天不定時更新文章~