記一次堆外記憶體洩漏排查過程
一、專案介紹
lz_rec_push_kafka_consume
該專案通過kafka與演算法進行互動,通過push推薦平臺(lz_rec_push_platform)預生成訊息體。
二、問題背景
發現專案的k8s容器會出現重啟現象,重啟時間剛好是push擴量,每小時push資料量擴大5倍左右。
發生問題時,容器配置:CPU:4個,記憶體:堆內3G,堆外1G。
三、問題排查流程:望-聞-問-切
望:檢視監控系統,觀察重啟發生時,容器例項的資源情況
注:容器重啟機制:k8s監控發現“例項”記憶體使用超過申請時,會對容器進行重啟。該動作是直接使用kill -9的,而非通過jvm指令對虛擬機器進行重啟,所以此處別想dump堆。
一開始懷疑是記憶體,但是記憶體不足的話,應該是出現oom的情況。所以先排除堆內記憶體不足的問題。將例項記憶體擴大至:6G,堆內5G,堆外1G。發現重啟現象沒有絲毫改善。
聞:檢查專案的健康情況:執行緒、堆內記憶體使用、堆外記憶體使用。
-
通過jstack、jstat二連,檢視專案執行緒情況及垃圾回收情況,無執行緒突增情況,無fullGC及頻繁youngGC情況。
-
通過top命令發現res使用比jstat命令顯示的堆大小大許多(忘了保留現場了),此時懷疑是堆外記憶體洩漏導致的。為了確定是堆外洩漏而非堆內,分析GC日誌檔案。
- 藉助easygc對GC日誌進行分析:無fullGC情況(圖中四次fullGC為手動觸發測試的:jmap -histo:live ),且每次youngGC能正常回收物件。
-
修改啟動指令碼,將-Xmx引數和-Xms引數置為4G,且增加dump堆引數(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/ ),如果堆內發生oom則能拿到我們心心念唸的堆檔案進行分析。
但是事與願違,容器多次發生重啟的時候,並沒有發生專案堆內oom,也就是說,並沒有dump下堆現場。此時更加確定,應該是堆外記憶體洩漏。 -
配置堆外引數:-XX:MaxDirectMemorySize用於限制堆外記憶體的使用,但是例項的記憶體使用還是膨脹到11G。網上的小夥伴都說這個引數可以用來限制堆外記憶體使用,難道是我沒用好。原本是想用這個引數來觸發堆外記憶體不足的錯誤,好驗證堆外記憶體洩漏這個方向。
-
堆外記憶體洩漏一般由堆內物件引用(最常見由NIO引起,但是這次NIO表示不背鍋),且堆內引用無法被回收引起的(我猜的)。通過第四點圖,自然情況下的youngGC或者手動觸發fullGC後,垃圾回收都能試堆回到正常水平。此處判斷,洩漏的記憶體由可回收的引用所值向。
那麼問題來了,該部分引用在垃圾回收前就已經大量堆積,導致堆外記憶體空間不足,觸發k8s容器被kill。我猜的,接下來驗證這個想法。- 讓運維大佬將k8s例項調整到12G,因為每次重啟時,容器的記憶體佔用幾乎穩定在11g左右。(好吧其實是運維大佬看容器一直重啟,主動要求擴容協助排查,贊一個)
- 將堆內記憶體限制在7G,堆內使用6G,留給堆外儘可能大的空間。
-
例項記憶體調整後,專案的三個例項在持續執行兩天過程中,沒有再出現重啟情況,且每次“預生成資料”後記憶體能正常回收。由此確定,洩漏的堆外記憶體是可回收的,而非永久洩漏,且在堆內引用被回收後即可完成回收。
-
上圖為k8s例項資源監控圖,僅能體現容器資源情況,而非容器內專案的堆情況,該圖只能證明堆外記憶體能正常回收,而不是永久洩漏。既然不再重啟了,那麼問題解決了,搞定走人?天真,一個節點12G,沒必要的浪費,運維大佬會殺人祭天的。
通過jstat命令可觀察,且GC日誌可以得出,堆記憶體使用基本可穩定在4G以內,沒必要浪費12G的空間。
問:目前需要解決的問題是找出堆外記憶體洩漏的原因。
- 通過Google查詢堆記憶體排查的文章:今咱們來聊聊JVM 堆外記憶體洩露的BUG是如何查詢的 一次堆外記憶體洩露的排查過程
- 借用arthas觀察,當Eden區膨脹到85%+的時候會進行一輪youngGC。所以盯著監控在Eden使用達到80%的時候將堆dump下來(jmap -dump:format=b,file=heap.hprof )。
切:通過對分析工具對堆檔案進行分析:JProfiler(後面會用到)、MemoryAnalyzer
- 藉助Memory Analyzer (MAT)工具將堆檔案開。具體使用流程可自行百度,這裡不細講。
- 首先開啟堆檔案
- 進入後看到對分析結果中出現三個明顯的錯誤,問題一跟問題二是由於引入了arthas導致的,直接跳過。
- 看到第三個問題是否眼前一亮,小時候我們學java的時候就知道java.lang.ref.Finalizer是幹嘛的,有興趣的可自行Google,也可看一下:JVM finalize實現原理與由此引發的血案
- 首先開啟堆檔案
- java.lang.ref.Finalizer基本確定回收階段出現問題,進入搜尋待回收的物件。此時我們不是糾結有多少物件沒有被回收,為什麼沒有回收。而是這些沒有回收的物件是否由指向堆外記憶體。
- 點開例項檢視所屬類,此處看到這裡出現3500+的未回收物件指向java.util.zip.ZipFile$ZipFileInflaterInputStream,趕緊Google發現還是有許多小夥伴碰到相同的問題,例如:Java壓縮流GZIPStream導致的記憶體洩露。
- 看到ZipFileInflaterInputStream馬上想起該壓縮在哪使用:push訊息在預生成後儲存redis,批量生成後將訊息進行壓縮再儲存,採用的正是zip壓縮,程式碼示例如下:
遺憾的是專案中使用的壓縮工具為jdk自帶的zip壓縮,有興趣的孩子可以瞭解一下基於Deflater 和 Inflater的zip壓縮。(具體使用方法直接參照這兩個類上的示例註釋,應該是最權威的使用方式了)以下是本人在專案中的使用:
- 點開例項檢視所屬類,此處看到這裡出現3500+的未回收物件指向java.util.zip.ZipFile$ZipFileInflaterInputStream,趕緊Google發現還是有許多小夥伴碰到相同的問題,例如:Java壓縮流GZIPStream導致的記憶體洩露。
byte[] input = log.getBytes();
try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(input.length)) {
final Deflater compressor = new Deflater();
compressor.setInput(input);
compressor.finish();
byte[] buffer = new byte[1024];
int offset = 0;
for (int length = compressor.deflate(buffer, offset, buffer.length); length > 0; length = compressor.deflate(buffer, offset, buffer.length)) {
outputStream.write(buffer, 0, length);
outputStream.flush();
}
//compressor.end();
return Base64Utils.encodeToString(outputStream.toByteArray());
}
}
public static String zipDecompress(final String str) throws Exception {
byte[] input = Base64Utils.decodeFromString(str);
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(input.length)) {
final Inflater decompressor = new Inflater();
decompressor.setInput(input);
byte[] buffer = new byte[1024];
for (int length = decompressor.inflate(buffer); length > 0 || !decompressor.finished(); length = decompressor.inflate(buffer)) {
byteArrayOutputStream.write(buffer, 0, length);
}
//decompressor.end();
return new String(byteArrayOutputStream.toByteArray());
}
}
- 奇怪的是,壓縮與解壓縮的預發都是採用try with resource的格式進行編寫,講道理是會進行流關閉的。網上部分小夥伴推薦使用snapy代替zip,但是我就不~~還是要搞清楚為什麼此處沒有在方法棧彈出之後馬上做資源回收。
- 點選進入Deflater的deflate方法或者Inflater的inflate方法可以發現,二者都是呼叫了“native”方法,詳細程式碼請參照原始碼。兩個工具類均持有end()方法,其註釋如下:
/**
* Closes the compressor and discards any unprocessed input.
* This method should be called when the compressor is no longer
* being used, but will also be called automatically by the
* finalize() method. Once this method is called, the behavior
* of the Deflater object is undefined.
*/
- 所以以上程式碼中將註釋掉的兩行end()方法的呼叫放開即可(這兩行是鎖定問題後加上的)。end()方法在呼叫後即可對堆外使用的記憶體進行釋放,而不是等待jvm垃圾回收來臨之後,將引用回收時再間接使堆外的緩衝區回收。繼續翻看原始碼,不難發現Deflater和Inflater確實重寫了finalize方法,而該方法的實現正是呼叫end方法,這就驗證了我們上面的猜想。眾所周知finalize方法會在物件被回收的時候被呼叫且只會被呼叫一次。所以在物件回收之前,被引用的堆外的空間是無法被回收的。
/**
* Closes the compressor and discards any unprocessed input.
* This method should be called when the compressor is no longer
* being used, but will also be called automatically by the
* finalize() method. Once this method is called, the behavior
* of the Deflater object is undefined.
*/
public void end() {
synchronized (zsRef) {
long addr = zsRef.address();
zsRef.clear();
if (addr != 0) {
end(addr);
buf = null;
}
}
}
/**
* Closes the compressor when garbage is collected.
*/
protected void finalize() {
end();
}
- 翻看redis的儲存空間,好吧即使是高峰期的資料也不是很多,是我考慮太多了。
思考:專案發生重啟是在kafka資料擴量後才出現的,那為何擴量前沒有這個問題的出現呢?其實問題一直是存在的,只是資料量小的情況下,引用都在垃圾回收後能正常釋放堆外記憶體。但是擴量後,瞬間的流量增高,產生大量的堆外記憶體使用引用。在下一次垃圾回收之前ReferenceQueue佇列已經堆積了大量的引用,將容器內的堆外記憶體撐爆。
藥:去除壓縮解壓縮動作
去除壓縮與解壓縮動作後,發版觀察。專案的k8s例項資源監控處在合理範圍。
至此,堆外記憶體問題已經解決了。
五、思考與覆盤
問題:使用資源時,保持著資源使用後及時釋放的習慣。該問題便是由壓縮使用有誤引起的,應該也算是低階錯誤了。
由於第一次排查堆外記憶體洩漏的問題,沒有豐富的經驗去鎖定問題點達到快速排查,走了不著彎路。該文章略顯囉嗦,但是主要目的還是想記錄下排查問題的過程。第一次發部落格,寫作思路上有點紊亂,請多多包涵。如果有什麼措辭不當的,還望指出。有什麼好的建議也希望能指點一二。