原文地址:Spring Boot引起的“堆外内存泄漏”排查及经验总结
背景
一个基于java的springboot项目频繁报出Swap区域使用量过高的异常,(Swap区域是linux中的一个硬盘区域,即在内存不足的时候用将内存中不常访问的数据保存到swap)。在运行jar包的时候,配置了内存为4G,即-Xms4g -Xmx4g
,但是程序出现了7G的内存,如图
排查过程
1. 确定哪个内存区域的内存过大
在项目中使用-XX:NativeMemoryTracking=detail
jvm参数重启项目,使用jcmd pid VM.native_memory detail
来展示每个部分的内存使用,该 命令不会显示c语言写的native方法使用的内存
发现committed的内存小于物理内存,这里的native memory显示的是unsafe.allocateMemory和DirectByteBuffer申请的内存,所以c写的堆外内存应该还有2G左右的使用空间,大致可以猜测是Native Code申请内存所导致的问题,为了防止误判,再用pmap来查看内存的分布,发现大量的64M的地址,这些地址不是jcmd命令所给出的地址空间里面,基本上就断定就是这些64M的内存所导致。
pmap命令用于报告进程的内存映射关系
-x 显示扩展格式
上面显示的是
内存地址 | 占用的字节数(Kb) | rss | dirty | Mode | Mapper
rss: 占用了RAM多少字节,不包括swap中的(Kb)
Dirty: 脏页的字节数(包括共享和私有的)(KB)
Mode: 内存的权限:read、write、execute、shared、private (写时复制)
Mapping: 占用内存的文件、或[anon](分配的内存)、或[stack](堆栈)
2. 使用系统层面的工具定位堆外内存
确定了问题是由堆外内存所引起的,所以就不能使用java的工具排查问题,需要使用系统的工具去逐步排查
使用gperftools去查看c++代码的内存内存使用情况
可以看到,内存申请到了3G后降到了700M-800M左右。因为gperftools原理就使用动态链接的方式替换了操作系统默认的内存分配器(glibc),所以可以考虑是否是Native Code中没有使用malloc申请,直接使用mmap/brk申请的(这里需要有相关的c和操作系统的知识)
然后使用strace -f -e”brk,mmap,munmap” -p pid
去查看OS申请的内存请求,没有发现可疑的内存申请
使用GDB的内去dump可疑内存
因为使用strace没有追踪到可疑内存申请;于是想着看看内存中的情况。就是直接使用命令gdp -pid pid
进入GDB之后,然后使用命令dump memory mem.bin startAddress endAddress
dump内存,其中startAddress和endAddress可以从/proc/pid/smaps中查找。然后使用strings mem.bin
查看dump的内容,如下:
从内容上来看,像是解压后的JAR包信息。读取JAR包信息应该是在项目启动的时候,那么在项目启动之后使用strace作用就不是很大了。所以应该在项目启动的时候使用strace,而不是启动完成之后。
所以,重新启动项目,在项目启动的时候使用strace去追踪系统调用
项目启动使用strace追踪系统调用,发现确实申请了很多64M的内存空间,截图如下:
使用该mmap申请的地址空间在pmap对应如下:
因为在strace命令中已经显示了申请内存的线程id,所以可以使用java中的工具去查看线程干了哪些事情。
直接使用命令jstack pid
去查看线程栈,找到对应的线程栈(注意10进制和16进制转换)如下:
jstack的显示说明:
线程名称 | 线程id | 线程优先级 | 对应操作系统线程优先级 | 对应的操作系统线程id | 线程状态 | 锁信息 (没有不显示)
所以nid为操作系统的线程id,对应的10进制为16681
这里基本上就可以看出问题来了:MCC(美团统一配置中心)使用了Reflections进行扫包,底层使用了Spring Boot去加载JAR。因为解压JAR使用Inflater类,需要用到堆外内存,然后使用Btrace去追踪这个类,栈如下
BTrace 是检查和解决线上的问题的杀器,BTrace 可以通过编写脚本的方式,获取程序执行过程中的一切信息,并且,注意了,不用重启服务,是的,不用重启服务。写好脚本,直接用命令执行即可,不用动原程序的代码。
然后查看使用MCC的地方,发现没有配置扫包路径,默认是扫描所有的包。于是修改代码,配置扫包路径,发布上线后内存问题解决。
解决疑问
1. 为什么堆外内存 没有释放掉
释放了,但没完全释放。在jvmGC的时候会释放 堆外内存资源,但是glibc
为了性能考虑,并没有真正把内存归返到操作系统,而是留下来放入内存池
了,导致应用层以为发生了“内存泄漏”
2. 为什么使用旧框架没有问题
作者原文好像没说,但是可能的原因是:项目进行迁移,原来的系统环境的glibc
可能没有配置内存池的功能
3. 为什么内存大小都是64M,JAR大小不可能这么大,而且都是一样大?
搜索了一下glibc 64M,发现glibc从2.11开始对每个线程引入内存池(64位机器大小就是64M内存),原文如下:
4. 为什么gperftools最终显示使用的的内存大小是700M左右,解压包真的没有使用malloc申请内存吗?
因为 启动后查询使用的堆外内存实际地区只有700M左右,但是从操作系统的角度来开,你在解压包的时候使用的资源放回到了线程池,而没有归还给我,我仍然算是你在使用
总结
整个内存分配的流程如上图所示。MCC扫包的默认配置是扫描所有的JAR包。在扫描包的时候,Spring Boot不会主动去释放堆外内存,导致在扫描阶段,堆外内存占用量一直持续飙升。当发生GC的时候,Spring Boot依赖于finalize机制去释放了堆外内存;但是glibc为了性能考虑,并没有真正把内存归返到操作系统,而是留下来放入内存池了,导致应用层以为发生了“内存泄漏”。所以修改MCC的配置路径为特定的JAR包,问题解决。笔者在发表这篇文章时,发现Spring Boot的最新版本(2.0.5.RELEASE)已经做了修改,在ZipInflaterInputStream主动释放了堆外内存不再依赖GC;所以Spring Boot升级到最新版本,这个问题也可以得到解决。