JVM堆外內(nèi)存導(dǎo)致的FGC問題排查
問題發(fā)現(xiàn)
服務(wù)在線上環(huán)境頻繁的Full GC。把相關(guān)運(yù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的虛擬機(jī)規(guī)范。解讀如下:
Java虛擬機(jī)運(yùn)行時數(shù)據(jù)區(qū)
Java虛擬機(jī)定義了程序執(zhí)行期間使用的各種運(yùn)行時數(shù)據(jù)區(qū)域。其中一些數(shù)據(jù)區(qū)域是在Java虛擬機(jī)啟動時創(chuàng)建的,只有在Java虛擬機(jī)退出時才會被銷毀,這部分線程共有。其他數(shù)據(jù)區(qū)域?yàn)槊總€線程。每線程數(shù)據(jù)區(qū)域在創(chuàng)建線程時創(chuàng)建,在線程退出時銷毀,也就是線程私有。
運(yùn)行時數(shù)據(jù)區(qū)分為以下幾個部分:
1、PC寄存器(The pc Register)
每個線程一個,以保存當(dāng)前執(zhí)行指令的地址。一旦執(zhí)行了指令,PC寄存器將用下一條指令更新。
2、虛擬機(jī)棧( Java Virtual Machine Stacks)
每個Java虛擬機(jī)線程都有一個私有Java虛擬機(jī)堆棧,與線程同時創(chuàng)建。虛擬機(jī)棧存儲棧幀,它保存局部變量和部分結(jié)果。
虛擬機(jī)??赡軙霈F(xiàn)Java虛擬機(jī)將拋出StackOverflowerError。
3、堆(Heap)
Java虛擬機(jī)線程之間共享堆,堆只有一個。堆是為所有類實(shí)例和數(shù)組分配內(nèi)存的運(yùn)行時數(shù)據(jù)區(qū)域。這也是我們創(chuàng)建的對象放置的區(qū)域。是最大的,最需要調(diào)優(yōu)的地方。
堆是在虛擬機(jī)啟動時創(chuàng)建的。對象的堆存儲由垃圾收集器回收;對象永遠(yuǎn)不會顯式解除分配。
如果計算需要的堆超過了自動存儲管理系統(tǒng)的可用堆,Java虛擬機(jī)會拋出OutOfMemoryError。
4、方法區(qū)(Method Area)
存儲所有類級別的數(shù)據(jù),包括靜態(tài)變量所有線程共享。Java虛擬機(jī)只有一個方法區(qū)。存儲的有類結(jié)構(gòu),例如運(yùn)行時常量池、字段和方法數(shù)據(jù),以及方法和構(gòu)造函數(shù)的代碼,包括類和實(shí)例初始化以及接口初始化中使用的特殊方法。
5、運(yùn)行時常量池(Run-Time Constant Pool)
運(yùn)行時常量池是類文件中常量池表的每類或每接口運(yùn)行時表示形式。它包含多種常量,從編譯時已知的數(shù)字文本到必須在運(yùn)行時解析的方法和字段引用。運(yùn)行時常量池的功能類似于傳統(tǒng)編程語言的符號表,盡管它包含比典型符號表更廣泛的數(shù)據(jù)范圍。
這段我抄的,為了保持完整性,運(yùn)行時常量池其實(shí)是方法區(qū)的一部分。
6、本地方法棧(Native Method Stacks)
存儲本地方法信息,線程私有。
整體結(jié)構(gòu)表示如下?
問題:方法區(qū)和元空間有什么關(guān)系?
簡單理解,方法區(qū)是java的定義,而元空間則是hotspot虛擬機(jī)在1.8及其以后的實(shí)現(xiàn)。在1.7之前叫永久代(永久代還包含了部分老年對象),如果使用java8的話忽略永久代就行了。
根據(jù)jvm的規(guī)范,方法區(qū)內(nèi)存儲的都是jvm類級別的數(shù)據(jù),包括什么構(gòu)造方法,什么常量池什么的。那什么操作會使得這方面一直在上漲呢?帶著問題,一步步搞唄。
簡單嘗試
首先先定死m(xù)etaspce的大小,不讓他動態(tài)擴(kuò)容,因?yàn)樵臻g每次調(diào)整大小都會進(jìn)行一次full gc。
jvm啟動參數(shù)新增。
-XX:MetaspaceSize=512m
-XX:MaxMetaspaceSize=512m
但是發(fā)現(xiàn)并沒有用。
是否能從堆看出些端倪?
堆外內(nèi)存,沒有特別好的查看方法。我決定還是把堆內(nèi)存dump下來看看,看能否通過堆內(nèi)存,看出一些貓膩來。
將堆dump下來進(jìn)行分析。
使用命令 jps 找到j(luò)ava進(jìn)程pid,指定生成文件的path。
jmap -dump:file=/path ${pid}
dump完畢后。
借助工具進(jìn)行查詢 首先使用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)存的上漲。其實(shí)在這就有點(diǎn)無頭蒼蠅亂撞了。
確實(shí),只有兩種情況會導(dǎo)致netty相關(guān)的堆外內(nèi)存上漲。
1、要么是netty有bug 。
2、要么是使用方法不對。
netty有bug,這個可能性就算了吧。使用的版本也不是最新的,也沒有直接引用netty包,都是通過例如http-client或者rpc框架引入的netty。
使用方法不對?在http client或者rpc服務(wù)的部分代碼排查了一遍,基本上都是比較簡單的用法,并沒有直接設(shè)置很怪的參數(shù),或者很非常規(guī)的操作。
在這就確實(shí)在堆里面找不到有用的線索了。
找到原因
貌似確實(shí)沒轍了。
隨后我請教了我司的超級大佬:森哥。森哥給我要了相關(guān)權(quán)限后,上去機(jī)器一頓操作。推測可能是C2 Compiler或者什么即時編譯導(dǎo)致的問題,因?yàn)槎淹舛际莏vm級別的數(shù)據(jù),常規(guī)的排查確實(shí)比較難找到線索。
聽完后聯(lián)想到堆外不就是方法區(qū)嗎,我用的java8 hotspot虛擬機(jī),也就是元空間了。
代碼里面會有什么導(dǎo)致元空間上漲呢?
元空間是存儲jvm級別的數(shù)據(jù),是否有很多類加載?
帶著這個猜想,找到相應(yīng)的參數(shù) -verbose:class,這個會將類加載全部打印出來。
如下圖:
發(fā)現(xiàn)有非常多的ASMAccessorImpl_,而且是不會停止,一直在加載。
厚禮蟹,這就查到了原因。
那ASM是什么,如果研究過spring,就知道在aop擴(kuò)展動態(tài)生成字節(jié)碼,最底層其實(shí)就是ASM生成的,其實(shí)是一個字節(jié)碼編輯框架。官網(wǎng):https://asm.ow2.io/。
也就是說,我的代碼有一個地方一直在動態(tài)生成類字節(jié)碼,加載到方法區(qū)。從而導(dǎo)致堆外內(nèi)存一直在上漲,從而導(dǎo)致full gc。
代碼修改
那怎么定位到是哪段代碼?
這個簡單,打開idea,double shift,調(diào)search everywhere。
排查到是mvel這個依賴框架生成的。
關(guān)于mvel,其實(shí)是spel差不多,表達(dá)式解析引擎。在項(xiàng)目中,mvel的使用我們只用了兩行代碼。
MVEL.executeExpression()
MVEL.compileExpression()
然后我們也有把編譯完的進(jìn)行緩存,按道理說不會一直生成類的。因?yàn)閙vel這個框架實(shí)在是相關(guān)文檔太少,沒人維護(hù)的感覺,抱著死馬當(dāng)活馬醫(yī)的態(tài)度,去github上提一個issue,然后自己同時接著排查。
幸運(yùn)的是這個框架還沒死絕,還有人回復(fù)。
大概意思是說,我問為什么使用你們的mvel會導(dǎo)致我jvm出現(xiàn)oom錯誤(頻繁的full gc),另外如果說每次編譯相同的內(nèi)容的話,為什么沒有框架層面緩存起來?;卮鹫f是需要自己緩存的。
也就是我的代碼還是緩存失效了。
找到緩存的那一行,使用的是map,用key去查找的時候,發(fā)現(xiàn)用的是contains,而沒有用containsKey。這就導(dǎo)致了永遠(yuǎn)查不到,也就導(dǎo)致了永遠(yuǎn)會重新編譯。
經(jīng)過修改后,問題得以解決。
一條平平的線,并且沒有full gc,皆大歡喜
總結(jié)
堆外內(nèi)存有點(diǎn)難搞,難以和代碼聯(lián)系起來。提供一個思路:可通過-verbose:class查看類加載的情況,然后具體分析。