一、背景说明

项目测试过程中,测试人员反馈使用Excel导入100万号码时报错,通过Top命令查看到该java进程占用物理内存大小为3.4G,而且内存持续占用,一直没有释放,怀疑是不是有内存泄露的情况。

top结果

PS:上图为后续重新截的图,比第一次物理内存占用小

JVM配置参数如下:

server.java.opts=-d64 -XX:MaxPermSize=192M -Xms3000M -Xmx3000M -XX:+HeapDumpOnOutOfMemoryError

二、问题排查

查看日志发现出现了OOM, 报错信息未:java.lang.OutOfMemoryError: GC overhead limit exceeded,并生成了堆内存快照文件java_pid31734.hprof。
通过jVisualVM工具分析java_pid31734.hprof文件。

hprof

java进程发生OOM时生成的堆内存快照文件大小为3.4G。

hprof_class

由于在项目中使用POI去读取Excel文件的,从内存快照中可以看到大量的内存被POI相关的对象占用。

发生OOM后java进程并没有退出,仍可以正常访问。接下来看一下Java进程的堆内存使用情况:

jmap result

通过jmap查看JVM堆内存使用总计约为152.8 MB:100.07(年轻代) + 52.73(老年代),说明出现OOM后,占用的堆内存已经正常释放。

2.1 为什么堆内存释放后java进程占用的物理内存没有收缩?

项目启动后,该java进程占用内存也就1G多一点,进行导入Excel文件操作后,出现了OOM,但是后面堆内存已经正常释放,为什么该java进程占用物理内存没有释放?

操作系统内存分配机制

进程向操作系统申请内存时,操作系统分配给进程的是虚拟内存空间,只有进程真正访问这块内存时,操作系统才会给进程分配物理内存。

JVM启动时,向操作系统申请了3000M的内存,但是这3000M内存空间是虚拟内存空间,只有在java进程真正使用这些内存时操作系统才会去分配物理内。
由于JVM启动后,没有进行耗内存的操作,实际使用的内存空间较小,所以操作系统只分配真正使用到的物理内存给JVM,所以通过Top命令查看到的物理内存占用小于3000M
但是在处理Excel导入时发生了OOM,说明java进程已经使用了全部的堆内存,此时操作系统会将堆内存大小的物理内存全部分配给JVM。
上述JVM启动参数配置为-Xms3000M -Xmx3000M, Xms为最小堆内存,Xmx为最大堆大小,由于这两个参数配置相同,所以JVM在堆内存空闲时仍不会归还物理内存给操作系统。
可以通过pmap命令看一下进程内存占用情况:

pmap

从上图可以看出堆内存释放后,JVM底层并未将物理内存归还给操作系统。

2.2 为什么java进程占用的内存大小超过了堆内存大小?

JVM 的内存大概分为下面这几个部分

  • 堆(Heap):eden、survivor、old 区域等

  • 线程栈(Thread Stack):每个线程栈预留 1M 的线程栈大小

  • 非堆(Non-heap):包括 code_cache、metaspace 等

  • 堆外内存:unsafe.allocateMemory 和 DirectByteBuffer申请的堆外内存

  • native (C/C++ 代码)申请的内存

  • 还有 JVM 运行本身需要的内存,比如 GC 等。

除去JVM堆内存外,java进程本身会占用一些内存,所以会出现java进程的占用的总内存会比最大堆内存大的情况。

三、POI读取超大Excel文件OOM问题

POI是读写Excel的常用工具包,功能非常丰富。但是POI的缺点也非常明显,在导入100万手机号码的Excel文件时,即使最大堆设置为4000M也还是会发生OOM的。
所以项目后续采用了阿里开源的easyexceleasyexcel官网给出的数据为:64M内存1分钟内读取75M(46W行25列)的Excel。
使用easyexcel后成功解决读取百万以上Excel数据OOM问题。

参考文章

一次 Java 进程 OOM 的排查分析(glibc 篇)
记一次堆外内存泄漏排查过程
Understanding glibc malloc