
問題現(xiàn)象
7月25號,我們一服務(wù)的內(nèi)存占用較高,約13G,容器總內(nèi)存16G,占用約85%,觸發(fā)了內(nèi)存報警(閾值85%),而我們是按容器內(nèi)存60%(9.6G)的比例配置的JVM堆內(nèi)存??戳讼缕渌?wù),同樣的堆內(nèi)存配置,它們內(nèi)存占用約70%~79%,此服務(wù)比其它服務(wù)內(nèi)存占用稍大。

那為什么此服務(wù)內(nèi)存占用稍大呢,它存在內(nèi)存泄露嗎?
排查步驟
1. 檢查Java堆占用與gc情況
jcmd 1 GC.heap_info

jstat -gcutil 1 1000

可見堆使用情況正常。
2. 檢查非堆占用情況
查看監(jiān)控儀表盤,如下:

arthas的memory命令查看,如下:

可見非堆內(nèi)存占用也正常。
3. 檢查native內(nèi)存
Linux進(jìn)程的內(nèi)存布局,如下:

linux進(jìn)程啟動時,有代碼段、數(shù)據(jù)段、堆(Heap)、棧(Stack)及內(nèi)存映射段,在運行過程中,應(yīng)用程序調(diào)用malloc、mmap等C庫函數(shù)來使用內(nèi)存,C庫函數(shù)內(nèi)部則會視情況通過brk系統(tǒng)調(diào)用擴(kuò)展堆或使用mmap系統(tǒng)調(diào)用創(chuàng)建新的內(nèi)存映射段。
而通過pmap命令,就可以查看進(jìn)程的內(nèi)存布局,它的輸出樣例如下:

可以發(fā)現(xiàn),進(jìn)程申請的所有虛擬內(nèi)存段,都在pmap中能夠找到,相關(guān)字段解釋如下:
- Address:表示此內(nèi)存段的起始地址
- Kbytes:表示此內(nèi)存段的大小(ps:這是虛擬內(nèi)存)
- RSS:表示此內(nèi)存段實際分配的物理內(nèi)存,這是由于Linux是延遲分配內(nèi)存的,進(jìn)程調(diào)用malloc時Linux只是分配了一段虛擬內(nèi)存塊,直到進(jìn)程實際讀寫此內(nèi)存塊中部分時,Linux會通過缺頁中斷真正分配物理內(nèi)存。
- Dirty:此內(nèi)存段中被修改過的內(nèi)存大小,使用mmap系統(tǒng)調(diào)用申請?zhí)摂M內(nèi)存時,可以關(guān)聯(lián)到某個文件,也可不關(guān)聯(lián),當(dāng)關(guān)聯(lián)了文件的內(nèi)存段被訪問時,會自動讀取此文件的數(shù)據(jù)到內(nèi)存中,若此段某一頁內(nèi)存數(shù)據(jù)后被更改,即為Dirty,而對于非文件映射的匿名內(nèi)存段(anon),此列與RSS相等。
- Mode:內(nèi)存段是否可讀(r)可寫(w)可執(zhí)行(x)
- Mapping:內(nèi)存段映射的文件,匿名內(nèi)存段顯示為anon,非匿名內(nèi)存段顯示文件名(加-p可顯示全路徑)。
因此,我們可以找一些內(nèi)存段,來看看這些內(nèi)存段中都存儲的什么數(shù)據(jù),來確定是否有泄露。但jvm一般有非常多的內(nèi)存段,重點檢查哪些內(nèi)存段呢?
有兩種思路,如下:
- 檢查那些占用內(nèi)存較大的內(nèi)存段,如下:
pmap -x 1 | sort -nrk3 | less

可以發(fā)現(xiàn)我們進(jìn)程有非常多的64M的內(nèi)存塊,而我同時看了看其它java服務(wù),發(fā)現(xiàn)64M內(nèi)存塊則少得多。
- 檢查一段時間后新增了哪些內(nèi)存段,或哪些變大了,如下:
在不同的時間點多次保存pmap命令的輸出,然后通過文本對比工具查看兩個時間點內(nèi)存段分布的差異。
pmap -x 1 > pmap-`date +%F-%H-%M-%S`.log

icdiff pmap-2023-07-27-09-46-36.log pmap-2023-07-28-09-29-55.log | less -SR

可以看到,一段時間后,新分配了一些內(nèi)存段,看看這些變化的內(nèi)存段里存的是什么內(nèi)容!
tail -c +$((0x00007face0000000+1)) /proc/1/mem|head -c $((11616*1024))|strings|less -S
說明:
- Linux將進(jìn)程內(nèi)存虛擬為偽文件/proc/$pid/mem,通過它即可查看進(jìn)程內(nèi)存中的數(shù)據(jù)。
- tail用于偏移到指定內(nèi)存段的起始地址,即pmap的第一列,head用于讀取指定大小,即pmap的第二列。
- strings用于找出內(nèi)存中的字符串?dāng)?shù)據(jù),less用于查看strings輸出的字符串。
通過查看各個可疑內(nèi)存段,發(fā)現(xiàn)有不少類似我們一自研消息隊列的響應(yīng)格式數(shù)據(jù),通過與消息隊列團(tuán)隊合作,找到了相關(guān)的消息topic,并最終與相關(guān)研發(fā)確認(rèn)了此topic消息最近剛遷移到此服務(wù)中。
4. 檢查發(fā)http請求代碼
由于發(fā)送消息是走h(yuǎn)ttp接口,故我在工程中搜索調(diào)用http接口的相關(guān)代碼,發(fā)現(xiàn)一處代碼中創(chuàng)建的流對象沒有關(guān)閉,而GZIPInputStream這個類剛好會直接分配到native內(nèi)存。

其它方法
本次問題,通過檢查內(nèi)存中的數(shù)據(jù)找到了問題,還是有些碰運氣的。這需要內(nèi)存中剛好有一些非常有代表性的字符串,因為非字符串的二進(jìn)制數(shù)據(jù),基本無法分析。
如果查看內(nèi)存數(shù)據(jù)無法找到關(guān)鍵線索,還可嘗試以下幾個方法:
5. 開啟JVM的NMT原生內(nèi)存追蹤功能
添加JVM參數(shù)-XX:NativeMemoryTracking=detail開啟,使用jcmd查看,如下:
jcmd 1 VM.native_memory

NMT只能觀察到JVM管理的內(nèi)存,像通過JNI機制直接調(diào)用malloc分配的內(nèi)存,則感知不到。
6. 檢查被glibc內(nèi)存分配器緩存的內(nèi)存
JVM等原生應(yīng)用程序調(diào)用的malloc、free函數(shù),實際是由基礎(chǔ)C庫libc提供的,而linux系統(tǒng)則提供了brk、mmap、munmap這幾個系統(tǒng)調(diào)用來分配虛擬內(nèi)存,所以libc的malloc、free函數(shù)實際是基于這些系統(tǒng)調(diào)用實現(xiàn)的。
由于系統(tǒng)調(diào)用有一定的開銷,為減小開銷,libc實現(xiàn)了一個類似內(nèi)存池的機制,在free函數(shù)調(diào)用時將內(nèi)存塊緩存起來不歸還給linux,直到緩存內(nèi)存量到達(dá)一定條件才會實際執(zhí)行歸還內(nèi)存的系統(tǒng)調(diào)用。
所以進(jìn)程占用內(nèi)存比理論上要大些,一定程度上是正常的。

malloc_stats函數(shù)
通過如下命令,可以確認(rèn)glibc庫緩存的內(nèi)存量,如下:
# 查看glibc內(nèi)存分配情況,會輸出到進(jìn)程標(biāo)準(zhǔn)錯誤中
gdb -q -batch -ex 'call malloc_stats()' -p 1

如上,Total (incl. mmap)表示glibc分配的總體情況(包含mmap分配的部分),其中system bytes表示glibc從操作系統(tǒng)中申請的虛擬內(nèi)存總大小,in use bytes表示JVM正在使用的內(nèi)存總大小(即調(diào)用glibc的malloc函數(shù)后且沒有free的內(nèi)存)。
可以發(fā)現(xiàn),glibc緩存了快500m的內(nèi)存。
注:當(dāng)我對jvm進(jìn)程中執(zhí)行malloc_stats后,我發(fā)現(xiàn)它顯示的in use bytes要少得多,經(jīng)過檢查JVM代碼,發(fā)現(xiàn)JVM在為Java Heap、Metaspace分配內(nèi)存時,是直接通過mmap函數(shù)分配的,而這個函數(shù)是直接封裝的mmap系統(tǒng)調(diào)用,不走glibc內(nèi)存分配器,故in use bytes會小很多。
malloc_trim函數(shù)
glibc實現(xiàn)了malloc_trim函數(shù),通過brk或madvise系統(tǒng)調(diào)用,歸還被glibc緩存的內(nèi)存,如下:
# 回收glibc緩存的內(nèi)存
gdb -q -batch -ex 'call malloc_trim(0)' -p 1


可以發(fā)現(xiàn),執(zhí)行malloc_trim后,RSS減少了約250m內(nèi)存,可見內(nèi)存占用高并不是因為glibc緩存了內(nèi)存。
注:通過gdb調(diào)用C函數(shù),會有一定概率造成jvm進(jìn)程崩潰,需謹(jǐn)慎執(zhí)行。
7. 使用tcmalloc或jemalloc的內(nèi)存泄露檢測工具
glibc的默認(rèn)內(nèi)存分配器為ptmalloc2,但Linux提供了LD_PRELOAD機制,使得我們可以更換為其它的內(nèi)存分配器,如業(yè)內(nèi)比較成熟的tcmalloc或jemalloc。
這兩個內(nèi)存分配器除了實現(xiàn)了內(nèi)存分配功能外,還提供了內(nèi)存泄露檢測的能力,它們通過hook進(jìn)程的malloc、free函數(shù)調(diào)用,然后找到那些調(diào)用了malloc后一直沒有free的地方,那么這些地方就可能是內(nèi)存泄露點。
HEAPPROFILE=./heap.log
HEAP_PROFILE_ALLOCATION_INTERVAL=104857600
LD_PRELOAD=./libtcmalloc_and_profiler.so
java -jar xxx ...
pprof --pdf /path/to/java heap.log.xx.heap > test.pdf

tcmalloc下載地址:https://github.com/gperftools/gperftools
如上,可以發(fā)現(xiàn)內(nèi)存泄露點來自Inflater對象的init和inflateBytes方法,而這些方法是通過JNI調(diào)用實現(xiàn)的,它會申請native內(nèi)存,經(jīng)過檢查代碼,發(fā)現(xiàn)GZIPInputStream確實會創(chuàng)建并使用Inflater對象,如下:

而它的close方法,會調(diào)用Inflater的end方法來歸還native內(nèi)存,由于我們沒有調(diào)用close方法,故相關(guān)聯(lián)的native內(nèi)存無法歸還。

可以發(fā)現(xiàn),tcmalloc的泄露檢測只能看到native棧,如想看到Java棧,可考慮配合使用arthas的profile命令,如下:
# 獲取調(diào)用inflateBytes時的調(diào)用棧
profiler execute 'start,event=Java_java_util_zip_Inflater_inflateBytes,alluser'
# 獲取調(diào)用malloc時的調(diào)用棧
profiler execute 'start,event=malloc,alluser'
如果代碼不修復(fù),內(nèi)存會一直漲嗎?
經(jīng)過查看代碼,發(fā)現(xiàn)Inflater實現(xiàn)了finalize方法,而finalize方法調(diào)用了end方法。
也就是說,若GC時Inflater對象被回收,相關(guān)聯(lián)的原生內(nèi)存是會被free的,所以內(nèi)存會一直漲下去導(dǎo)致進(jìn)程被oom kill嗎?maybe,這取決于GC觸發(fā)的閾值,即在GC觸發(fā)前JVM中會保留的垃圾Inflater對象數(shù)量,保留得越多native內(nèi)存占用越大。

但我發(fā)現(xiàn)一個有趣現(xiàn)象,我通過jcmd強行觸發(fā)了一次Full GC,如下:
jcmd 1 GC.run
理論上native內(nèi)存應(yīng)該會free,但我通過top觀察進(jìn)程rss,發(fā)現(xiàn)基本沒有變化,但我檢查malloc_stats的輸出,發(fā)現(xiàn)in use bytes確實少了許多,這說明Full GC后,JVM確實歸還了Inflater對象關(guān)聯(lián)的原生內(nèi)存,但它們都被glibc緩存起來了,并沒有歸還給操作系統(tǒng)。
于是我再執(zhí)行了一次malloc_trim,強制glibc歸還緩存的內(nèi)存,發(fā)現(xiàn)進(jìn)程的rss降了下來。
編碼最佳實踐
這個問題是由于InputStream流對象未關(guān)閉導(dǎo)致的,在Java中流對象(FileInputStream)、網(wǎng)絡(luò)連接對象(Socket)一般都關(guān)聯(lián)了原生資源,記得在finally中調(diào)用close方法歸還原生資源。
而GZIPInputstream、Inflater是JVM堆外內(nèi)存泄露的常見問題點,review代碼發(fā)現(xiàn)有使用這些類時,需要保持警惕。
JVM內(nèi)存常見疑問
為什么我設(shè)置了-Xmx為10G,top中看到的rss卻大于10G?
根據(jù)上面的介紹,JVM內(nèi)存占用分布大概如下:

可以發(fā)現(xiàn),JVM內(nèi)存占用主要包含如下部分:
- Java堆,-Xmx選項限制的就是Java堆的大小,可通過jcmd命令觀測。
- Java非堆,包含Metaspace、Code Cache、直接內(nèi)存(DirectByteBuffer、MappedByteBuffer)、Thread、GC,它可通過arthas memory命令或NMT原生內(nèi)存追蹤觀測。
- native分配內(nèi)存,即直接調(diào)用malloc分配的,如JNI調(diào)用、磁盤與網(wǎng)絡(luò)io操作等,可通過pmap命令、malloc_stats函數(shù)觀測,或使用tcmalloc檢測泄露點。
- glibc緩存的內(nèi)存,即JVM調(diào)用free后,glibc庫緩存下來未歸還給操作系統(tǒng)的部分,可通過pmap命令、malloc_stats函數(shù)觀測。
所以-Xmx的值,一定要小于容器/物理機的內(nèi)存限制,根據(jù)經(jīng)驗,一般設(shè)置為容器/物理機內(nèi)存的65%左右較為安全,可考慮使用比例的方式代替-Xms與-Xmx,如下:
-XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=65.0 -XX:MinRAMPercentage=65.0
top中VIRT與RES是什么區(qū)別?

- VIRT:進(jìn)程申請的虛擬內(nèi)存總大小。
- RES:進(jìn)程在讀寫它申請的虛擬內(nèi)存頁面后,會觸發(fā)Linux的內(nèi)存缺頁中斷,進(jìn)而導(dǎo)致Linux為該頁分配實際內(nèi)存,即RSS,在top中叫RES。
- SHR:進(jìn)程間共享的內(nèi)存,如libc.so這個C動態(tài)庫,幾乎會被所有進(jìn)程加載到各自的虛擬內(nèi)存空間并使用,但Linux實際只分配了一份內(nèi)存,各個進(jìn)程只是通過內(nèi)存頁表關(guān)聯(lián)到此內(nèi)存而已,注意,RSS指標(biāo)一般也包含SHR。
通過top、ps或pidstat可查詢進(jìn)程的缺頁中斷次數(shù),如下:
top中可以通過f交互指令,將mMin、mMaj列顯示出來。



minflt表示輕微缺頁,即Linux分配了一個內(nèi)存頁給進(jìn)程,而majflt表示主要缺頁,即Linux除了要分配內(nèi)存頁外,還需要從磁盤中讀取數(shù)據(jù)到內(nèi)存頁,一般是內(nèi)存swap到了磁盤后再訪問,或使用了內(nèi)存映射技術(shù)讀取文件。
為什么top中JVM進(jìn)程的VIRT列(虛擬內(nèi)存)那么大?

可以看到,我們一Java服務(wù),申請了約30G的虛擬內(nèi)存,比RES實際內(nèi)存5.6G大很多。
這是因為glibc為了解決多線程內(nèi)存申請時的鎖競爭問題,創(chuàng)建了多個內(nèi)存分配區(qū)Arena,然后每個Arena都有一把鎖,特定的線程會hash到特定的Arena中去競爭鎖并申請內(nèi)存,從而減少鎖開銷。
但在64位系統(tǒng)里,每個Arena去系統(tǒng)申請?zhí)摂M內(nèi)存的單位是64M,然后按需拆分為小塊分配給申請方,所以哪怕線程在此Arena中只申請了1K內(nèi)存,glibc也會為此Arena申請64M。
64位系統(tǒng)里glibc創(chuàng)建Arena數(shù)量的默認(rèn)值為CPU核心數(shù)的8倍,而我們?nèi)萜鬟\行在32核的機器,故glibc會創(chuàng)建32*8=256個Arena,如果每個Arena最少申請64M虛擬內(nèi)存的話,總共申請的虛擬內(nèi)存為256*64M=16G。

然后JVM是直接通過mmap申請的堆、MetaSpace等內(nèi)存區(qū)域,不走glibc的內(nèi)存分配器,這些加起來大約14G,與走glibc申請的16G虛擬內(nèi)存加起來,總共申請?zhí)摂M內(nèi)存30G!
當(dāng)然,不必驚慌,這些只是虛擬內(nèi)存而已,它們多一些并沒有什么影響,畢竟64位進(jìn)程的虛擬內(nèi)存空間有2^48字節(jié)那么大!
為什么jvm啟動后一段時間內(nèi)內(nèi)存占用越來越多,存在內(nèi)存泄露嗎?
如下,是我們一服務(wù)重啟后運行快2天的內(nèi)存占用情況,可以發(fā)現(xiàn)內(nèi)存一直從45%漲到了62%,8G的容器,上漲內(nèi)存大小為1.36G!

但我們這個服務(wù)其實沒有內(nèi)存泄露問題,因為JVM為堆申請的內(nèi)存是虛擬內(nèi)存,如4.8G,但在啟動后JVM一開始可能實際只使用了3G內(nèi)存,導(dǎo)致Linux實際只分配了3G。
然后在gc時,由于會復(fù)制存活對象到堆的空閑部分,如果正好復(fù)制到了以前未使用過的區(qū)域,就又會觸發(fā)Linux進(jìn)行內(nèi)存分配,故一段時間內(nèi)內(nèi)存占用會越來越多,直到堆的所有區(qū)域都被touch到。

而通過添加JVM參數(shù)
-XX:+AlwaysPreTouch,可以讓JVM為堆申請?zhí)摂M內(nèi)存后,立即把堆全部touch一遍,使得堆區(qū)域全都被分配物理內(nèi)存,而由于Java進(jìn)程主要活動在堆內(nèi),故后續(xù)內(nèi)存就不會有很大變化了,我們另一服務(wù)添加了此參數(shù),內(nèi)存表現(xiàn)如下:
可以看到,內(nèi)存上漲幅度不到2%,無此參數(shù)可以提高內(nèi)存利用度,加此參數(shù)則會使應(yīng)用運行得更穩(wěn)定。
如我們之前一服務(wù)一周內(nèi)會有1到2次GC耗時超過2s,當(dāng)我添加此參數(shù)后,再未出現(xiàn)過此情況。這是因為當(dāng)無此參數(shù)時,若GC訪問到了未讀寫區(qū)域,會觸發(fā)Linux分配內(nèi)存,大多數(shù)情況下此過程很快,但有極少數(shù)情況下會較慢,在GC日志中則表現(xiàn)為sys耗時較高。

參考文章
https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/
https://juejin.cn/post/7078624931826794503
https://juejin.cn/post/6903363887496691719
