摘要:小心遞歸中內存泄漏前段時間由于業務需要,需要從數據庫中查詢出來所有滿足條件的數據,然后導入到文件中。綜上,我們可以得知程序出現了內存泄漏。
小心遞歸中內存泄漏
前段時間由于業務需要,需要從數據庫中查詢出來所有滿足條件的數據,然后導入到文件中。于是隨便寫了個程序,查詢出所有滿足條件然后再寫入文件。但是實際上線后卻發現,程序剛開始運行馬上看到部分數據寫入到文件,但是后面運行越來越慢,于是對此分析排查了一下。
應用環境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 日志,然后可以查看各個時間點堆內存占用情況。分析情況如圖。
這張圖為 GC 之后堆內存占用情況??梢钥闯龆褍却嬖?Full GC 之后并沒有很快的降下來且很快下一次 Full GC 就開始了。這樣大致可以看出,程序沒有在期待時間內運行結束,就是由于堆內被占用過多,持續引起Full GC,應用程序線程持續被掛起。然后我們再看堆內存老年代占用情況。
如上圖,堆內存老年代占用空間持續上升直到接近占滿,引起 Full GC,并沒有緩解這種情況,之后內存占用一直接近到占滿。
綜上,我們可以得知程序出現了內存泄漏。
知道了原因,我們就好順著找到問題。又順著捋了一遍代碼,可惜的是并沒有看出問題。難道是 allData 數據集合越來越大,然后導致該現象?仔細查看了 saveToFile 代碼邏輯。
</>復制代碼
List 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 之后卻無法騰出空間。最后我們看到的現象就是程序執行很慢很慢。
總結這個問題本質看起來不是很難,但是實際發生的時候排查問題著實花費不少時間。下面我們總結一下這個過程。
如果程序實際運行起來與預想差距太大,那么不用想了,肯定哪里出問題了,趕快登上機器查看吧。
程序運行必要節點的日志輸出需要打印。上面程序本來剛開始寫的時候,由于主觀意思,想想沒那么難,很快就擼完部署了。最后查看日志,由于沒有必要的日志輸出,都不知道程序卡在那了。
需要了解一些 JVM 相關工具,可以及時查看 JVM 相關情況,如內存使用情況。如本文的例子,實際上我們可以 dump 內存,然后分析哪里發生了內存泄漏。很不幸的是,這方面本人只是處于了解層面,用的時候卻不知道如何下手,只好求助于一些現成開源工具完成。之后需要好好補這方面操作能力,哈哈哈。
本文如果使用 while 循環代替遞歸方式,問題可能更快定位。遞歸中的內存泄漏可能更加隱蔽,很容易被我們忽略,同學們下次再寫遞歸方法的時候不僅要注意遞歸方法深度,還要注意這個過程需要及時釋放無用對象,不要讓內存泄漏發生。
好了,文章大概就這樣了,下次文章再見了。
參考文章以及網站深入 Java 虛擬機 堆內存章節
Java JVM 中 堆,棧,方法區 詳解
gc 日志分析網站
查看 JVM 進程信息的工具 -- vjtop
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/72644.html
摘要:本期推薦文章類內存泄漏及如何避免,由于微信不能訪問外鏈,點擊閱讀原文就可以啦。四種常見的內存泄漏劃重點這是個考點意外的全局變量未定義的變量會在全局對象創建一個新變量,如下。因為老版本的是無法檢測節點與代碼之間的循環引用,會導致內存泄漏。 (關注福利,關注本公眾號回復[資料]領取優質前端視頻,包括Vue、React、Node源碼和實戰、面試指導) 本周正式開始前端進階的第一期,本周的主題...
摘要:但是如果一個值不再用到了,引用次數卻不為,垃圾回收機制卻無法釋放這塊內存,從而導致內存泄漏。內存泄漏垃圾回收語言的內存泄漏主因是不需要的引用。常見內存泄漏意外的全局變量處理未定義變量的方式比較寬松未定義的變量會在全局對象創建一個新變量。 簡答題: settimeout 與 setInterval的區別, 及對他們的內存的分析 區別 setTimeout是在一段時間后調用指定函數(僅一...
摘要:但是如果一個值不再用到了,引用次數卻不為,垃圾回收機制卻無法釋放這塊內存,從而導致內存泄漏。內存泄漏垃圾回收語言的內存泄漏主因是不需要的引用。常見內存泄漏意外的全局變量處理未定義變量的方式比較寬松未定義的變量會在全局對象創建一個新變量。 簡答題: settimeout 與 setInterval的區別, 及對他們的內存的分析 區別 setTimeout是在一段時間后調用指定函數(僅一...
摘要:但是如果一個值不再用到了,引用次數卻不為,垃圾回收機制卻無法釋放這塊內存,從而導致內存泄漏。內存泄漏垃圾回收語言的內存泄漏主因是不需要的引用。常見內存泄漏意外的全局變量處理未定義變量的方式比較寬松未定義的變量會在全局對象創建一個新變量。 簡答題: settimeout 與 setInterval的區別, 及對他們的內存的分析 區別 setTimeout是在一段時間后調用指定函數(僅一...
摘要:內存泄漏指的是,程序之前需要用到部分內存,而這部分內存在用完之后并沒有返回到內存池。基本事件遞歸調用為什么是單線程的一個線程代表著在同一時間段內可以單獨執行的程序部分的數目。 原文地址:How Does JavaScript Really Work? (Part 2) 原文作者:Priyesh Patel showImg(https://segmentfault.com/img...
閱讀 1695·2021-10-13 09:39
閱讀 3167·2021-10-12 10:11
閱讀 559·2021-09-28 09:36
閱讀 2643·2019-08-30 15:55
閱讀 1393·2019-08-30 13:04
閱讀 636·2019-08-29 17:08
閱讀 1916·2019-08-29 14:14
閱讀 3415·2019-08-28 18:23