记一次EasyExcel的错误使用导致的频繁FullGC
一、背景描述
繁忙的校招结束了,美好的大学四年也结束了,作者也有10个月没有更新了。拿到心仪的offer之后也开始了苦B的打工生活。
最近接到了这样的一个需求:从大量Excel文件中清洗出来关键信息,文件数量很多,数据量也很大。
早就听说EasyExcel是处理Excel的利器,性能极高的同时还不会出现内存溢出,作者想都没想就开始用了起来,于是就有了今天这篇文章。。。。
二、场景复现
参照GPT以及一些文档还有以前的一点点使用经验,作者写了这样一段代码。
java
@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()方法,而刚才的代码中调用的逻辑是这样的
java
excelReader.readAll();
excelReader.finish();
具体的逻辑就不细看了,语义上的描述大概是读取所有的内容,然后手动关闭。
这时候就真相大白了,结合我们的代码中又添加了@Async注解,场景发生的原因大概是:
多个线程同时读取到了超大文件,导致在excelReader.readAll()过程中老年代被打满,老年代已经没有空间去读取这几个超大文件中的内容了,且由于ExcelReader重写了finalize()方法,并不会进入到GC队列,这就会导致老年代的占用一直是接近100%,不断的触发FullGC,而那些使用年轻代就能进行读取的小文件就可以正常的进行数据解析,随后被GC掉。
四、解决方案
学习了官方文档后,发现作者的场景应该使用这部分逻辑,即继承AnalysisEventListener,重写invoke方法,doAfterAllAnalysed()方法,最关键的是定义一个没读取一部分数据就释放空间的List,这样可以实现读取一部分内容后就释放内存,不会出现读取超大文件导致大对象无法回收的问题,也是这个工具类的正确使用方法。
五、思考复盘
- 选择某个工具类实现功能的时候一定要充分阅读文档,找到自己需要的能力
- 学习JVM,这会让好多排查过程变得非常轻松
- 养成阅读源码的习惯,快速定位生产中的问题
- 学习设计模式,哪怕自己的屎山没机会通过设计模式重构,也能提高自己阅读优秀开源组件实现逻辑的能力
最后感慨一下:用EasyExcel这个组件好长时间了,都没有去探索他的实现逻辑,而且最开始使用EasyExcel真的是觉得他用起来比POI更加的Easy,根本不了解他可以解决内存溢出的问题,更是忽略掉了这个组件的更加牛逼的用途,自己的成长空间还是很大啊。。。