1. 程式人生 > 實用技巧 >記一次堆外記憶體洩漏排查過程

記一次堆外記憶體洩漏排查過程

一、專案介紹

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。發現重啟現象沒有絲毫改善。

聞:檢查專案的健康情況:執行緒、堆內記憶體使用、堆外記憶體使用。

  1. 通過jstack、jstat二連,檢視專案執行緒情況及垃圾回收情況,無執行緒突增情況,無fullGC及頻繁youngGC情況。

  2. 通過top命令發現res使用比jstat命令顯示的堆大小大許多(忘了保留現場了),此時懷疑是堆外記憶體洩漏導致的。為了確定是堆外洩漏而非堆內,分析GC日誌檔案。

    • 藉助easygc對GC日誌進行分析:無fullGC情況(圖中四次fullGC為手動觸發測試的:jmap -histo:live ),且每次youngGC能正常回收物件。
  3. 修改啟動指令碼,將-Xmx引數和-Xms引數置為4G,且增加dump堆引數(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/ ),如果堆內發生oom則能拿到我們心心念唸的堆檔案進行分析。
    但是事與願違,容器多次發生重啟的時候,並沒有發生專案堆內oom,也就是說,並沒有dump下堆現場。此時更加確定,應該是堆外記憶體洩漏。

  4. 配置堆外引數:-XX:MaxDirectMemorySize用於限制堆外記憶體的使用,但是例項的記憶體使用還是膨脹到11G。網上的小夥伴都說這個引數可以用來限制堆外記憶體使用,難道是我沒用好。原本是想用這個引數來觸發堆外記憶體不足的錯誤,好驗證堆外記憶體洩漏這個方向。

    既然這個方向走不通,那就擴大堆外看看是否堆外的洩漏能否回收,還是永久洩漏。

  5. 堆外記憶體洩漏一般由堆內物件引用(最常見由NIO引起,但是這次NIO表示不背鍋),且堆內引用無法被回收引起的(我猜的)。通過第四點圖,自然情況下的youngGC或者手動觸發fullGC後,垃圾回收都能試堆回到正常水平。此處判斷,洩漏的記憶體由可回收的引用所值向。
    那麼問題來了,該部分引用在垃圾回收前就已經大量堆積,導致堆外記憶體空間不足,觸發k8s容器被kill。我猜的,接下來驗證這個想法。

    • 讓運維大佬將k8s例項調整到12G,因為每次重啟時,容器的記憶體佔用幾乎穩定在11g左右。(好吧其實是運維大佬看容器一直重啟,主動要求擴容協助排查,贊一個)
    • 將堆內記憶體限制在7G,堆內使用6G,留給堆外儘可能大的空間。
  6. 例項記憶體調整後,專案的三個例項在持續執行兩天過程中,沒有再出現重啟情況,且每次“預生成資料”後記憶體能正常回收。由此確定,洩漏的堆外記憶體是可回收的,而非永久洩漏,且在堆內引用被回收後即可完成回收。

  7. 上圖為k8s例項資源監控圖,僅能體現容器資源情況,而非容器內專案的堆情況,該圖只能證明堆外記憶體能正常回收,而不是永久洩漏。既然不再重啟了,那麼問題解決了,搞定走人?天真,一個節點12G,沒必要的浪費,運維大佬會殺人祭天的。
    通過jstat命令可觀察,且GC日誌可以得出,堆記憶體使用基本可穩定在4G以內,沒必要浪費12G的空間。

問:目前需要解決的問題是找出堆外記憶體洩漏的原因。

  1. 通過Google查詢堆記憶體排查的文章:今咱們來聊聊JVM 堆外記憶體洩露的BUG是如何查詢的 一次堆外記憶體洩露的排查過程
  2. 借用arthas觀察,當Eden區膨脹到85%+的時候會進行一輪youngGC。所以盯著監控在Eden使用達到80%的時候將堆dump下來(jmap -dump:format=b,file=heap.hprof )。

切:通過對分析工具對堆檔案進行分析:JProfiler(後面會用到)、MemoryAnalyzer

  1. 藉助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壓縮。(具體使用方法直接參照這兩個類上的示例註釋,應該是最權威的使用方式了)以下是本人在專案中的使用:

        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());
        }
    }

  1. 奇怪的是,壓縮與解壓縮的預發都是採用try with resource的格式進行編寫,講道理是會進行流關閉的。網上部分小夥伴推薦使用snapy代替zip,但是我就不~~還是要搞清楚為什麼此處沒有在方法棧彈出之後馬上做資源回收。
  2. 點選進入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.
     */
  1. 所以以上程式碼中將註釋掉的兩行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();
    }
  1. 翻看redis的儲存空間,好吧即使是高峰期的資料也不是很多,是我考慮太多了。

思考:專案發生重啟是在kafka資料擴量後才出現的,那為何擴量前沒有這個問題的出現呢?其實問題一直是存在的,只是資料量小的情況下,引用都在垃圾回收後能正常釋放堆外記憶體。但是擴量後,瞬間的流量增高,產生大量的堆外記憶體使用引用。在下一次垃圾回收之前ReferenceQueue佇列已經堆積了大量的引用,將容器內的堆外記憶體撐爆。

藥:去除壓縮解壓縮動作

去除壓縮與解壓縮動作後,發版觀察。專案的k8s例項資源監控處在合理範圍。

至此,堆外記憶體問題已經解決了。

五、思考與覆盤

問題:使用資源時,保持著資源使用後及時釋放的習慣。該問題便是由壓縮使用有誤引起的,應該也算是低階錯誤了。

由於第一次排查堆外記憶體洩漏的問題,沒有豐富的經驗去鎖定問題點達到快速排查,走了不著彎路。該文章略顯囉嗦,但是主要目的還是想記錄下排查問題的過程。第一次發部落格,寫作思路上有點紊亂,請多多包涵。如果有什麼措辭不當的,還望指出。有什麼好的建議也希望能指點一二。