前段时间因为业务须要,须要从数据库中查询出来全部知足条件的数据,而后导入到文件中。因而随便写了个程序,查询出全部知足条件而后再写入文件。可是实际上线后却发现,程序刚开始运行立刻看到部分数据写入到文件,可是后面运行愈来愈慢,因而对此分析排查了一下。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 以后却没法腾出空间。最后咱们看到的现象就是程序执行很慢很慢。
这个问题本质看起来不是很难,可是实际发生的时候排查问题着实花费很多时间。下面咱们总结一下这个过程。
好了,文章大概就这样了,下次文章再见了。