記一次EasyExcel的錯誤使用導致的頻繁FullGC
- 一、背景描述
- 二、場景復現
- 三、原因分析
- 四、解決方案
- 五、思考復盤
一、背景描述
繁忙的校招結束了,美好的大學四年也結束了,作者也有10個月沒有更新了。拿到心儀的offer之后也開始了苦B的打工生活。
最近接到了這樣的一個需求:從大量Excel文件中清洗出來關鍵信息,文件數量很多,數據量也很大。
早就聽說EasyExcel是處理Excel的利器,性能極高的同時還不會出現內存溢出,作者想都沒想就開始用了起來,于是就有了今天這篇文章。。。。
二、場景復現
參照GPT以及一些文檔還有以前的一點點使用經驗,作者寫了這樣一段代碼。
@Component
public class EasyExcelUtil {// 這里開了32個線程@Async("excelExecutor")public void test(String fileName){ExcelReaderBuilder read = EasyExcel.read(fileName);List<Object> objects = read.doReadAllSync();// 其他處理邏輯}
}
觀察了一會日志,發現運行的還挺正常,作者就心滿意足的去寫文檔了,悲劇的是寫完文檔回來發現,GC日志上面瘋狂的FullGC,文件也只處理了一千個左右,當時的心情是極其復雜的,于是就開始了漫長的排查。
三、原因分析
首先觀察日志,這時候有些文件其實還是被處理了的,頻繁的FullGC日志中有一些年輕代是被正常回收了的,但是老年代已經滿了,且無論怎么回收,都不會被回收掉,這時候其實就可以想到一種可能性是有一些不會被FullGC回收的大對象存在。于是我去dump了堆內存圖,老年代的分布大概是這樣的:
其中SyncReadListener的對象躲過了所有的FullGC且沒有GC Root,猜測一定是SyncReadListener這個類出現了什么問題,我們先看doReadAllSync()這個方法的源碼
可以看到是先注冊了SyncReadListener這個監聽器,然后構造了一個excelReader對象,通過excelReader對excel進行讀取,那為什么SyncReadListener會出現這么多大對象呢,我們看看源碼。
SyncReadListener可以將某些數據一條條的塞進去,這里我們合理推測其實就是我們讀取到的數據被傳遞給了監聽器,但是為什么沒有被垃圾回收掉呢?推測問題應該就出現在了ExcelReader這個類。
首先是常量定義和一些讀取的方法。
接下來這部分內容就有意思了,也是問題所在。
這個類重寫了finalize方法,調用了一次finish()方法,而剛才的代碼中調用的邏輯是這樣的
excelReader.readAll();
excelReader.finish();
具體的邏輯就不細看了,語義上的描述大概是讀取所有的內容,然后手動關閉。
這時候就真相大白了,結合我們的代碼中又添加了@Async注解,場景發生的原因大概是:
多個線程同時讀取到了超大文件,導致在excelReader.readAll()過程中老年代被打滿,老年代已經沒有空間去讀取這幾個超大文件中的內容了,且由于ExcelReader重寫了finalize()方法,并不會進入到GC隊列,這就會導致老年代的占用一直是接近100%,不斷的觸發FullGC,而那些使用年輕代就能進行讀取的小文件就可以正常的進行數據解析,隨后被GC掉。
四、解決方案
學習了官方文檔后,發現作者的場景應該使用這部分邏輯,即繼承AnalysisEventListener,重寫invoke方法,doAfterAllAnalysed()方法,最關鍵的是定義一個沒讀取一部分數據就釋放空間的List,這樣可以實現讀取一部分內容后就釋放內存,不會出現讀取超大文件導致大對象無法回收的問題,也是這個工具類的正確使用方法。
五、思考復盤
- 選擇某個工具類實現功能的時候一定要充分閱讀文檔,找到自己需要的能力
- 學習JVM,這會讓好多排查過程變得非常輕松
- 養成閱讀源碼的習慣,快速定位生產中的問題
- 學習設計模式,哪怕自己的屎山沒機會通過設計模式重構,也能提高自己閱讀優秀開源組件實現邏輯的能力
最后感慨一下:用EasyExcel這個組件好長時間了,都沒有去探索他的實現邏輯,而且最開始使用EasyExcel真的是覺得他用起來比POI更加的Easy,根本不了解他可以解決內存溢出的問題,更是忽略掉了這個組件的更加牛逼的用途,自己的成長空間還是很大啊。。。