JVM堆外內(nèi)存導致的FGC問題排查

問題發(fā)現(xiàn)
服務(wù)在線上環(huán)境頻繁的Full GC。把相關(guān)運行時數(shù)據(jù)區(qū)的監(jiān)控打開,發(fā)現(xiàn)堆外內(nèi)存一直在上升。

我使用的版本是 java8,jvm廠商是orcale hotspot,垃圾回收器使用的CMS+ParNew。
我使用的jvm參數(shù)是:
-Xmx6g
-Xms6g
-XX:NewRatio=1
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
-XX:MaxTenuringThreshold=6
-XX:+ParallelRefProcEnabled
-XX:+CMSParallelRemarkEnabled
-XX:+UseCMSCompactAtFullCollection
-XX:+heapDumpOnOutOfMemoryError
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/export/Logs/gc.log
為了明確排查方向,需要研究堆外內(nèi)存都具體有什么東西。于是我翻看了jvm的虛擬機規(guī)范。解讀如下:
Java虛擬機運行時數(shù)據(jù)區(qū)
Java虛擬機定義了程序執(zhí)行期間使用的各種運行時數(shù)據(jù)區(qū)域。其中一些數(shù)據(jù)區(qū)域是在Java虛擬機啟動時創(chuàng)建的,只有在Java虛擬機退出時才會被銷毀,這部分線程共有。其他數(shù)據(jù)區(qū)域為每個線程。每線程數(shù)據(jù)區(qū)域在創(chuàng)建線程時創(chuàng)建,在線程退出時銷毀,也就是線程私有。
運行時數(shù)據(jù)區(qū)分為以下幾個部分:
1、PC寄存器(The pc Register)
每個線程一個,以保存當前執(zhí)行指令的地址。一旦執(zhí)行了指令,PC寄存器將用下一條指令更新。
2、虛擬機棧( Java Virtual Machine Stacks)
每個Java虛擬機線程都有一個私有Java虛擬機堆棧,與線程同時創(chuàng)建。虛擬機棧存儲棧幀,它保存局部變量和部分結(jié)果。
虛擬機??赡軙霈F(xiàn)Java虛擬機將拋出StackOverflowerError。
3、堆(Heap)
Java虛擬機線程之間共享堆,堆只有一個。堆是為所有類實例和數(shù)組分配內(nèi)存的運行時數(shù)據(jù)區(qū)域。這也是我們創(chuàng)建的對象放置的區(qū)域。是最大的,最需要調(diào)優(yōu)的地方。
堆是在虛擬機啟動時創(chuàng)建的。對象的堆存儲由垃圾收集器回收;對象永遠不會顯式解除分配。
如果計算需要的堆超過了自動存儲管理系統(tǒng)的可用堆,Java虛擬機會拋出OutOfMemoryError。
4、方法區(qū)(Method Area)
存儲所有類級別的數(shù)據(jù),包括靜態(tài)變量所有線程共享。Java虛擬機只有一個方法區(qū)。存儲的有類結(jié)構(gòu),例如運行時常量池、字段和方法數(shù)據(jù),以及方法和構(gòu)造函數(shù)的代碼,包括類和實例初始化以及接口初始化中使用的特殊方法。
5、運行時常量池(Run-Time Constant Pool)
運行時常量池是類文件中常量池表的每類或每接口運行時表示形式。它包含多種常量,從編譯時已知的數(shù)字文本到必須在運行時解析的方法和字段引用。運行時常量池的功能類似于傳統(tǒng)編程語言的符號表,盡管它包含比典型符號表更廣泛的數(shù)據(jù)范圍。
這段我抄的,為了保持完整性,運行時常量池其實是方法區(qū)的一部分。
6、本地方法棧(Native Method Stacks)
存儲本地方法信息,線程私有。
整體結(jié)構(gòu)表示如下?

問題:方法區(qū)和元空間有什么關(guān)系?
簡單理解,方法區(qū)是java的定義,而元空間則是hotspot虛擬機在1.8及其以后的實現(xiàn)。在1.7之前叫永久代(永久代還包含了部分老年對象),如果使用java8的話忽略永久代就行了。
根據(jù)jvm的規(guī)范,方法區(qū)內(nèi)存儲的都是jvm類級別的數(shù)據(jù),包括什么構(gòu)造方法,什么常量池什么的。那什么操作會使得這方面一直在上漲呢?帶著問題,一步步搞唄。
簡單嘗試
首先先定死m(xù)etaspce的大小,不讓他動態(tài)擴容,因為元空間每次調(diào)整大小都會進行一次full gc。
jvm啟動參數(shù)新增。
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=512m
但是發(fā)現(xiàn)并沒有用。
是否能從堆看出些端倪?
堆外內(nèi)存,沒有特別好的查看方法。我決定還是把堆內(nèi)存dump下來看看,看能否通過堆內(nèi)存,看出一些貓膩來。
將堆dump下來進行分析。
使用命令 jps 找到j(luò)ava進程pid,指定生成文件的path。
jmap -dump:file=/path ${pid}
dump完畢后。
借助工具進行查詢 首先使用mat,官方網(wǎng)站:https://www.eclipse.org/mat/。


這邊看到了很多Netty的PoolThreaCache。
聯(lián)想到netty使用了直接內(nèi)存,是否和這個有關(guān)呢?
為此查詢了大量資料,找到了一個參數(shù):-Dio.netty.maxDirectMemory 。
這個參數(shù)大概意思是調(diào)整netty堆外內(nèi)存,通過它有三個取值,無論調(diào)成什么都沒辦法阻止堆外內(nèi)存的上漲。其實在這就有點無頭蒼蠅亂撞了。
確實,只有兩種情況會導致netty相關(guān)的堆外內(nèi)存上漲。
1、要么是netty有bug 。
2、要么是使用方法不對。
netty有bug,這個可能性就算了吧。使用的版本也不是最新的,也沒有直接引用netty包,都是通過例如http-client或者rpc框架引入的netty。
使用方法不對?在http client或者rpc服務(wù)的部分代碼排查了一遍,基本上都是比較簡單的用法,并沒有直接設(shè)置很怪的參數(shù),或者很非常規(guī)的操作。
在這就確實在堆里面找不到有用的線索了。
找到原因
貌似確實沒轍了。
隨后我請教了我司的超級大佬:森哥。森哥給我要了相關(guān)權(quán)限后,上去機器一頓操作。推測可能是C2 Compiler或者什么即時編譯導致的問題,因為堆外都是jvm級別的數(shù)據(jù),常規(guī)的排查確實比較難找到線索。
聽完后聯(lián)想到堆外不就是方法區(qū)嗎,我用的java8 hotspot虛擬機,也就是元空間了。
代碼里面會有什么導致元空間上漲呢?
元空間是存儲jvm級別的數(shù)據(jù),是否有很多類加載?
帶著這個猜想,找到相應(yīng)的參數(shù) -verbose:class,這個會將類加載全部打印出來。
如下圖:


發(fā)現(xiàn)有非常多的ASMAccessorImpl_,而且是不會停止,一直在加載。
厚禮蟹,這就查到了原因。
那ASM是什么,如果研究過spring,就知道在aop擴展動態(tài)生成字節(jié)碼,最底層其實就是ASM生成的,其實是一個字節(jié)碼編輯框架。官網(wǎng):https://asm.ow2.io/。
也就是說,我的代碼有一個地方一直在動態(tài)生成類字節(jié)碼,加載到方法區(qū)。從而導致堆外內(nèi)存一直在上漲,從而導致full gc。
代碼修改
那怎么定位到是哪段代碼?
這個簡單,打開idea,double shift,調(diào)search everywhere。

排查到是mvel這個依賴框架生成的。
關(guān)于mvel,其實是spel差不多,表達式解析引擎。在項目中,mvel的使用我們只用了兩行代碼。
MVEL.executeExpression()
MVEL.compileExpression()
然后我們也有把編譯完的進行緩存,按道理說不會一直生成類的。因為mvel這個框架實在是相關(guān)文檔太少,沒人維護的感覺,抱著死馬當活馬醫(yī)的態(tài)度,去github上提一個issue,然后自己同時接著排查。

幸運的是這個框架還沒死絕,還有人回復(fù)。
大概意思是說,我問為什么使用你們的mvel會導致我jvm出現(xiàn)oom錯誤(頻繁的full gc),另外如果說每次編譯相同的內(nèi)容的話,為什么沒有框架層面緩存起來?;卮鹫f是需要自己緩存的。
也就是我的代碼還是緩存失效了。
找到緩存的那一行,使用的是map,用key去查找的時候,發(fā)現(xiàn)用的是contains,而沒有用containsKey。這就導致了永遠查不到,也就導致了永遠會重新編譯。

經(jīng)過修改后,問題得以解決。

一條平平的線,并且沒有full gc,皆大歡喜

總結(jié)
堆外內(nèi)存有點難搞,難以和代碼聯(lián)系起來。提供一個思路:可通過-verbose:class查看類加載的情況,然后具體分析。















 
 
 










 
 
 
 