問題現象
7月25號,我們一服務的內存佔用較高,約13G,容器總內存16G,佔用約85%,觸發了內存報警(閾值85%),而我們是按容器內存60%(9.6G)的比例配置的JVM堆內存。看了下其它服務,同樣的堆內存配置,它們內存佔用約70%~79%,此服務比其它服務內存佔用稍大。
那爲什麼此服務內存佔用稍大呢,它存在內存泄露嗎?
排查步驟
1. 檢查Java堆佔用與gc情況
jcmd 1 GC.heap_info
image_2023-08-26_20230826175746
jstat -gcutil 1 1000
可見堆使用情況正常。
2. 檢查非堆佔用情況
查看監控儀表盤,如下:
arthas的memory命令查看,如下:
可見非堆內存佔用也正常。
3. 檢查native內存
Linux進程的內存佈局,如下:
linux進程啓動時,有代碼段、數據段、堆(Heap)、棧(Stack)及內存映射段,在運行過程中,應用程序調用malloc、mmap等C庫函數來使用內存,C庫函數內部則會視情況通過brk系統調用擴展堆或使用mmap系統調用創建新的內存映射段。
而通過pmap命令,就可以查看進程的內存佈局,它的輸出樣例如下:
可以發現,進程申請的所有虛擬內存段,都在pmap中能夠找到,相關字段解釋如下:
- Address:表示此內存段的起始地址
- Kbytes:表示此內存段的大小(ps:這是虛擬內存)
- RSS:表示此內存段實際分配的物理內存,這是由於Linux是延遲分配內存的,進程調用malloc時Linux只是分配了一段虛擬內存塊,直到進程實際讀寫此內存塊中部分時,Linux會通過缺頁中斷真正分配物理內存。
- Dirty:此內存段中被修改過的內存大小,使用mmap系統調用申請虛擬內存時,可以關聯到某個文件,也可不關聯,當關聯了文件的內存段被訪問時,會自動讀取此文件的數據到內存中,若此段某一頁內存數據後被更改,即爲Dirty,而對於非文件映射的匿名內存段(anon),此列與RSS相等。
- Mode:內存段是否可讀(r)可寫(w)可執行(x)
- Mapping:內存段映射的文件,匿名內存段顯示爲anon,非匿名內存段顯示文件名(加-p可顯示全路徑)。
因此,我們可以找一些內存段,來看看這些內存段中都存儲的什麼數據,來確定是否有泄露。但jvm一般有非常多的內存段,重點檢查哪些內存段呢?
有兩種思路,如下:
- 檢查那些佔用內存較大的內存段,如下:
pmap -x 1 | sort -nrk3 | less
可以發現我們進程有非常多的64M的內存塊,而我同時看了看其它java服務,發現64M內存塊則少得多。
- 檢查一段時間後新增了哪些內存段,或哪些變大了,如下:
在不同的時間點多次保存pmap命令的輸出,然後通過文本對比工具查看兩個時間點內存段分佈的差異。
pmap -x 1 > pmap-`date +%F-%H-%M-%S`.log
image_2023-08-26_20230826180037
icdiff pmap-2023-07-27-09-46-36.log pmap-2023-07-28-09-29-55.log | less -SR
image_2023-08-26_20230826180057
可以看到,一段時間後,新分配了一些內存段,看看這些變化的內存段裏存的是什麼內容!
tail -c +$((0x00007face0000000+1)) /proc/1/mem|head -c $((11616*1024))|strings|less -S
說明:
- Linux將進程內存虛擬爲僞文件/proc/$pid/mem,通過它即可查看進程內存中的數據。
- tail用於偏移到指定內存段的起始地址,即pmap的第一列,head用於讀取指定大小,即pmap的第二列。
- strings用於找出內存中的字符串數據,less用於查看strings輸出的字符串。
通過查看各個可疑內存段,發現有不少類似我們一自研消息隊列的響應格式數據,通過與消息隊列團隊合作,找到了相關的消息topic,並最終與相關研發確認了此topic消息最近剛遷移到此服務中。
4. 檢查發http請求代碼
由於發送消息是走http接口,故我在工程中搜索調用http接口的相關代碼,發現一處代碼中創建的流對象沒有關閉,而GZIPInputStream這個類剛好會直接分配到native內存。
其它方法
本次問題,通過檢查內存中的數據找到了問題,還是有些碰運氣的。這需要內存中剛好有一些非常有代表性的字符串,因爲非字符串的二進制數據,基本無法分析。
如果查看內存數據無法找到關鍵線索,還可嘗試以下幾個方法:
5. 開啓JVM的NMT原生內存追蹤功能
添加JVM參數-XX:NativeMemoryTracking=detail
開啓,使用jcmd查看,如下:
jcmd 1 VM.native_memory
NMT只能觀察到JVM管理的內存,像通過JNI機制直接調用malloc分配的內存,則感知不到。
6. 檢查被glibc內存分配器緩存的內存
JVM等原生應用程序調用的malloc、free函數,實際是由基礎C庫libc提供的,而linux系統則提供了brk、mmap、munmap這幾個系統調用來分配虛擬內存,所以libc的malloc、free函數實際是基於這些系統調用實現的。
由於系統調用有一定的開銷,爲減小開銷,libc實現了一個類似內存池的機制,在free函數調用時將內存塊緩存起來不歸還給linux,直到緩存內存量到達一定條件纔會實際執行歸還內存的系統調用。
所以進程佔用內存比理論上要大些,一定程度上是正常的。
malloc_stats函數
通過如下命令,可以確認glibc庫緩存的內存量,如下:
# 查看glibc內存分配情況,會輸出到進程標準錯誤中
gdb -q -batch -ex 'call malloc_stats()' -p 1
如上,Total (incl. mmap)表示glibc分配的總體情況(包含mmap分配的部分),其中system bytes表示glibc從操作系統中申請的虛擬內存總大小,in use bytes表示JVM正在使用的內存總大小(即調用glibc的malloc函數後且沒有free的內存)。
可以發現,glibc緩存了快500m的內存。
注:當我對jvm進程中執行malloc_stats後,我發現它顯示的in use bytes要少得多,經過檢查JVM代碼,發現JVM在爲Java Heap、Metaspace分配內存時,是直接通過mmap函數分配的,而這個函數是直接封裝的mmap系統調用,不走glibc內存分配器,故in use bytes會小很多。
malloc_trim函數
glibc實現了malloc_trim函數,通過brk或madvise系統調用,歸還被glibc緩存的內存,如下:
# 回收glibc緩存的內存
gdb -q -batch -ex 'call malloc_trim(0)' -p 1
可以發現,執行malloc_trim後,RSS減少了約250m內存,可見內存佔用高並不是因爲glibc緩存了內存。
注:通過gdb調用C函數,會有一定概率造成jvm進程崩潰,需謹慎執行。
7. 使用tcmalloc或jemalloc的內存泄露檢測工具
glibc的默認內存分配器爲ptmalloc2,但Linux提供了LD_PRELOAD機制,使得我們可以更換爲其它的內存分配器,如業內比較成熟的tcmalloc或jemalloc。
這兩個內存分配器除了實現了內存分配功能外,還提供了內存泄露檢測的能力,它們通過hook進程的malloc、free函數調用,然後找到那些調用了malloc後一直沒有free的地方,那麼這些地方就可能是內存泄露點。
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
如上,可以發現內存泄露點來自Inflater對象的init和inflateBytes方法,而這些方法是通過JNI調用實現的,它會申請native內存,經過檢查代碼,發現GZIPInputStream確實會創建並使用Inflater對象,如下:
而它的close方法,會調用Inflater的end方法來歸還native內存,由於我們沒有調用close方法,故相關聯的native內存無法歸還。
可以發現,tcmalloc的泄露檢測只能看到native棧,如想看到Java棧,可考慮配合使用arthas的profile命令,如下:
# 獲取調用inflateBytes時的調用棧
profiler execute 'start,event=Java_java_util_zip_Inflater_inflateBytes,alluser'
# 獲取調用malloc時的調用棧
profiler execute 'start,event=malloc,alluser'
如果代碼不修復,內存會一直漲嗎?
經過查看代碼,發現Inflater實現了finalize方法,而finalize方法調用了end方法。
也就是說,若GC時Inflater對象被回收,相關聯的原生內存是會被free的,所以內存會一直漲下去導致進程被oom kill嗎?maybe,這取決於GC觸發的閾值,即在GC觸發前JVM中會保留的垃圾Inflater對象數量,保留得越多native內存佔用越大。
但我發現一個有趣現象,我通過jcmd強行觸發了一次Full GC,如下:
jcmd 1 GC.run
理論上native內存應該會free,但我通過top觀察進程rss,發現基本沒有變化,但我檢查malloc_stats的輸出,發現in use bytes確實少了許多,這說明Full GC後,JVM確實歸還了Inflater對象關聯的原生內存,但它們都被glibc緩存起來了,並沒有歸還給操作系統。
於是我再執行了一次malloc_trim,強制glibc歸還緩存的內存,發現進程的rss降了下來。
編碼最佳實踐
這個問題是由於InputStream流對象未關閉導致的,在Java中流對象(FileInputStream)、網絡連接對象(Socket)一般都關聯了原生資源,記得在finally中調用close方法歸還原生資源。
而GZIPInputstream、Inflater是JVM堆外內存泄露的常見問題點,review代碼發現有使用這些類時,需要保持警惕。
JVM內存常見疑問
爲什麼我設置了-Xmx爲10G,top中看到的rss卻大於10G?
根據上面的介紹,JVM內存佔用分佈大概如下:
可以發現,JVM內存佔用主要包含如下部分:
- Java堆,-Xmx選項限制的就是Java堆的大小,可通過jcmd命令觀測。
- Java非堆,包含Metaspace、Code Cache、直接內存(DirectByteBuffer、MappedByteBuffer)、Thread、GC,它可通過arthas memory命令或NMT原生內存追蹤觀測。
- native分配內存,即直接調用malloc分配的,如JNI調用、磁盤與網絡io操作等,可通過pmap命令、malloc_stats函數觀測,或使用tcmalloc檢測泄露點。
- glibc緩存的內存,即JVM調用free後,glibc庫緩存下來未歸還給操作系統的部分,可通過pmap命令、malloc_stats函數觀測。
所以-Xmx的值,一定要小於容器/物理機的內存限制,根據經驗,一般設置爲容器/物理機內存的65%左右較爲安全,可考慮使用比例的方式代替-Xms與-Xmx,如下:
-XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=65.0 -XX:MinRAMPercentage=65.0
top中VIRT與RES是什麼區別?
image_2023-08-26_20230826181236
- VIRT:進程申請的虛擬內存總大小。
- RES:進程在讀寫它申請的虛擬內存頁面後,會觸發Linux的內存缺頁中斷,進而導致Linux爲該頁分配實際內存,即RSS,在top中叫RES。
- SHR:進程間共享的內存,如libc.so這個C動態庫,幾乎會被所有進程加載到各自的虛擬內存空間並使用,但Linux實際只分配了一份內存,各個進程只是通過內存頁表關聯到此內存而已,注意,RSS指標一般也包含SHR。
通過top、ps或pidstat可查詢進程的缺頁中斷次數,如下:
top中可以通過f交互指令,將mMin、mMaj列顯示出來。
minflt表示輕微缺頁,即Linux分配了一個內存頁給進程,而majflt表示主要缺頁,即Linux除了要分配內存頁外,還需要從磁盤中讀取數據到內存頁,一般是內存swap到了磁盤後再訪問,或使用了內存映射技術讀取文件。
爲什麼top中JVM進程的VIRT列(虛擬內存)那麼大?
可以看到,我們一Java服務,申請了約30G的虛擬內存,比RES實際內存5.6G大很多。
這是因爲glibc爲了解決多線程內存申請時的鎖競爭問題,創建了多個內存分配區Arena,然後每個Arena都有一把鎖,特定的線程會hash到特定的Arena中去競爭鎖並申請內存,從而減少鎖開銷。
但在64位系統裏,每個Arena去系統申請虛擬內存的單位是64M,然後按需拆分爲小塊分配給申請方,所以哪怕線程在此Arena中只申請了1K內存,glibc也會爲此Arena申請64M。
64位系統裏glibc創建Arena數量的默認值爲CPU核心數的8倍,而我們容器運行在32核的機器,故glibc會創建32*8=256
個Arena,如果每個Arena最少申請64M虛擬內存的話,總共申請的虛擬內存爲256*64M=16G
。
然後JVM是直接通過mmap申請的堆、MetaSpace等內存區域,不走glibc的內存分配器,這些加起來大約14G,與走glibc申請的16G虛擬內存加起來,總共申請虛擬內存30G!
當然,不必驚慌,這些只是虛擬內存而已,它們多一些並沒有什麼影響,畢竟64位進程的虛擬內存空間有2^48字節那麼大!
爲什麼jvm啓動後一段時間內內存佔用越來越多,存在內存泄露嗎?
如下,是我們一服務重啓後運行快2天的內存佔用情況,可以發現內存一直從45%漲到了62%,8G的容器,上漲內存大小爲1.36G!
但我們這個服務其實沒有內存泄露問題,因爲JVM爲堆申請的內存是虛擬內存,如4.8G,但在啓動後JVM一開始可能實際只使用了3G內存,導致Linux實際只分配了3G。
然後在gc時,由於會複製存活對象到堆的空閒部分,如果正好複製到了以前未使用過的區域,就又會觸發Linux進行內存分配,故一段時間內內存佔用會越來越多,直到堆的所有區域都被touch到。
而通過添加JVM參數-XX:+AlwaysPreTouch
,可以讓JVM爲堆申請虛擬內存後,立即把堆全部touch一遍,使得堆區域全都被分配物理內存,而由於Java進程主要活動在堆內,故後續內存就不會有很大變化了,我們另一服務添加了此參數,內存表現如下:
可以看到,內存上漲幅度不到2%,無此參數可以提高內存利用度,加此參數則會使應用運行得更穩定。
如我們之前一服務一週內會有1到2次GC耗時超過2s,當我添加此參數後,再未出現過此情況。這是因爲當無此參數時,若GC訪問到了未讀寫區域,會觸發Linux分配內存,大多數情況下此過程很快,但有極少數情況下會較慢,在GC日誌中則表現爲sys耗時較高。
參考文章
https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/
https://juejin.cn/post/7078624931826794503
https://juejin.cn/post/69033638
https://zhuanlan.zhihu.com/p/652545321