公司內部一次關於OOM故障復盤分享

最近筆者有點忙,這次OOM事故發生過去兩周前,記得筆者那天正帶着家人在外地玩,正中午跟友人吃飯的時候,釘釘連續告警爆表,接着就是釘釘電話(顯示廣東抬頭)一看就知道BBQ了,又一次故障發生了,今天把那次故障復盤一下,做個總結,也給小夥伴分享一下 我是怎麼從接到告警開始,怎麼一步一步分析故障,然後定位到問題,最後完美解決,成功上線解決問題的。

 

 

上述告警內容,由於筆者所在服務是用CMS垃圾回收器,當其GC次數太頻繁,達到公司監控平台設置的閾值時,就會通過釘釘通知告知開發者,發送到對應的控制台上。這個異常先從字面意義上來說倒也比較明顯,如果老年代裏的對象太多,無法提供空間容納年輕代傳遞過來的對象的時候,就會觸發FULL GC。

這裡我們先簡單分析一下,對象什麼情況下會進入老年代,以及老年代又是在什麼情況下會觸發FULL GC?只有先知道了原理性東西,你才能帶着思路去分析,真實線上場景屬於對應哪種情況

首先科普一下對象什麼情況下會進入老年代?

1)躲過15次GC之後進入老年代

public class Kafka{
//只要Kafka這個類存在,r這個靜態變量就會一直存在
private static ReplicManager r=new ReplicManager();
}
像上面這塊代碼,成員變量是GCROOT引用,所以一直不會回收不掉;這個對象每次從Eden躲過一次到Survivor區域中,它的年齡就增長1歲,當年齡增加到15歲時候,就會轉移到老年代裏。
 
2)動態對象年齡判斷
意思是如果Survivor空間中相同年齡的所有對象大小總和大於Survivor空間的一半,年齡大於等於該年齡的對象會直接進入老年代
 
3)大對象直接進入老年代
 
4)空間擔保策略
在發生MinorGc之前,JVM會檢查老年代的最大連續可用空間是否大於新生代所有對象總空間,如果不成立,那麼JVM會查看一個參數值查看是否允許擔保,如果之前配置了允許,那麼會檢查老年代最大可用空間是否大於歷次晉陞到老年代對象的平均大小,如果大於將嘗試進行一次Minor GC;如果小於或者參數設置不允許冒險,那麼就會進行一次FULL GC。
 
那老年代又是在什麼情況下會觸發FULL GC?
1.也是上面第四種情況,就不寫了
2.yongGC之後如果滿足上述分析的[#首先科普一下對象什麼情況下會進入老年代?]其中一種情況,那麼進入老年代,但這個時候如果老年代空間不足,就會觸發FULLGC
3.如果老年代內存使用率超過92%其實會觸發fullgc的
 
好了先科普一下相關知識點,利於後續的分析做鋪墊。下面開始逐步分析具體具體原因,到底是什麼大對象充滿了老年代內存區域。
 
首先一碰到特別是線上這種重大事故,第一思路是保留線程,然後快速止血。這也是筆者所在公司對於開發的其中一條軍規(估計之前出現過太多的這種事故,形成規範了😄)。
那我也按照這種思想,通過日誌分析,馬上知道這個是有同時在批量導數據,導致入口流量很大,先聯繫同時快速止血,暫停導入操作。果然沒多久 就不報警了,告警恢復通知一個接一個過來。
 
接下來我們開始分析到底是什麼對象快速把老年代給填滿了,相應入口在哪裡。
先看業務監控大圖:

 

 

現象是從下午4點開始內存有一波快速增長。
通過阿里的Arthas分析工具,通過命令dashboard查看當下系統的實時信息。
(下面這張圖已經是止血之後文檔的圖了,但老年代還是填充了不少對象的)

 

 

線上由於比較麻煩dump線程。而且現場已經過去了,所以我還是自己寫了一段壓測代碼(類似Jmeter效果),來壓測相應的總入口,看看具體是哪個對象佔了大內存

 

 

 很明顯是有一個nashorn相關對象佔據了比較大的佔比。那這個對象其實對應筆者的程序是

ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn");
Compilable compEngine = (Compilable) engine;
try {
CompiledScript compile = compEngine.compile(script);
}catch(Exception e){

}

簡單來說,Nashorn的編譯入口可以從 Context.compileScript() 開始看:[ JavaScript源碼 ] -> ( 語法分析器 Parser ) -> [ 抽象語法樹(AST) ir ] -> ( 編譯優化 Compiler ) -> [ 優化後的AST + Java Class文件(包含Java位元組碼) ] -> JVM加載和執行生成的位元組碼 -> [ 運行結果 ]

此過程是十分耗時的,每次執行eval 去運行js ,都需要編譯成位元組碼、然後加載執行。同時會將編譯過的位元組碼緩存起來,以便後續使用,因此加載的類會長時間存活,佔用很大的內存空間。

所以筆者嘗試將CompiledScript這一對象第一次編譯完後,本地緩存起來用

private static Map<Long, CompiledScript> scriptMap = new ConcurrentHashMap<>();
緩存起來,下一次如果已經存在,就直接拿來用。
重新壓測後效果還是明顯的

 

 

總結
線上場景 特別對於一些新的框架或技術 如果你的流量很大,筆者那時參與了這個項目,工期特別短,功能又特別多,想着先上線,下一步再做壓測,想不到等不到下一步問題就暴露出來了😄