小心遞歸中內存泄漏

小心遞歸中內存泄漏

前段時間由於業務需要,需要從數據庫中查詢出來所有滿足條件的數據,然後導入到文件中。於是隨便寫了個程序,查詢出所有滿足條件然後再寫入文件。但是實際上線後卻發現,程序剛開始運行馬上看到部分數據寫入到文件,但是後面運行越來越慢,於是對此分析排查了一下。

應用環境

JDK 1.7 + Spring 4.3 + mybatis + oracle

問題排查

查詢以及寫入文件僞代碼如下:

    private void queryAllData(Request request, List querData, int count, String path, List allData) {
        if (CollectionUtils.isEmpty(querData)) {
            return;
        }
        allData.addAll(querData);
        // 總 List 大於一定指定數量將數據刷新到文件
        if (allData.size() > 20000) {
            saveToFile(request, allData, path);
        }
        // 判斷下一個偏移量 是否大於 總數
        request.setPageNo(request.getPageNo() + 1);
        // 查詢下一頁數據
        List  newQueryData = queryDao.selectDataByPage(request);
        queryAllData(request, newQueryData, count, path, allData);
    }

其中 queryDao.selectDataByPage 爲一個分頁查找方法。這個方法目的就在於遞歸查找分頁數據,如果某一頁數據爲空,就代表查詢結束,此時已查詢出所有數據。

爲什麼不直接執行 select * from table where a=xx 類似的數據直接查出所有數據?

因爲寫程序之前,查詢了一下滿足條件的數據總共有 200 w 數據,這樣如果直接一把查詢出所有數據,主要擔心堆內存直接佔滿,導致 OOM 錯誤。

寫完代碼,部署到線上,然後執行導出數據,就放着不管,幹其他事。過一段時間回來看數據導出結果,這個時候大吃一驚,程序竟然還沒有結束,數據也才導出 3/4 左右。這個時候意識到程序肯定存在問題,於是仔細檢查了一遍代碼,也沒看出什麼。

沒辦法,這個時候只能分析線上程序 GC 情況了,幸好開啓了打印 GC 日誌的選項。拿到 GC 日誌文件後,由於不太精通 GC 日誌詳細內容,只能借靠外部力量了。GC 日誌分析網站,該網站可以分析 GC 日誌,然後可以查看各個時間點堆內存佔用情況。分析情況如圖。

Heap after gc

這張圖爲 GC 之後堆內存佔用情況。可以看出堆內存在 Full GC 之後並沒有很快的降下來且很快下一次 Full GC 就開始了。這樣大致可以看出,程序沒有在期待時間內運行結束,就是由於堆內被佔用過多,持續引起Full GC,應用程序線程持續被掛起。然後我們再看堆內存老年代佔用情況。

老年代內存佔用情況

如上圖,堆內存老年代佔用空間持續上升直到接近佔滿,引起 Full GC,並沒有緩解這種情況,之後內存佔用一直接近到佔滿。

綜上,我們可以得知程序出現了內存泄漏。

知道了原因,我們就好順着找到問題。又順着捋了一遍代碼,可惜的是並沒有看出問題。難道是 allData 數據集合越來越大,然後導致該現象?仔細查看了 saveToFile 代碼邏輯。

        List<String> lines = Lists.newArrayListWithExpectedSize(allData.size());
        for (Data data : allData) {
            String line = process(data);
            lines.add(line);
        }
        String fileName = "xx.txt";
         try {
            log.info("文件開始輸出,輸出行數{}", lines.size());
            FileUtils.writeLines(new File(fileName), "utf-8", lines, true);
            allData.clear();
            lines = null;
        } catch (IOException e) {
            log.error("文件輸出失敗", e);
            // 輸出失敗,先不管了,將數據繼續保存集合中
        }

可以看到,數據一旦寫入到文件中,allData 集合立刻清空,所以不可能是該問題導致。

看了好幾遍代碼之後,還是無法確定問題原因。最後一遍查看代碼,靈關一現,不會是 newQueryData 導致的問題吧?嘗試把這裏代碼改成下面方式。


    private void queryAllData(Request request, List querData, int count, String path, List allData) {
        if (CollectionUtils.isEmpty(querData)) {
            return;
        }
        allData.addAll(querData);
        // queryData 放入到 allData 中後,將 querData 結合清空。
        querData.clear();
        // 總 List 大於一定指定數量將數據刷新到文件
        if (allData.size() > 20000) {
            saveToFile(request, allData, path);
        }
        // 判斷下一個偏移量 是否大於 總數
        request.setPageNo(request.getPageNo() + 1);
        // 查詢下一頁數據
        newQueryData = queryDao.selectDataByPage(request);
        queryAllData(request, newQueryData, count, path, allData);

改完代碼,立刻部署,開始運行程序。這個時候查看堆內存佔用情況,就可以知道改動是否有效。這裏推薦一個方便查看 JVM 進程信息的工具 vjtop。可以快速查看堆內存佔用情況。

運行 vjtop 之後,一直盯着堆內存佔用情況。然後發現 eden 空間持續上升直到接近到滿,然後發生 Minor GC ,eden 空間迅速清空。 old 區內存也沒有一直佔用接近到滿這麼誇張。大概佔用 1/5 內存。改善情況如想象中一致,等待一定時間後,數據導出完畢。

分析

現在我們分析爲什麼出現內存泄漏。

我們知道 jvm 運行時,內存區分爲 堆,虛擬機棧,方法區等。上面我們發生的現象就與虛擬機棧有關。

什麼事虛擬機棧?

摘錄深入 Java 虛擬機一書解釋

虛擬機棧描述的是 Java 方法執行的內存模型:每個方法執行時都會創建一個棧幀用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。每一個方法從調用直至執行完後的過程,就對應一個棧幀在虛擬機棧中入棧到出棧的過程。

Java 線程執行方法時,jvm 虛擬機棧數據結構如圖所示。

虛擬棧

可以看出,我們在調用函數 1 時,就將該棧幀壓如棧中。函數 1 調用函數 2 時,也將該棧幀壓入棧中。處於棧中的棧幀包含局部變量表,操作數幀等,而局部變量表包含基本數據類型,以及對象引用指針。對象指針指向堆內存對象。就是因爲對象引用指針,導致我們上面情況。爲何這麼說那。我們再看下面這張圖。

遞歸中棧

我們可以看到,棧中每個方法 newQueryData 都指向堆中真正的對象。由於遞歸執行時,前面的方法都壓到棧中,newQueryData 一直還指向堆中對象,然後 GC 時,由於對象還處於被引用,虛擬機判定該對象存活,所以不清理這些對象。隨着遞歸方法越來越深入,堆積的 newQueryData 越來越多,量表引起質變,導致堆內存被佔滿,引發虛擬機持續 GC。但是每次 GC 之後卻無法騰出空間。最後我們看到的現象就是程序執行很慢很慢。

 總結

這個問題本質看起來不是很難,但是實際發生的時候排查問題着實花費不少時間。下面我們總結一下這個過程。

  1. 如果程序實際運行起來與預想差距太大,那麼不用想了,肯定哪裏出問題了,趕快登上機器查看吧。
  2. 程序運行必要節點的日誌輸出需要打印。上面程序本來剛開始寫的時候,由於主觀意思,想想沒那麼難,很快就擼完部署了。最後查看日誌,由於沒有必要的日誌輸出,都不知道程序卡在那了。
  3. 需要了解一些 JVM 相關工具,可以及時查看 JVM 相關情況,如內存使用情況。如本文的例子,實際上我們可以 dump 內存,然後分析哪裏發生了內存泄漏。很不幸的是,這方面本人只是處於瞭解層面,用的時候卻不知道如何下手,只好求助於一些現成開源工具完成。之後需要好好補這方面操作能力,哈哈哈。
  4. 本文如果使用 while 循環代替遞歸方式,問題可能更快定位。遞歸中的內存泄漏可能更加隱蔽,很容易被我們忽略,同學們下次再寫遞歸方法的時候不僅要注意遞歸方法深度,還要注意這個過程需要及時釋放無用對象,不要讓內存泄漏發生。

好了,文章大概就這樣了,下次文章再見了。

參考文章以及網站

  1. 深入 Java 虛擬機 堆內存章節
  2. Java JVM 中 堆,棧,方法區 詳解
  3. gc 日誌分析網站
  4. 查看 JVM 進程信息的工具 -- vjtop
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章