溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

怎么排查Spring Boot內(nèi)存泄露

發(fā)布時間:2021-11-02 13:35:20 來源:億速云 閱讀:180 作者:iii 欄目:編程語言

本篇內(nèi)容主要講解“怎么排查Spring Boot內(nèi)存泄露”,感興趣的朋友不妨來看看。本文介紹的方法操作簡單快捷,實用性強。下面就讓小編來帶大家學(xué)習(xí)“怎么排查Spring Boot內(nèi)存泄露”吧!

為了更好地實現(xiàn)對項目的管理,我們將組內(nèi)一個項目遷移到MDP框架(基于Spring Boot),隨后我們就發(fā)現(xiàn)系統(tǒng)會頻繁報出Swap區(qū)域使用量過高的異常。筆者被叫去幫忙查看原因,發(fā)現(xiàn)配置了4G堆內(nèi)內(nèi)存,但是實際使用的物理內(nèi)存竟然高達7G,確實不正常。JVM參數(shù)配置是“-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+AlwaysPreTouch -XX:ReservedCodeCacheSize=128m -XX:InitialCodeCacheSize=128m, -Xss512k -Xmx4g -Xms4g,-XX:+UseG1GC -XX:G1HeapRegionSize=4M”,實際使用的物理內(nèi)存如下圖所示:

怎么排查Spring Boot內(nèi)存泄露

top命令顯示的內(nèi)存情況

排查過程

1. 使用Java層面的工具定位內(nèi)存區(qū)域(堆內(nèi)內(nèi)存、Code區(qū)域或者使用unsafe.allocateMemory和DirectByteBuffer申請的堆外內(nèi)存)

筆者在項目中添加-XX:NativeMemoryTracking=detailJVM參數(shù)重啟項目,使用命令jcmd pid VM.native_memory detail查看到的內(nèi)存分布如下:

怎么排查Spring Boot內(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)存所導(dǎo)致的問題。

為了防止誤判,筆者使用了pmap查看內(nèi)存分布,發(fā)現(xiàn)大量的64M的地址;而這些地址空間不在jcmd命令所給出的地址空間里面,基本上就斷定就是這些64M的內(nèi)存所導(dǎo)致。

怎么排查Spring Boot內(nèi)存泄露

pmap顯示的內(nèi)存情況

2、使用系統(tǒng)層面的工具定位堆外內(nèi)存

因為筆者已經(jīng)基本上確定是Native Code所引起,而Java層面的工具不便于排查此類問題,只能使用系統(tǒng)層面的工具去定位問題。

首先,使用了gperftools去定位問題

gperftools的使用方法可以參考gperftools,gperftools的監(jiān)控如下:

怎么排查Spring Boot內(nèi)存泄露

gperftools監(jiān)控

從上圖可以看出:使用malloc申請的的內(nèi)存最高到3G之后就釋放了,之后始終維持在700M-800M。筆者第一反應(yīng)是:難道Native Code中沒有使用malloc申請,直接使用mmap/brk申請的?(gperftools原理就使用動態(tài)鏈接的方式替換了操作系統(tǒng)默認(rèn)的內(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)控如下圖所示:

怎么排查Spring Boot內(nèi)存泄露

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)容,如下:

怎么排查Spring Boot內(nèi)存泄露

gperftools監(jiān)控

從內(nèi)容上來看,像是解壓后的JAR包信息。讀取JAR包信息應(yīng)該是在項目啟動的時候,那么在項目啟動之后使用strace作用就不是很大了。所以應(yīng)該在項目啟動的時候使用strace,而不是啟動完成之后。

再次,項目啟動時使用strace去追蹤系統(tǒng)調(diào)用

項目啟動使用strace追蹤系統(tǒng)調(diào)用,發(fā)現(xiàn)確實申請了很多64M的內(nèi)存空間,截圖如下:

怎么排查Spring Boot內(nèi)存泄露

strace監(jiān)控

使用該mmap申請的地址空間在pmap對應(yīng)如下:

怎么排查Spring Boot內(nèi)存泄露

strace申請內(nèi)容對應(yīng)的pmap地址空間

最后,使用jstack去查看對應(yīng)的線程

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

怎么排查Spring Boot內(nèi)存泄露

strace申請空間的線程棧

這里基本上就可以看出問題來了:MCC(美團統(tǒng)一配置中心)使用了Reflections進行掃包,底層使用了Spring Boot去加載JAR。因為解壓JAR使用Inflater類,需要用到堆外內(nèi)存,然后使用Btrace去追蹤這個類,棧如下:

怎么排查Spring Boot內(nèi)存泄露

btrace追蹤棧

然后查看使用MCC的地方,發(fā)現(xiàn)沒有配置掃包路徑,默認(rèn)是掃描所有的包。于是修改代碼,配置掃包路徑,發(fā)布上線后內(nèi)存問題解決。

3、為什么堆外內(nèi)存沒有釋放掉呢?

雖然問題已經(jīng)解決了,但是有幾個疑問:

  •  為什么使用舊的框架沒有問題?

  •  為什么堆外內(nèi)存沒有釋放?

  •  為什么內(nèi)存大小都是64M,JAR大小不可能這么大,而且都是一樣大?

  •  為什么gperftools最終顯示使用的的內(nèi)存大小是700M左右,解壓包真的沒有使用malloc申請內(nèi)存嗎?

帶著疑問,筆者直接看了一下Spring Boot Loader那一塊的源碼。發(fā)現(xiàn)Spring Boot對Java JDK的InflaterInputStream進行了包裝并且使用了Inflater,而Inflater本身用于解壓JAR包的需要用到堆外內(nèi)存。而包裝之后的類ZipInflaterInputStream沒有釋放Inflater持有的堆外內(nèi)存。于是筆者以為找到了原因,立馬向Spring Boot社區(qū)反饋了這個bug。但是反饋之后,筆者就發(fā)現(xiàn)Inflater這個對象本身實現(xiàn)了finalize方法,在這個方法中有調(diào)用釋放堆外內(nèi)存的邏輯。也就是說Spring Boot依賴于GC釋放堆外內(nèi)存。

筆者使用jmap查看堆內(nèi)對象時,發(fā)現(xiàn)已經(jīng)基本上沒有Inflater這個對象了。于是就懷疑GC的時候,沒有調(diào)用finalize。帶著這樣的懷疑,筆者把Inflater進行包裝在Spring Boot Loader里面替換成自己包裝的Inflater,在finalize進行打點監(jiān)控,結(jié)果finalize方法確實被調(diào)用了。于是筆者又去看了Inflater對應(yīng)的C代碼,發(fā)現(xiàn)初始化的使用了malloc申請內(nèi)存,end的時候也調(diào)用了free去釋放內(nèi)存。

此刻,筆者只能懷疑free的時候沒有真正釋放內(nèi)存,便把Spring Boot包裝的InflaterInputStream替換成Java JDK自帶的,發(fā)現(xiàn)替換之后,內(nèi)存問題也得以解決了。

這時,再返過來看gperftools的內(nèi)存分布情況,發(fā)現(xiàn)使用Spring Boot時,內(nèi)存使用一直在增加,突然某個點內(nèi)存使用下降了好多(使用量直接由3G降為700M左右)。這個點應(yīng)該就是GC引起的,內(nèi)存應(yīng)該釋放了,但是在操作系統(tǒng)層面并沒有看到內(nèi)存變化,那是不是沒有釋放到操作系統(tǒng),被內(nèi)存分配器持有了呢?

繼續(xù)探究,發(fā)現(xiàn)系統(tǒng)默認(rèn)的內(nèi)存分配器(glibc 2.12版本)和使用gperftools內(nèi)存地址分布差別很明顯,2.5G地址使用smaps發(fā)現(xiàn)它是屬于Native Stack。內(nèi)存地址分布如下:

怎么排查Spring Boot內(nèi)存泄露

gperftools顯示的內(nèi)存地址分布

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

怎么排查Spring Boot內(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)存分配器。其中代碼Demo如下:

#include<sys/mman.h>  #include<stdlib.h>  #include<string.h>  #include<stdio.h>  //作者使用的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));  }

通過在自定義分配器當(dāng)中埋點可以發(fā)現(xiàn)其實程序啟動之后應(yīng)用實際申請的堆外內(nèi)存始終在700M-800M之間,gperftools監(jiān)控顯示內(nèi)存使用量也是在700M-800M左右。但是從操作系統(tǒng)角度來看進程占用的內(nèi)存差別很大(這里只是監(jiān)控堆外內(nèi)存)。

筆者做了一下測試,使用不同分配器進行不同程度的掃包,占用的內(nèi)存如下:

怎么排查Spring Boot內(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。

到此,相信大家對“怎么排查Spring Boot內(nèi)存泄露”有了更深的了解,不妨來實際操作一番吧!這里是億速云網(wǎng)站,更多相關(guān)內(nèi)容可以進入相關(guān)頻道進行查詢,關(guān)注我們,繼續(xù)學(xué)習(xí)!

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI