Spring Boot引發的“堆外內存泄漏”,該怎麼解決?[排查及經驗總結]

點關注,不迷路;持續更新Java相關技術及資訊!!!

背景

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

排查過程

1. 使用Java層面的工具定位內存區域(堆內內存、Code區域或者使用unsafe.allocateMemory和DirectByteBuffer申請的堆外內存)
筆者在項目中添加-XX:NativeMemoryTracking=detailJVM參數重啓項目,使用命令jcmd pid VM.native_memory detail查看到的內存分佈如下:


發現命令顯示的committed的內存小於物理內存,因爲jcmd命令顯示的內存包含堆內內存、Code區域、通過unsafe.allocateMemory和DirectByteBuffer申請的內存,但是不包含其他Native Code(C代碼)申請的堆外內存。所以猜測是使用Native Code申請內存所導致的問題。

爲了防止誤判,筆者使用了pmap查看內存分佈,發現大量的64M的地址;而這些地址空間不在jcmd命令所給出的地址空間裏面,基本上就斷定就是這些64M的內存所導致。 在這裏插入圖片描述

2. 使用系統層面的工具定位堆外內存

因爲筆者已經基本上確定是Native Code所引起,而Java層面的工具不便於排查此類問題,只能使用系統層面的工具去定位問題。
首先,使用了gperftools去定位問題gperftools的使用方法可以參考gperftools,gperftools的監控如下:
gperftools監控從內容上來看,像是解壓後的JAR包信息。讀取JAR包信息應該是在項目啓動的時候,那麼在項目啓動之後使用strace作用就不是很大了。所以應該在項目啓動的時候使用strace,而不是啓動完成之後。

再次,項目啓動時使用strace去追蹤系統調用
項目啓動使用strace追蹤系統調用,發現確實申請了很多64M的內存空間,截圖如下:在這裏插入圖片描述
使用該mmap申請的地址空間在pmap對應如下:在這裏插入圖片描述
最後,使用jstack去查看對應的線程
因爲strace命令中已經顯示申請內存的線程ID。直接使用命令jstack pid去查看線程棧,找到對應的線程棧(注意10進制和16進制轉換)如下:在這裏插入圖片描述
這裏基本上就可以看出問題來了:MCC(美團統一配置中心)使用了Reflections進行掃包,底層使用了Spring Boot去加載JAR。因爲解壓JAR使用Inflater類,需要用到堆外內存,然後使用Btrace去追蹤這個類,棧如下:在這裏插入圖片描述
然後查看使用MCC的地方,發現沒有配置掃包路徑,默認是掃描所有的包。於是修改代碼,配置掃包路徑,發佈上線後內存問題解決。

3. 爲什麼堆外內存沒有釋放掉呢?

雖然問題已經解決了,但是有幾個疑問:

  • 爲什麼使用舊的框架沒有問題?
  • 爲什麼堆外內存沒有釋放?
  • 爲什麼內存大小都是64M,JAR大小不可能這麼大,而且都是一樣大?
  • 爲什麼gperftools最終顯示使用的的內存大小是700M左右,解壓包真的沒有使用malloc申請內存嗎?

帶着疑問,筆者直接看了一下Spring Boot Loader那一塊的源碼。發現Spring Boot對Java JDK的InflaterInputStream進行了包裝並且使用了Inflater,而Inflater本身用於解壓JAR包的需要用到堆外內存。而包裝之後的類ZipInflaterInputStream沒有釋放Inflater持有的堆外內存。於是筆者以爲找到了原因,立馬向Spring Boot社區反饋了這個BUG。但是反饋之後,筆者就發現Inflater這個對象本身實現了finalize方法,在這個方法中有調用釋放堆外內存的邏輯。也就是說Spring Boot依賴於GC釋放堆外內存。

筆者使用jmap查看堆內對象時,發現已經基本上沒有Inflater這個對象了。於是就懷疑GC的時候,沒有調用finalize。帶着這樣的懷疑,筆者把Inflater進行包裝在Spring Boot Loader裏面替換成自己包裝的Inflater,在finalize進行打點監控,結果finalize方法確實被調用了。於是筆者又去看了Inflater對應的C代碼,發現初始化的使用了malloc申請內存,end的時候也調用了free去釋放內存。

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

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

繼續探究,發現系統默認的內存分配器(glibc 2.12版本)和使用gperftools內存地址分佈差別很明顯,2.5G地址使用smaps發現它是屬於Native Stack。內存地址分佈如下:
在這裏插入圖片描述到此,基本上可以確定是內存分配器在搗鬼;搜索了一下glibc 64M,發現glibc從2.11開始對每個線程引入內存池(64位機器大小就是64M內存),原文如下:
在這裏插入圖片描述
按照文中所說去修改MALLOC_ARENA_MAX環境變量,發現沒什麼效果。查看tcmalloc(gperftools使用的內存分配器)也使用了內存池方式。

爲了驗證是內存池搞的鬼,筆者就簡單寫個不帶內存池的內存分配器。使用命令gcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so生成動態庫,然後使用export LD_PRELOAD=zjbmalloc.so替換掉glibc的內存分配器。其中代碼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));
}

通過在自定義分配器當中埋點可以發現其實程序啓動之後應用實際申請的堆外內存始終在700M-800M之間,gperftools監控顯示內存使用量也是在700M-800M左右。但是從操作系統角度來看進程佔用的內存差別很大(這裏只是監控堆外內存)。

筆者做了一下測試,使用不同分配器進行不同程度的掃包,佔用的內存如下:在這裏插入圖片描述爲什麼自定義的malloc申請800M,最終佔用的物理內存在1.7G呢?

因爲自定義內存分配器採用的是mmap分配內存,mmap分配內存按需向上取整到整數個頁,所以存在着巨大的空間浪費。通過監控發現最終申請的頁面數目在536k個左右,那實際上向系統申請的內存等於512k * 4k(pagesize) = 2G。 爲什麼這個數據大於1.7G呢?

因爲操作系統採取的是延遲分配的方式,通過mmap向系統申請內存的時候,系統僅僅返回內存地址並沒有分配真實的物理內存。只有在真正使用的時候,系統產生一個缺頁中斷,然後再分配實際的物理Page。

總結在這裏插入圖片描述

整個內存分配的流程如上圖所示。MCC掃包的默認配置是掃描所有的JAR包。在掃描包的時候,Spring Boot不會主動去釋放堆外內存,導致在掃描階段,堆外內存佔用量一直持續飆升。當發生GC的時候,Spring Boot依賴於finalize機制去釋放了堆外內存;但是glibc爲了性能考慮,並沒有真正把內存歸返到操作系統,而是留下來放入內存池了,導致應用層以爲發生了“內存泄漏”。所以修改MCC的配置路徑爲特定的JAR包,問題解決。筆者在發表這篇文章時,發現Spring Boot的最新版本(2.0.5.RELEASE)已經做了修改,在ZipInflaterInputStream主動釋放了堆外內存不再依賴GC;所以Spring Boot升級到最新版本,這個問題也可以得到解決。

福利

推薦【java技術交流小站】:點擊加入

羣號:530720915
進羣驗證“csdn”獲取Java高級架構資料、源碼、筆記、視頻Dubbo、Redis、Netty、zookeeper、Springcloud、分佈式、高併發等架構技術架構視頻

希望對大家有所幫助,一起進步,喜歡點個贊 點下關注唄 以後將不斷持續更新~
福利

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章