<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Spring Boot 引起的 “堆外內(nèi)存泄漏”,太坑了,快看看你什么版本!

          共 7149字,需瀏覽 15分鐘

           ·

          2022-11-09 18:56

          點擊關注公眾號,Java干貨 及時送達 ac09b40c516c911659f6043a18053dd0.webp

          作者:紀兵,2015年加入美團,目前主要從事酒店C端相關的工作。
          原文:https://tech.meituan.com/2019/01/03/spring-boot-native-memory-leak.html

          背景

          為了更好地實現(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)存如下圖所示:

          4742fe4052b416b549f5fdf451d81d65.webptop命令顯示的內(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)存分布如下:

          c204cc772e5db3ab3315eee05cec6cc2.webpjcmd顯示的內(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)存分布,發(fā)現(xiàn)大量的64M的地址;而這些地址空間不在jcmd命令所給出的地址空間里面,基本上就斷定就是這些64M的內(nèi)存所導致。

          82c7c71bd8af4cc98f25a72147bc5a05.webppmap顯示的內(nèi)存情況

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

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

          首先,使用了gperftools去定位問題,gperftools的使用方法可以參考gperftools,gperftools的監(jiān)控如下:

          b18afe4d5199faaa4dd3f5d8393e19c9.webpgperftools監(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)控如下圖所示:

          942409db74c6973810c2cfa95343c39d.webpstrace監(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)容,如下:

          66ef7a991b6a3ccd9a7e46f8f0b52779.webpgperftools監(jiān)控

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

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

          fd27491d7e9ed63ded127d5d56312616.webpstrace監(jiān)控

          使用該mmap申請的地址空間在pmap對應如下:

          0474f5b744c9e59eb9cfe99d92e1b3f0.webpstrace申請內(nèi)容對應的pmap地址空間

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

          17112cba5f7c61b678d6cf7d601df04b.webpstrace申請空間的線程棧

          這里基本上就可以看出問題來了:MCC(美團統(tǒng)一配置中心)使用了Reflections進行掃包,底層使用了Spring Boot去加載JAR?!?a >46 張 PPT 弄懂JVM、GC 算法和性能調(diào)優(yōu)分享給你。因為解壓JAR使用Inflater類,需要用到堆外內(nèi)存,然后使用Btrace去追蹤這個類,棧如下:

          f4c6baa13c4ae0e91503a933aa338049.webpbtrace追蹤棧

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

          推薦一個開源免費的 Spring Boot 最全教程:

          https://github.com/javastacks/spring-boot-best-practice

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

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

          • 為什么使用舊的框架沒有問題?
          • 為什么堆外內(nèi)存沒有釋放?
          • 為什么內(nèi)存大小都是64M,JAR大小不可能這么大,而且都是一樣大?
          • 為什么gperftools最終顯示使用的的內(nèi)存大小是700M左右,解壓包真的沒有使用malloc申請內(nèi)存嗎?
          另外,如果你近期準備面試跳槽,建議在 Java面試庫 小程序在線刷題,涵蓋 2000+?道 Java 面試題,幾乎覆蓋了所有主流技術面試題。

          帶著疑問,筆者直接看了一下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)控,結果finalize方法確實被調(diào)用了。于是筆者又去看了Inflater對應的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左右)。這個點應該就是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。內(nèi)存地址分布如下:

          9d77ffd642a78cc8aef57cc566c32ebd.webpgperftools顯示的內(nèi)存地址分布

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

          f27f637eb2476f4c9cbbbbecf6dcef6f.webpglib內(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));
          }

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

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

          ff82e8ad3d9b9f7c838019ab723a4c41.webp內(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。

          總結

          cafcafb48535d894950d8e8c3653a561.webp流程圖

          整個內(nèi)存分配的流程如上圖所示。MCC掃包的默認配置是掃描所有的JAR包。在掃描包的時候,Spring Boot不會主動去釋放堆外內(nèi)存,導致在掃描階段,堆外內(nèi)存占用量一直持續(xù)飆升。當發(fā)生GC的時候,Spring Boot依賴于finalize機制去釋放了堆外內(nèi)存;但是glibc為了性能考慮,并沒有真正把內(nèi)存歸返到操作系統(tǒng),而是留下來放入內(nèi)存池了,導致應用層以為發(fā)生了“內(nèi)存泄漏”。

          所以修改MCC的配置路徑為特定的JAR包,問題解決。筆者在發(fā)表這篇文章時,發(fā)現(xiàn)Spring Boot的最新版本(2.0.5.RELEASE)已經(jīng)做了修改,在ZipInflaterInputStream主動釋放了堆外內(nèi)存不再依賴GC;所以Spring Boot升級到最新版本,這個問題也可以得到解決。

          參考資料

          [1]

          GNU C Library (glibc): https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/6.0_release_notes/compiler

          [2]

          Native Memory Tracking: https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html

          [3]

          Spring Boot: https://github.com/spring-projects/spring-boot

          [4]

          gperftools: https://github.com/gperftools/gperftools

          [5]

          Btrace: https://github.com/btraceio/btrace

          End


          Spring Boot 學習筆記,這個太全了!

          23 種設計模式實戰(zhàn)(很全)

          Nacos 2.1.1 正式發(fā)布,真心強!

          Spring Cloud Alibaba 最新重磅發(fā)布!

          Stream 中的 map、peek、foreach 方法的區(qū)別?

          9284b81ab4ed4df18bb4b99b847e7ac1.webpSpring Cloud 微服務最新課程!
          瀏覽 65
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  午夜无码电影 | 亚洲.欧美.丝袜.中文.综合 | 一道本在线无码免费视频 | 蜜桃熟女网| 竹菊影视一区二区 |