美团-Spring Boot引起的“堆外内存泄漏”排查及经验总结

原文地址:Spring Boot引起的“堆外内存泄漏”排查及经验总结

背景

一个基于java的springboot项目频繁报出Swap区域使用量过高的异常,(Swap区域是linux中的一个硬盘区域,即在内存不足的时候用将内存中不常访问的数据保存到swap)。在运行jar包的时候,配置了内存为4G,即-Xms4g -Xmx4g,但是程序出现了7G的内存,如图

top命令显示的内存情况

排查过程

1. 确定哪个内存区域的内存过大

在项目中使用-XX:NativeMemoryTracking=detail jvm参数重启项目,使用jcmd pid VM.native_memory detail来展示每个部分的内存使用,该 命令不会显示c语言写的native方法使用的内存

jcmd显示的内存情况

发现committed的内存小于物理内存,这里的native memory显示的是unsafe.allocateMemory和DirectByteBuffer申请的内存,所以c写的堆外内存应该还有2G左右的使用空间,大致可以猜测是Native Code申请内存所导致的问题,为了防止误判,再用pmap来查看内存的分布,发现大量的64M的地址,这些地址不是jcmd命令所给出的地址空间里面,基本上就断定就是这些64M的内存所导致。

pmap显示的内存情况

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++代码的内存内存使用情况

gperftools监控

可以看到,内存申请到了3G后降到了700M-800M左右。因为gperftools原理就使用动态链接的方式替换了操作系统默认的内存分配器(glibc),所以可以考虑是否是Native Code中没有使用malloc申请,直接使用mmap/brk申请的(这里需要有相关的c和操作系统的知识)

然后使用strace -f -e”brk,mmap,munmap” -p pid去查看OS申请的内存请求,没有发现可疑的内存申请

strace监控

使用GDB的内去dump可疑内存

因为使用strace没有追踪到可疑内存申请;于是想着看看内存中的情况。就是直接使用命令gdp -pid pid进入GDB之后,然后使用命令dump memory mem.bin startAddress endAddressdump内存,其中startAddress和endAddress可以从/proc/pid/smaps中查找。然后使用strings mem.bin查看dump的内容,如下:

gperftools监控

从内容上来看,像是解压后的JAR包信息。读取JAR包信息应该是在项目启动的时候,那么在项目启动之后使用strace作用就不是很大了。所以应该在项目启动的时候使用strace,而不是启动完成之后。

所以,重新启动项目,在项目启动的时候使用strace去追踪系统调用

项目启动使用strace追踪系统调用,发现确实申请了很多64M的内存空间,截图如下:

strace监控

​ 使用该mmap申请的地址空间在pmap对应如下:

strace申请内容对应的pmap地址空间

因为在strace命令中已经显示了申请内存的线程id,所以可以使用java中的工具去查看线程干了哪些事情。

直接使用命令jstack pid去查看线程栈,找到对应的线程栈(注意10进制和16进制转换)如下:

strace申请空间的线程栈

jstack的显示说明:

线程名称 | 线程id | 线程优先级 | 对应操作系统线程优先级 | 对应的操作系统线程id | 线程状态 | 锁信息 (没有不显示)

所以nid为操作系统的线程id,对应的10进制为16681

这里基本上就可以看出问题来了:MCC(美团统一配置中心)使用了Reflections进行扫包,底层使用了Spring Boot去加载JAR。因为解压JAR使用Inflater类,需要用到堆外内存,然后使用Btrace去追踪这个类,栈如下

btrace追踪栈

BTrace 是检查和解决线上的问题的杀器,BTrace 可以通过编写脚本的方式,获取程序执行过程中的一切信息,并且,注意了,不用重启服务,是的,不用重启服务。写好脚本,直接用命令执行即可,不用动原程序的代码。

然后查看使用MCC的地方,发现没有配置扫包路径,默认是扫描所有的包。于是修改代码,配置扫包路径,发布上线后内存问题解决。

解决疑问

1. 为什么堆外内存 没有释放掉

释放了,但没完全释放。在jvmGC的时候会释放 堆外内存资源,但是glibc为了性能考虑,并没有真正把内存归返到操作系统,而是留下来放入内存池了,导致应用层以为发生了“内存泄漏”

2. 为什么使用旧框架没有问题

作者原文好像没说,但是可能的原因是:项目进行迁移,原来的系统环境的glibc可能没有配置内存池的功能

3. 为什么内存大小都是64M,JAR大小不可能这么大,而且都是一样大?

搜索了一下glibc 64M,发现glibc从2.11开始对每个线程引入内存池(64位机器大小就是64M内存),原文如下:

glib内存池说明

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升级到最新版本,这个问题也可以得到解决。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值