當心遞歸中內存泄漏

當心遞歸中內存泄漏

前段時間因爲業務須要,須要從數據庫中查詢出來全部知足條件的數據,而後導入到文件中。因而隨便寫了個程序,查詢出全部知足條件而後再寫入文件。可是實際上線後卻發現,程序剛開始運行立刻看到部分數據寫入到文件,可是後面運行愈來愈慢,因而對此分析排查了一下。java

應用環境

JDK 1.7 + Spring 4.3 + mybatis + oraclegit

問題排查

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

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 錯誤。mybatis

寫完代碼,部署到線上,而後執行導出數據,就放着無論,幹其餘事。過一段時間回來看數據導出結果,這個時候大吃一驚,程序居然尚未結束,數據也才導出 3/4 左右。這個時候意識到程序確定存在問題,因而仔細檢查了一遍代碼,也沒看出什麼。oracle

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

Heap after gc

這張圖爲 GC 以後堆內存佔用狀況。能夠看出堆內存在 Full GC 以後並無很快的降下來且很快下一次 Full GC 就開始了。這樣大體能夠看出,程序沒有在期待時間內運行結束,就是因爲堆內被佔用過多,持續引發Full GC,應用程序線程持續被掛起。而後咱們再看堆內存老年代佔用狀況。jsp

老年代內存佔用狀況

如上圖,堆內存老年代佔用空間持續上升直到接近佔滿,引發 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
相關文章
相關標籤/搜索