前段時間因爲業務須要,須要從數據庫中查詢出來全部知足條件的數據,而後導入到文件中。因而隨便寫了個程序,查詢出全部知足條件而後再寫入文件。可是實際上線後卻發現,程序剛開始運行立刻看到部分數據寫入到文件,可是後面運行愈來愈慢,因而對此分析排查了一下。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
這張圖爲 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 以後卻沒法騰出空間。最後咱們看到的現象就是程序執行很慢很慢。
這個問題本質看起來不是很難,可是實際發生的時候排查問題着實花費很多時間。下面咱們總結一下這個過程。
好了,文章大概就這樣了,下次文章再見了。