最近排查一個線上java服務常駐內存異常高的問題,大概現象是:java堆Xmx配置了8G,但運行一段時間後常駐內存RES從5G逐漸增加到13G #補圖#,致使機器開始swap從而服務總體變慢。
因爲Xmx只配置了8G但RES常駐內存達到了13G,多出了5G堆外內存,經驗上判斷這裏超出太多不太正常。html
開始逐步對堆外內存進行排查,首先了解一下JVM內存模型。根據JVM規範,JVM運行時數據區共分爲虛擬機棧、堆、方法區、程序計數器、本地方法棧五個部分。java
PermGen space 和 Metaspace是HotSpot對於方法區的不一樣實現。在Java虛擬機(如下簡稱JVM)中,類包含其對應的元數據,好比類名,父類名,類的類型,訪問修飾符,字段信息,方法信息,靜態變量,常量,類加載器的引用,類的引用。在HotSpot JDK 1.8以前這些類元數據信息存放在一個叫永久代的區域(PermGen space),永久代一段連續的內存空間。在JDK 1.8開始,方法區實現採用Metaspace代替,這些元數據信息直接使用本地內存來分配。元空間與永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地內存。nginx
java 8下是指除了Xmx設置的java堆(java 8如下版本還包括MaxPermSize設定的持久代大小)外,java進程使用的其餘內存。主要包括:DirectByteBuffer分配的內存,JNI裏分配的內存,線程棧分配佔用的系統內存,jvm自己運行過程分配的內存,codeCache,java 8裏還包括metaspace元數據空間。c++
因爲現象是RES比較高,先看一下java堆是否有異常。把java堆dump下來仔細排查一下,jmap -histo:live pid,發現整個堆回收完也才幾百兆,遠不到8G的Xmx的上限值,GC日誌看着也沒啥異常。基本排查java堆內存泄露的可能性。git
因爲服務使用的RPC框架底層採用了Netty等NIO框架,會使用到DirectByteBuffer這種「冰山對象」,先簡單排查一下。關於DirectByteBuffer先介紹一下:JDK 1.5以後ByteBuffer類提供allocateDirect(int capacity)進行堆外內存的申請,底層經過unsafe.allocateMemory(size)實現,會調用malloc方法進行內存分配。實際上,在java堆裏是維護了一個記錄堆外地址和大小的DirectByteBuffer的對象,因此GC是能經過操做DirectByteBuffer對象來間接操做對應的堆外內存,從而達到釋放堆外內存的目的。但若是一旦這個DirectByteBuffer對象熬過了young GC到達了Old區,同時Old區一直又沒作CMS GC或者Full GC的話,這些「冰山對象」會將系統物理內存慢慢消耗掉。對於這種狀況JVM留了後手,Bits給DirectByteBuffer前首先須要向Bits類申請額度,Bits類維護了一個全局的totalCapacity變量,記錄着所有DirectByteBuffer的總大小,每次申請,都先看看是否超限(堆外內存的限額默認與堆內內存Xmx設定相仿),若是已經超限,會主動執行Sytem.gc(),System.gc()會對新生代的老生代都會進行內存回收,這樣會比較完全地回收DirectByteBuffer對象以及他們關聯的堆外內存。但若是啓動時經過-DisableExplicitGC禁止了System.gc(),那麼這裏就會出現比較嚴重的問題,致使回收不了DirectByteBuffer底下的堆外內存了。因此在相似Netty的框架裏對DirectByteBuffer是框架本身主動回收來避免這個問題。github
DirectByteBuffer是直接經過native方法使用malloc分配內存,這塊內存位於java堆以外,對GC沒有影響;其次,在通訊場景下,堆外內存能減小IO時的內存複製,不須要堆內存Buffer拷貝一份到直接內存中,而後才寫入Socket中。因此DirectByteBuffer通常用於通訊過程當中做爲緩衝池來減小內存拷貝。固然,因爲直接用malloc在OS裏申請一段內存,比在已申請好的JVM堆內內存裏劃一塊出來要慢,因此在Netty中通常用池化的 PooledDirectByteBuf 對DirectByteBuffer進行重用進一步提高性能。算法
JMX提供了監控direct buffer的MXBean,啓動服務時開啓-Dcom.sun.management.jmxremote.port=9527 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=10.79.40.147,JMC掛上後運行一段時間,此時Xmx是8G的狀況下總體RES逐漸增加到13G,MBean裏找到java.nio.BufferPool下的direct節點,查看direct buffer的狀況,發現總共才213M。爲了進一步排除,在啓動時經過-XX:MaxDirectMemorySize來限制DirectByteBuffer的最大限額,調整爲1G後,進程總體常駐內存的增加並無限制住,所以這裏基本排除了DirectByteBuffer的嫌疑。sql
NMT是Java7U40引入的HotSpot新特性,可用於監控JVM原生內存的使用,但比較惋惜的是,目前的NMT不能監控到JVM以外或原生庫分配的內存。java進程啓動時指定開啓NMT(有必定的性能損耗),輸出級別能夠設置爲「summary」或「detail」級別。如:編程
-XX:NativeMemoryTracking=summary 或者 -XX:NativeMemoryTracking=detail
開啓後,經過jcmd能夠訪問收集到的數據。數組
jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff
如:jcmd 11 VM.native_memory,輸出以下:
Native Memory Tracking:
Total: reserved=12259645KB(保留內存), committed=11036265KB (提交內存) 堆內存使用狀況,保留內存和提交內存和Xms、Xmx一致,都是8G。 - Java Heap (reserved=8388608KB, committed=8388608KB) (mmap: reserved=8388608KB, committed=8388608KB) 用於存儲類元數據信息使用到的原生內存,總共12045個類,總體實際使用了79M內存。 - Class (reserved=1119963KB, committed=79751KB) (classes #12045) (malloc=1755KB #29277) (mmap: reserved=1118208KB, committed=77996KB) 總共2064個線程,提交內存是2.1G左右,一個線程1M,和設置Xss1m相符。 - Thread (reserved=2130294KB, committed=2130294KB) (thread #2064) (stack: reserved=2120764KB, committed=2120764KB) (malloc=6824KB #10341) (arena=2706KB #4127) JIT的代碼緩存,12045個類JIT編譯後代碼緩存總體使用79M內存。 - Code (reserved=263071KB, committed=79903KB) (malloc=13471KB #15191) (mmap: reserved=249600KB, committed=66432KB) GC相關使用到的一些堆外內存,好比GC算法的處理鎖會使用一些堆外空間。118M左右。 - GC (reserved=118432KB, committed=118432KB) (malloc=93848KB #453) (mmap: reserved=24584KB, committed=24584KB) JAVA編譯器自身操做使用到的一些堆外內存,不多。 - Compiler (reserved=975KB, committed=975KB) (malloc=844KB #1074) (arena=131KB #3) Internal:memory used by the command line parser, JVMTI, properties等。 - Internal (reserved=117158KB, committed=117158KB) (malloc=117126KB #44857) (mmap: reserved=32KB, committed=32KB) Symbol:保留字符串(Interned String)的引用與符號表引用放在這裏,17M左右 - Symbol (reserved=17133KB, committed=17133KB) (malloc=13354KB #145640) (arena=3780KB #1) NMT自己佔用的堆外內存,4M左右 - Native Memory Tracking (reserved=4402KB, committed=4402KB) (malloc=396KB #5287) (tracking overhead=4006KB) 不知道啥,用的不多。 - Arena Chunk (reserved=272KB, committed=272KB) (malloc=272KB) 其餘未分類的堆外內存佔用,100M左右。 - Unknown (reserved=99336KB, committed=99336KB) (mmap: reserved=99336KB, committed=99336KB)
保留內存(reserved):reserved memory 是指JVM 經過mmaped PROT_NONE 申請的虛擬地址空間,在頁表中已經存在了記錄(entries),保證了其餘進程不會被佔用,且保證了邏輯地址的連續性,能簡化指針運算。
提交內存(commited):committed memory 是JVM向操作系統實際分配的內存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,仍然會page faults,可是跟 reserved 不一樣,徹底內核處理像什麼也沒發生同樣。
這裏須要注意的是:因爲malloc/mmap的lazy allocation and paging機制,即便是commited的內存,也不必定會真正分配物理內存。
malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they're accessed.
Tips:因爲內存是一直在緩慢增加,所以在使用NMT跟蹤堆外內存時,一個比較好的辦法是,先創建一個內存使用基線,一段時間後再用當時數據和基線進行差異比較,這樣比較容易定位問題。
jcmd 11 VM.native_memory baseline
同時pmap看一下物理內存的分配,RSS佔用了10G。
pmap -x 11 | sort -n -k3

運行一段時間後,作一下summary級別的diff,看下內存變化,同時再次pmap看下RSS增加狀況。
jcmd 11 VM.native_memory summary.diff Native Memory Tracking: Total: reserved=13089769KB +112323KB, committed=11877285KB +117915KB - Java Heap (reserved=8388608KB, committed=8388608KB) (mmap: reserved=8388608KB, committed=8388608KB) - Class (reserved=1126527KB +2161KB, committed=85771KB +2033KB) (classes #12682 +154) (malloc=2175KB +113KB #37289 +2205) (mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB) - Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB) (thread #2772 +92) (stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB) (malloc=9169KB +305KB #13881 +460) (arena=3728KB +108 #5543 +184) - Code (reserved=265858KB +1146KB, committed=94130KB +6866KB) (malloc=16258KB +1146KB #18187 +1146) (mmap: reserved=249600KB, committed=77872KB +5720KB) - GC (reserved=118433KB +1KB, committed=118433KB +1KB) (malloc=93849KB +1KB #487 +24) (mmap: reserved=24584KB, committed=24584KB) - Compiler (reserved=1956KB +253KB, committed=1956KB +253KB) (malloc=1826KB +253KB #2098 +271) (arena=131KB #3) - Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB) (malloc=203900KB +13143KB #62342 +3942) (mmap: reserved=32KB, committed=32KB) - Symbol (reserved=17820KB +108KB, committed=17820KB +108KB) (malloc=13977KB +76KB #152204 +257) (arena=3844KB +32 #1) - Native Memory Tracking (reserved=5519KB +517KB, committed=5519KB +517KB) (malloc=797KB +325KB #9992 +3789) (tracking overhead=4722KB +192KB) - Arena Chunk (reserved=294KB +5KB, committed=294KB +5KB) (malloc=294KB +5KB) - Unknown (reserved=99336KB, committed=99336KB) (mmap: reserved=99336KB, committed=99336KB

發現這段時間pmap看到的RSS增加了3G多,但NMT觀察到的內存增加了不到120M,還有大概2G多常駐內存不翼而飛,所以也基本排除了因爲JVM自身管理的堆外內存的嫌疑。
因爲線上使用的是JDK8,前面提到,JDK8裏的元空間實際上使用的也是堆外內存,默認沒有設置元空間大小的狀況下,元空間最大堆外內存大小和Xmx是一致的。JMC連上後看下內存tab下metaspace一欄的內存佔用狀況,發現元空間只佔用不到80M內存,也排除了它的可能性。實在不放心的話能夠經過-XX:MaxMetaspaceSize設置元空間使用堆外內存的上限。
上面提到使用pmap來查看進程的內存映射,pmap命令實際是讀取了/proc/pid/maps和/porc/pid/smaps文件來輸出。發現一個細節,pmap取出的內存映射發現不少64M大小的內存塊。這種內存塊逐漸變多且佔用的RSS常駐內存也逐漸增加到reserved保留內存大小,內存增加的2G多基本上也是因爲這些64M的內存塊致使的,所以看一下這些內存塊裏具體內容。
strace -o /data1/weibo/logs/strace_output2.txt -T -tt -e mmap,munmap,mprotect -fp 12
看內存申請和釋放的狀況:
cat ../logs/strace_output2.txt | grep mprotect | grep -v resumed | awk '{print int($4)}' | sort -rn | head -5 cat ../logs/strace_output2.txt | grep mmap | grep -v resumed | awk '{print int($4)}' | sort -rn | head -5 cat ../logs/strace_output2.txt | grep munmap | grep -v resumed | awk '{print int($4)}' | sort -rn | head -5
配合pmap -x 10看一下實際內存分配狀況:
找一塊內存塊進行dump:
gdb --batch --pid 11 -ex "dump memory a.dump 0x7fd488000000 0x7fd488000000+56124000"
簡單分析一下內容,發現絕大部分是亂碼的二進制內容,看不出什麼問題。
strings a.dump | less
或者: hexdump -C a.dump | less
或者: view a.dump
沒啥思路的時候,隨便搜了一下發現貌似不少人碰到這種64M內存塊的問題(好比這裏),瞭解到glibc的內存分配策略在高版本有較大調整:
«從glibc 2.11(爲應用系統在多核心CPU和多Sockets環境中高伸縮性提供了一個動態內存分配的特性加強)版本開始引入了per thread arena內存池,Native Heap區被打散爲sub-pools ,這部份內存池叫作Arena內存池。也就是說,之前只有一個main arena,目前是一個main arena(仍是位於Native Heap區) + 多個per thread arena,多個線程之間再也不共用一個arena內存區域了,保證每一個線程都有一個堆,這樣避免內存分配時須要額外的鎖來下降性能。main arena主要經過brk/sbrk系統調用去管理,per thread arena主要經過mmap系統調用去分配和管理。»
«一個32位的應用程序進程,最大可建立 2 CPU總核數個arena內存池(MALLOC_ARENA_MAX),每一個arena內存池大小爲1MB,一個64位的應用程序進程,最大可建立 8 CPU總核數個arena內存池(MALLOC_ARENA_MAX),每一個arena內存池大小爲64MB»
«當某一線程須要調用 malloc()分配內存空間時, 該線程先查看線程私有變量中是否已經存在一個分配區,若是存在, 嘗試對該分配區加鎖,若是加鎖成功,使用該分配區分配內存,若是失敗, 該線程搜索循環鏈表試圖得到一個沒有加鎖的分配區。若是全部的分配區都已經加鎖,那麼 malloc()會開闢一個新的分配區,把該分配區加入到全局分配區循環鏈表並加鎖,而後使用該分配區進行分配內存操做。在釋放操做中,線程一樣試圖得到待釋放內存塊所在分配區的鎖,若是該分配區正在被別的線程使用,則須要等待直到其餘線程釋放該分配區的互斥鎖以後才能夠進行釋放操做。用戶 free 掉的內存並非都會立刻歸還給系統,ptmalloc2 會統一管理 heap 和 mmap 映射區域中的空閒的chunk,當用戶進行下一次分配請求時, ptmalloc2 會首先試圖在空閒的chunk 中挑選一塊給用戶,這樣就避免了頻繁的系統調用,下降了內存分配的開銷。»
«業務層調用free方法釋放內存時,ptmalloc2先判斷 top chunk 的大小是否大於 mmap 收縮閾值(默認爲 128KB),若是是的話,對於主分配區,則會試圖歸還 top chunk 中的一部分給操做系統。可是最早分配的 128KB 空間是不會歸還的,ptmalloc 會一直管理這部份內存,用於響應用戶的分配 請求;若是爲非主分配區,會進行 sub-heap 收縮,將 top chunk 的一部分返回給操 做系統,若是 top chunk 爲整個 sub-heap,會把整個 sub-heap 還回給操做系統。作 完這一步以後,釋放結束,從 free() 函數退出。能夠看出,收縮堆的條件是當前 free 的 chunk 大小加上先後能合併 chunk 的大小大於 64k,而且要 top chunk 的大 小要達到 mmap 收縮閾值,纔有可能收縮堆。»
«M_MMAP_THRESHOLD 用於設置 mmap 分配閾值,默認值爲 128KB,ptmalloc 默認開啓 動態調整 mmap 分配閾值和 mmap 收縮閾值。當用戶須要分配的內存大於 mmap 分配閾值,ptmalloc 的 malloc()函數其實至關於 mmap() 的簡單封裝,free 函數至關於 munmap()的簡單封裝。至關於直接經過系統調用分配內存, 回收的內存就直接返回給操做系統了。由於這些大塊內存不能被 ptmalloc 緩存管理,不能重用,因此 ptmalloc 也只有在萬不得已的狀況下才使用該方式分配內存。»
當前業務併發較大,線程較多,內存申請時容易形成鎖衝突申請多個arena,另外該服務涉及到圖片的上傳和處理,底層會比較頻繁的經過JNI調用ImageIO的圖片讀取方法(com_sun_imageio_plugins_jpeg_JPEGImageReader_readImage),常常會向glibc申請10M以上的buffer內存,考慮到ptmalloc2的lazy回收機制和mmap分配閾值動態調整默認打開,對於這些申請的大內存塊,使用完後仍然會停留在arena中不會歸還,同時也比較可貴到收縮的機會去釋放(當前回收的chunk和top chunk相鄰,且合併後大於64K)。所以在這種較高併發的多線程業務場景下,RES的增加也是不可避免。
第一種:控制分配區的總數上限。默認64位系統分配區數爲:cpu核數*8,如當前環境16核系統分配區數爲128個,每一個64M上限的話最多可達8G,限制上限後,後續不夠的申請會直接走mmap分配和munmap回收,不會進入ptmalloc2的buffer池。
因此第一種方案調整一下分配池上限個數到4:
export MALLOC_ARENA_MAX=4
第二種:以前降到ptmalloc2默認會動態調整mmap分配閾值,所以對於較大的內存請求也會進入ptmalloc2的內存buffer池裏,這裏能夠去掉ptmalloc的動態調整功能。能夠設置 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一個。這裏能夠固定分配閾值爲128K,這樣超過128K的內存分配請求都不會進入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上會有損耗,當前環境大概10%)。:
export MALLOC_MMAP_THRESHOLD_=131072 export MALLOC_TRIM_THRESHOLD_=131072 export MALLOC_TOP_PAD_=131072 export MALLOC_MMAP_MAX_=65536
第三種:使用tcmalloc來替代默認的ptmalloc2。google的tcmalloc提供更優的內存分配效率,性能更好,ThreadCache會階段性的回收內存到CentralCache裏。 解決了ptmalloc2中arena之間不能遷移致使內存浪費的問題。
perf-tools實現原理是:在java應用程序運行時,當系統分配內存時調用malloc時換用它的libtcmalloc.so,也就是TCMalloc會自動替換掉glibc默認的malloc和free,這樣就能作一些統計。使用TCMalloc(Thread-Caching Malloc)與標準的glibc庫的malloc相比,TCMalloc在內存的分配上效率和速度要高,==瞭解更多TCMalloc
yum -y install gcc make yum -y install gcc gcc-c++ yum -y perl
使用perf-tools的TCMalloc,在64bit系統上須要先安裝libunwind(http://download.savannah.gnu.org/releases/libunwind/libunwind-1.2.tar.gz,只能是這個版本),這個庫爲基於64位CPU和操做系統的程序提供了基本的堆棧展轉開解功能,其中包括用於輸出堆棧跟蹤的API、用於以編程方式展轉開解堆棧的API以及支持C++異常處理機制的API,32bit系統不需安裝。
tar zxvf libunwind-1.2.tar.gz ./configure make make install make clean
從https://github.com/gperftools/gperftools下載相應的google-perftools版本。
tar zxvf google-perftools-2.7.tar.gz ./configure make make install make clean #修改lc_config,加入/usr/local/lib(libunwind的lib所在目錄) echo "/usr/local/lib" > /etc/ld.so.conf.d/usr_local_lib.conf #使libunwind生效 ldconfig
這個文件記錄了編譯時使用的動態連接庫的路徑。默認狀況下,編譯器只會使用/lib和/usr/lib這兩個目錄下的庫文件。
若是你安裝了某些庫,好比在安裝gtk+-2.4.13時它會須要glib-2.0 >= 2.4.0,辛苦的安裝好glib後沒有指定 –prefix=/usr 這樣glib庫就裝到了/usr/local下,而又沒有在/etc/ld.so.conf中添加/usr/local/lib。
庫文件的路徑如 /usr/lib 或 /usr/local/lib 應該在 /etc/ld.so.conf 文件中,這樣 ldd 才能找到這個庫。在檢查了這一點後,要以 root 的身份運行 /sbin/ldconfig。
將/usr/local/lib加入到/etc/ld.so.conf中,這樣安裝gtk時就會去搜索/usr/local/lib,一樣能夠找到須要的庫
ldconfig的做用就是將/etc/ld.so.conf列出的路徑下的庫文件 緩存到/etc/ld.so.cache 以供使用
所以當安裝完一些庫文件,(例如剛安裝好glib),或者修改ld.so.conf增長新的庫路徑後,須要運行一下/sbin/ldconfig
使全部的庫文件都被緩存到ld.so.cache中,若是沒作,即便庫文件明明就在/usr/lib下的,也是不會被使用的
mkdir /data1/weibo/logs/gperftools/tcmalloc/heap chmod 0777 /data1/weibo/logs/gperftools/tcmalloc/heap
catalina.sh裏添加:
ldconfig
export LD_PRELOAD=/usr/local/lib/libtcmalloc.so export HEAPPROFILE=/data1/weibo/logs/gperftools/tcmalloc/heap
修改後重啓tomcat的容器。
LD_PRELOAD是Linux系統的一個環境變量,它能夠影響程序的運行時的連接(Runtime linker),它容許你定義在程序運行前優先加載的動態連接庫。這個功能主要就是用來有選擇性的載入不一樣動態連接庫中的相同函數。經過這個環境變量,咱們能夠在主程序和其動態連接庫的中間加載別的動態連接庫,甚至覆蓋正常的函數庫。一方面,咱們能夠以此功能來使用本身的或是更好的函數(無需別人的源碼),而另外一方面,咱們也能夠以向別人的程序注入程序,從而達到特定的目的。更多關於LD_PRELOAD