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

星巴克男孩發表於2022-03-12

最近筆者有點忙,這次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<>();
快取起來,下一次如果已經存在,就直接拿來用。
重新壓測後效果還是明顯的

 

 

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


 
 
 

 

相關文章