有個生產環境CRM業務應用服務,情況有些奇怪,監控數據顯示內存異常。內存使用率99.%多。通過生產監控看板發現,CRM內存超配或內存泄漏的現象,下面分析一下這個問題過程記錄。
1、服務器硬件配置部署情況
生產服務器採用阿里雲ECS機器,配置是4HZ、8GB,單個應用服務獨佔,CRM應用獨立部署,即單臺服務器僅部署一個java應用服務。
用了4個節點4臺機器,每臺機器都差不多情況。
監控看板如下:
2、應用啓動參數配置
應用啓動配置參數:
/usr/bin/java
-javaagent:/home/agent/skywalking-agent.jar
-Dskywalking.agent.service_name=xx-crm
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/xx-crm.hprof
-Dspring.profiles.active=prod
-server -Xms4884m -Xmx4884m -Xmn3584m
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=512m
-XX:CompressedClassSpaceSize=128m
-jar /home/xxs-crm.jar
堆內:最大最小堆內存4884m約4.8G左右,其中新生代-Xmn3584m 約3.5G左右,
非堆: 元數據區配置 512M,類壓縮空間 128M, Code Cache代碼緩存區240M(沒有配置參數,通過監控看板看到的)。
3、內存分佈統計
從監控看板的數據來看,我們簡單統計一下內存分配數據情況。
通過JVM配置參數和監控看板數據可知:
堆內存:4.8G
非堆內存:(Metaspace)512M+(CompressedClassSpace)128M+(Code Cache)240M約等1GB左右。
堆內存(heap)+非堆內存(nonHeap)=5.8G
8GB物理內存除去操作系統本身佔用大概500M。即除了操作系統本身佔用之外,還有7.5G可用內存。
但是 7.5-5.8=1.7GB,起碼至少還有1~2GB空閒才合理呀!怎麼內存佔用率99%多,就意味着有1~2G不知道誰佔去了,有點詭異!
4、問題分析
先看一下JVM內存模型,環境是使用JDK8
JVM內存數據分區:
堆heap結構:
堆大家都比較容易理解的,也是java程序接觸得最多的一塊,不存在什麼數據上統計錯誤,或佔用不算之類的。
那說明額外佔用也非堆裏面,只不過沒有統計到非堆裏面去,曾經一度懷疑監控prometheus展示的數據有誤。
先看一下dump文件數據,這裏使用MAT工具(一個開源免費的內存分析工具,個人認爲比較好用,推薦大家使用。下載地址:https://www.eclipse.org/mat/downloads.php)。
通過下載內存dump鏡像觀察到
有個offHeapStore,這個東西堆外內存,可以初步判斷是 ehcahe引起的。
通過ehcahe源碼分析,發現ehcache裏面也使用了netty的NIO方法內存,ehcache磁盤緩存寫數據時會用到DirectByteBuffer。
DirectByteBuffer是使用非堆內存,不受GC影響。
當有文件需要暫存到ehcache的磁盤緩存時,使用到了NIO中的FileChannel來讀取文件,默認ehcache使用了堆內的HeapByteBuffer來給FileChannel作爲讀取文件的緩衝,FileChannel讀取文件使用的IOUtil的read方法,針對HeapByteBuffer底層還用到一個臨時的DirectByteBuffer來和操作系統進行直接的交互。
ehcache使用HeapByteBuffer作爲讀文件緩衝:
IOUtil對於HeapByteBuffer實際會用到一個臨時的DirectByteBuffer來和操作系統進行交互。
DirectByteBuffer泄漏根因分析
默認情況下這個臨時的DirectByteBuffer會被緩存在一個ThreadLocal的bufferCache裏不會釋放,每一個bufferCache有一個DirectByteBuffer的數組,每次當前線程需要使用到臨時DirectByteBuffer時會取出自己bufferCache裏的DirectByteBuffer數據,選取一個不小於所需size的,如果bufferCache爲空或者沒有符合的,就會調用Bits重新創建一個,使用完之後再緩存到bufferCache裏。
這裏的問題在於 :這個bufferCache是ThreadLocal的,意味着極端情況下有N個調用線程就會有N組 bufferCache,就會有N組DirectByteBuffer被緩存起來不被釋放,而且不同於在IO時直接使用DirectByteBuffer,這N組DirectByteBuffer連GC時都不會回收。我們的文件服務在讀寫ehcache的磁盤緩存時直接使用的tomcat的worker線程池,
這個worker線程池的配置上限是2000,我們的配置中心上的配置的參數:
所以,這種隱藏的問題影響所有使用到HeapByteBuffer的地方而且很隱祕,由於在CRM服務中大量使用了ehcache存在較大的sizeIO且調用線程比較多的場景下容易暴露出來。
獲取臨時DirectByteBuffer的邏輯:
bufferCache從ByteBuffer數組裏選取合適的ByteBuffer:
將ByteBuffer回種到bufferCache:
NIO中的FileChannel、SocketChannel等Channel默認在通過IOUtil進行IO讀寫操作時,除了會使用HeapByteBuffer作爲和應用程序的對接緩衝,但在底層還會使用一個臨時的DirectByteBuffer來和系統進行真正的IO交互,爲提高性能,當使用完後這個臨時的DirectByteBuffer會被存放到ThreadLocal的緩存中不會釋放,當直接使用HeapByteBuffer的線程數較多或者IO操作的size較大時,會導致這些臨時的DirectByteBuffer佔用大量堆外直接內存造成泄漏。
那麼除了減少直接調用ehcache讀寫的線程數有沒有其他辦法能解決這個問題?併發比較高的場景下意味着減少業務線程數並不是一個好辦法。
在Java1.8_102版本開始,官方提供一個參數jdk.nio.maxCachedBufferSize,這個參數用於限制可以被緩存的DirectByteBuffer的大小,對於超過這個限制的DirectByteBuffer不會被緩存到ThreadLocal的bufferCache中,這樣就能被GC正常回收掉。唯一的缺點是讀寫的性能會稍差一些,畢竟創建一個新的DirectByteBuffer的代價也不小,當然通過測試驗證對比分析,性能也沒有數量級的差別。
增加參數:
-XX:MaxDirectMemorySize=1600m
-Djdk.nio.maxCachedBufferSize=500000 ---注意不能帶單位
就是調整了-Djdk.nio.maxCachedBufferSize=500000(注意這裏是字節數,不能用m、k、g等單位)。
增加調整參數之後,運行一段時間,持續觀察整體DirectByteBuffer穩定控制在1.5G左右,性能也幾乎沒有衰減。一切恢復正常,再看監控看板沒有看到佔滿內存告警。
5、調整應用啓動參數配置
業務系統調整後的啓動命令參數如下:
java
-javaagent:/home/agent/skywalking-agent.jar
-Dskywalking.agent.service_name=xx-crm
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/xx-crm.hprof
-Dspring.profiles.active=prod
-server -Xms4608m -Xmx4608m -Xmn3072m
-XX:MetaspaceSize=300m
-XX:MaxMetaspaceSize=512m
-XX:CompressedClassSpaceSize=64m
-XX:MaxDirectMemorySize=1600m
-Djdk.nio.maxCachedBufferSize=500000
-jar /home/xx-crm.jar
6、總結
碰到這類非堆內存問題有兩種解決辦法:
1、如果允許的話減少IO線程數。
2、調整配置應用啓動參數,-Djdk.nio.maxCachedBufferSize=xxx 記住 -XX:MaxDirectMemorySize 參數也要配置上。
如果不配置 -XX:MaxDirectMemorySize 這個參數(最大直接內存),JVM就默認取-Xmx的值當作它的最大值(可能就會像我一樣遇到超配的情況)。
參考文章《Troubleshooting Problems With Native (Off-Heap) Memory in Java Applications》