Spring Boot引起的“內(nèi)存泄漏”。。。
點擊上方[全棧開發(fā)者社區(qū)]→右上角[...]→[設為星標?
點擊領取全棧資料:全棧資料
背景
為了更好地實現(xiàn)對項目的管理,我們將組內(nèi)一個項目遷移到 MDP 框架(基于 SpringBoot),隨后我們就發(fā)現(xiàn)系統(tǒng)會頻繁報出 Swap 區(qū)域使用量過高的異常。
筆者被叫去幫忙查看原因,發(fā)現(xiàn)配置了 4G 堆內(nèi)內(nèi)存,但是實際使用的物理內(nèi)存竟然高達 7G,確實不正常。
“-XX:MetaspaceSize=256M?-XX:MaxMetaspaceSize=256M?-XX:+AlwaysPreTouch?-XX:ReservedCodeCacheSize=128m?-XX:InitialCodeCacheSize=128m,?-Xss512k?-Xmx4g?-Xms4g,-XX:+UseG1GC?-XX:G1HeapRegionSize=4M”
實際使用的物理內(nèi)存如下圖所示:

top 命令顯示的內(nèi)存情況
排查過程
| 使用 Java 層面的工具定位內(nèi)存區(qū)域
堆內(nèi)內(nèi)存、Code 區(qū)域或者使用 unsafe.allocateMemory 和 DirectByteBuffer 申請的堆外內(nèi)存。

jcmd 顯示的內(nèi)存情況
發(fā)現(xiàn)命令顯示的 committed 的內(nèi)存小于物理內(nèi)存,因為 jcmd 命令顯示的內(nèi)存包含堆內(nèi)內(nèi)存、Code 區(qū)域、通過 unsafe.allocateMemory 和 DirectByteBuffer 申請的內(nèi)存,但是不包含其他 Native Code(C 代碼)申請的堆外內(nèi)存。
所以猜測是使用 Native Code 申請內(nèi)存所導致的問題。

pmap 顯示的內(nèi)存情況
| 使用系統(tǒng)層面的工具定位堆外內(nèi)存
因為筆者已經(jīng)基本上確定是 Native Code 所引起,而 Java 層面的工具不便于排查此類問題,只能使用系統(tǒng)層面的工具去定位問題。
首先,使用了 gperftools 去定位問題,gperftools 的使用方法可以參考 gperftools:
https://github.com/gperftools/gperftools
gperftools 的監(jiān)控如下:

gperftools 監(jiān)控
從上圖可以看出:使用 malloc 申請的的內(nèi)存最高到 3G 之后就釋放了,之后始終維持在 700M-800M。
筆者第一反應是:難道 Native Code 中沒有使用 malloc 申請,直接使用 mmap/brk 申請的?(gperftools 原理就使用動態(tài)鏈接的方式替換了操作系統(tǒng)默認的內(nèi)存分配器(glibc)。)
然后,使用 strace 去追蹤系統(tǒng)調(diào)用。因為使用 gperftools 沒有追蹤到這些內(nèi)存,于是直接使用命令“strace -f -e”brk,mmap,munmap” -p pid”追蹤向 OS 申請內(nèi)存請求,但是并沒有發(fā)現(xiàn)有可疑內(nèi)存申請。
strace 監(jiān)控如下圖所示:

strace 監(jiān)控
接著,使用 GDB 去 dump 可疑內(nèi)存,因為使用 strace 沒有追蹤到可疑內(nèi)存申請;于是想著看看內(nèi)存中的情況。
就是直接使用命令 gdp -pid pid 進入 GDB 之后,然后使用命令 dump memory mem.bin startAddress endAddressdump 內(nèi)存,其中 startAddress 和 endAddress 可以從 /proc/pid/smaps 中查找。
然后使用 strings mem.bin 查看 dump 的內(nèi)容,如下:

從內(nèi)容上來看,像是解壓后的 JAR 包信息。讀取 JAR 包信息應該是在項目啟動的時候,那么在項目啟動之后使用 strace 作用就不是很大了。所以應該在項目啟動的時候使用 strace,而不是啟動完成之后。
再次,項目啟動時使用 strace 去追蹤系統(tǒng)調(diào)用,項目啟動使用 strace 追蹤系統(tǒng)調(diào)用,發(fā)現(xiàn)確實申請了很多 64M 的內(nèi)存空間。
截圖如下:

strace 監(jiān)控

strace 申請內(nèi)容對應的 pmap 地址空間
最后,使用 jstack 去查看對應的線程,因為 strace 命令中已經(jīng)顯示申請內(nèi)存的線程 ID。
直接使用命令 jstack pid 去查看線程棧,找到對應的線程棧(注意 10 進制和 16 進制轉換)如下:

strace 申請空間的線程棧
這里基本上就可以看出問題來了:MCC(美團統(tǒng)一配置中心)使用了 Reflections 進行掃包,底層使用了 SpringBoot 去加載 JAR。

btrace 追蹤棧
然后查看使用 MCC 的地方,發(fā)現(xiàn)沒有配置掃包路徑,默認是掃描所有的包。于是修改代碼,配置掃包路徑,發(fā)布上線后內(nèi)存問題解決。
| 為什么堆外內(nèi)存沒有釋放掉呢?
雖然問題已經(jīng)解決了,但是有幾個疑問:
為什么使用舊的框架沒有問題?
為什么堆外內(nèi)存沒有釋放?
為什么內(nèi)存大小都是 64M,JAR 大小不可能這么大,而且都是一樣大?
為什么 gperftools 最終顯示使用的的內(nèi)存大小是 700M 左右,解壓包真的沒有使用 malloc 申請內(nèi)存嗎?
帶著疑問,筆者直接看了一下 SpringBoot Loader 那一塊的源碼。發(fā)現(xiàn) SpringBoot 對 Java JDK 的 InflaterInputStream 進行了包裝并且使用了 Inflater,而 Inflater 本身用于解壓 JAR 包的需要用到堆外內(nèi)存。
而包裝之后的類 ZipInflaterInputStream 沒有釋放 Inflater 持有的堆外內(nèi)存。于是筆者以為找到了原因,立馬向 SpringBoot 社區(qū)反饋了這個 Bug。
但是反饋之后,筆者就發(fā)現(xiàn) Inflater 這個對象本身實現(xiàn)了 finalize 方法,在這個方法中有調(diào)用釋放堆外內(nèi)存的邏輯。也就是說 SpringBoot 依賴于 GC 釋放堆外內(nèi)存。
筆者使用 jmap 查看堆內(nèi)對象時,發(fā)現(xiàn)已經(jīng)基本上沒有 Inflater 這個對象了。于是就懷疑 GC 的時候,沒有調(diào)用 finalize。
帶著這樣的懷疑,筆者把 Inflater 進行包裝在 SpringBoot Loader 里面替換成自己包裝的 Inflater,在 finalize 進行打點監(jiān)控,結果 finalize 方法確實被調(diào)用了。
于是筆者又去看了 Inflater 對應的 C 代碼,發(fā)現(xiàn)初始化的使用了 malloc 申請內(nèi)存,end 的時候也調(diào)用了 free 去釋放內(nèi)存。
此刻,筆者只能懷疑 free 的時候沒有真正釋放內(nèi)存,便把 SpringBoot 包裝的 InflaterInputStream 替換成 Java JDK 自帶的,發(fā)現(xiàn)替換之后,內(nèi)存問題也得以解決了。
這時,再返過來看 gperftools 的內(nèi)存分布情況,發(fā)現(xiàn)使用 SpringBoot 時,內(nèi)存使用一直在增加,突然某個點內(nèi)存使用下降了好多(使用量直接由 3G 降為 700M 左右)。
這個點應該就是 GC 引起的,內(nèi)存應該釋放了,但是在操作系統(tǒng)層面并沒有看到內(nèi)存變化,那是不是沒有釋放到操作系統(tǒng),被內(nèi)存分配器持有了呢?
繼續(xù)探究,發(fā)現(xiàn)系統(tǒng)默認的內(nèi)存分配器(glibc 2.12 版本)和使用 gperftools 內(nèi)存地址分布差別很明顯,2.5G 地址使用 smaps 發(fā)現(xiàn)它是屬于 Native Stack。

gperftools 顯示的內(nèi)存地址分布
到此,基本上可以確定是內(nèi)存分配器在搗鬼;搜索了一下 glibc 64M,發(fā)現(xiàn) glibc 從 2.11 開始對每個線程引入內(nèi)存池(64 位機器大小就是 64M 內(nèi)存)。

glib 內(nèi)存池說明
按照文中所說去修改 MALLOC_ARENA_MAX 環(huán)境變量,發(fā)現(xiàn)沒什么效果。查看 tcmalloc(gperftools 使用的內(nèi)存分配器)也使用了內(nèi)存池方式。
為了驗證是內(nèi)存池搞的鬼,筆者就簡單寫個不帶內(nèi)存池的內(nèi)存分配器。
使用命令 gcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so 生成動態(tài)庫,然后使用 export LD_PRELOAD=zjbmalloc.so 替換掉 glibc 的內(nèi)存分配器。
#include
#include
#include
#include
//作者使用的64位機器,sizeof(size_t)也就是sizeof(long)?
void*?malloc?(?size_t?size?)
{
???long*?ptr?=?mmap(?0,?size?+?sizeof(long),?PROT_READ?|?PROT_WRITE,?MAP_PRIVATE?|?MAP_ANONYMOUS,?0,?0?);
???if?(ptr?==?MAP_FAILED)?{
??????return?NULL;
???}
???*ptr?=?size;?????????????????????//?First?8?bytes?contain?length.
???return?(void*)(&ptr[1]);????????//?Memory?that?is?after?length?variable
}
void?*calloc(size_t?n,?size_t?size)?{
?void*?ptr?=?malloc(n?*?size);
?if?(ptr?==?NULL)?{
????return?NULL;
?}
?memset(ptr,?0,?n?*?size);
?return?ptr;
}
void?*realloc(void?*ptr,?size_t?size)
{
?if?(size?==?0)?{
????free(ptr);
????return?NULL;
?}
?if?(ptr?==?NULL)?{
????return?malloc(size);
?}
?long?*plen?=?(long*)ptr;
?plen--;??????????????????????????//?Reach?top?of?memory
?long?len?=?*plen;
?if?(size?<=?len)?{
????return?ptr;
?}
?void*?rptr?=?malloc(size);
?if?(rptr?==?NULL)?{
????free(ptr);
????return?NULL;
?}
?rptr?=?memcpy(rptr,?ptr,?len);
?free(ptr);
?return?rptr;
}
void?free?(void*?ptr?)
{
???if?(ptr?==?NULL)?{
?????return;
???}
???long?*plen?=?(long*)ptr;
???plen--;??????????????????????????//?Reach?top?of?memory
???long?len?=?*plen;???????????????//?Read?length
???munmap((void*)plen,?len?+?sizeof(long));
}
通過在自定義分配器當中埋點可以發(fā)現(xiàn)其實程序啟動之后應用實際申請的堆外內(nèi)存始終在 700M-800M 之間,gperftools 監(jiān)控顯示內(nèi)存使用量也是在 700M-800M 左右。但是從操作系統(tǒng)角度來看進程占用的內(nèi)存差別很大(這里只是監(jiān)控堆外內(nèi)存)。

內(nèi)存測試對比
為什么自定義的 malloc 申請 800M,最終占用的物理內(nèi)存在 1.7G 呢?
因為自定義內(nèi)存分配器采用的是 mmap 分配內(nèi)存,mmap 分配內(nèi)存按需向上取整到整數(shù)個頁,所以存在著巨大的空間浪費。
通過監(jiān)控發(fā)現(xiàn)最終申請的頁面數(shù)目在 536k 個左右,那實際上向系統(tǒng)申請的內(nèi)存等于 512k * 4k(pagesize) = 2G。
為什么這個數(shù)據(jù)大于 1.7G 呢?因為操作系統(tǒng)采取的是延遲分配的方式,通過 mmap 向系統(tǒng)申請內(nèi)存的時候,系統(tǒng)僅僅返回內(nèi)存地址并沒有分配真實的物理內(nèi)存。
只有在真正使用的時候,系統(tǒng)產(chǎn)生一個缺頁中斷,然后再分配實際的物理 Page。
總結

流程圖
整個內(nèi)存分配的流程如上圖所示。MCC 掃包的默認配置是掃描所有的 JAR 包。在掃描包的時候,SpringBoot 不會主動去釋放堆外內(nèi)存,導致在掃描階段,堆外內(nèi)存占用量一直持續(xù)飆升。
當發(fā)生 GC 的時候,SpringBoot 依賴于 finalize 機制去釋放了堆外內(nèi)存;但是 glibc 為了性能考慮,并沒有真正把內(nèi)存歸返到操作系統(tǒng),而是留下來放入內(nèi)存池了,導致應用層以為發(fā)生了“內(nèi)存泄漏”。所以修改?MCC?的配置路徑為特定的?JAR?包,問題解決。
筆者在發(fā)表這篇文章時,發(fā)現(xiàn)?SpringBoot?的最新版本(2.0.5.RELEASE)已經(jīng)做了修改,在?ZipInflaterInputStream?主動釋放了堆外內(nèi)存不再依賴?GC;所以?SpringBoot 升級到最新版本,這個問題也可以得到解決。
覺得本文對你有幫助?請分享給更多人
關注「全棧開發(fā)者社區(qū)」加星標,提升全棧技能
本公眾號會不定期給大家發(fā)福利,包括送書、學習資源等,敬請期待吧!
如果感覺推送內(nèi)容不錯,不妨右下角點個在看轉發(fā)朋友圈或收藏,感謝支持。
好文章,留言、點贊、在看和分享一條龍

