背景
为了更好的管理项目,我们将群里的一个项目迁移到MDP框架(基于Spring Boot),然后发现系统会频繁报Swap区使用过多的异常。打电话给笔者帮忙查了一下原因,发现配置了4G的堆内内存,但实际使用的物理内存却高达7G,确实不正常。JVM参数配置为“-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+AlwaysPreTouch -XX:ReservedCodeCacheSize=128m -XX:InitialCodeCacheSize=128m, -Xss512k -Xmx4g -Xms4g,-XX:+UseG1GC -XX : G1HeapRegionSize=4M”,实际使用的物理内存如下图所示:
故障排除过程
1. 使用 Java 级别的工具来定位内存区域(堆上内存、代码区域或使用 unsafe.allocateMemory 和 DirectByteBuffer 请求的堆外内存)
作者在项目中添加-XX:NativeMemoryTracking=detailJVM参数重启项目,使用命令jcmd pid VM.native_memory detail查看内存分布如下:
发现该命令显示的committed memory比物理内存小,因为jcmd命令显示的内存包括堆上内存、Code区、unsafe.allocateMemory和DirectByteBuffer请求的内存,但是没有不包括其他本机代码(C 代码)请求的堆外内存。. 所以猜测是这个问题是使用Native Code申请内存造成的。
为了防止误判,作者使用pmap查看内存分布,发现大量64M地址;这些地址空间不在jcmd命令给出的地址空间内c语言内存泄漏的解决方法,基本可以断定是这64M内存造成的。
2. 使用系统级工具定位堆外内存
因为笔者已经基本确定是Native Code引起的,而Java级别的工具不容易排查此类问题,只能使用系统级别的工具来定位问题。
一、使用gperftools定位问题
gperftools的使用请参考gperftools。gperftools的监控如下:
从上图可以看出:malloc申请的内存最多释放3G,之后一直保持在700M-800M。笔者的第一反应是:Native Code里面没有malloc应用,直接mmap/brk应用吗?(gperftools 原理使用动态链接来替换操作系统的默认内存分配器 (glibc)。)
然后,使用 strace 跟踪系统调用
由于没有使用gperftools追踪内存,所以直接使用命令“strace -f -e “brk,mmap,munmap” -p pid” 追踪到OS的内存请求,但没有发现可疑的内存请求。strace监控如下图所示:
接下来,使用 GDB 转储可疑内存
因为没有使用 strace 跟踪可疑的内存应用程序;所以我想看看记忆中的情况。直接使用命令gdp -pid pid 进入GDB,然后使用命令dump memory mem.bin startAddress endAddressdump memory,其中startAddress 和endAddress 可以从/proc/pid/smaps 中找到。然后使用strings mem.bin查看dump的内容,如下:
从内容上看,就像解压后的JAR包信息。读取JAR包信息应该是在项目启动的时候,所以在项目启动后使用strace并不是很有用。所以你应该在项目启动时使用 strace,而不是在启动完成之后。
再次,在项目启动时使用 strace 跟踪系统调用
项目开始使用strace跟踪系统调用,发现确实申请了很多64M的内存空间。截图如下:
使用这个mmap申请的地址空间对应pmap中的如下:
最后使用jstack查看对应的线程
因为申请内存的线程ID已经显示在strace命令中了。直接使用命令jstack pid查看线程栈,找到对应的线程栈(注意十进制和十六进制的转换)如下:
这里基本可以看出问题所在:MCC(美团统一配置中心)使用Reflections扫描包,底层使用Spring Boot加载JAR。因为解压JAR使用了Inflater类,所以需要使用堆外内存,然后使用Btrace来追踪这个类。堆栈如下:
然后查看使用MCC的地方,发现没有配置包扫描路径。默认是扫描所有包。于是修改代码,配置包扫描路径,解决发布后的内存问题。
3. 为什么堆外内存没有释放?
虽然问题已经解决了,但是还有几个问题:
带着疑惑,笔者直接看了一下Spring Boot Loader的源码。发现Spring Boot封装了Java JDK的InflaterInputStream并使用了Inflater,而Inflater本身需要使用堆外内存来解压JAR包。包装类 ZipInflaterInputStream 不会释放 Inflater 持有的堆外内存。所以我以为我找到了原因,立即向 Spring Boot 社区报告了这个 bug。但是经过反馈,作者发现Inflater对象本身实现了finalize方法,并且在这个方法中有一个逻辑可以调用来释放堆外的内存。也就是说,Spring Boot 依赖 GC 来释放堆外内存。
当我使用jmap查看堆中的对象时,发现基本上没有Inflater对象。因此,当我怀疑 GC 时,不会调用 finalize。带着这样的疑惑,笔者将Spring Boot Loader中打包的Inflater替换为自己打包的Inflaterc语言内存泄漏的解决方法,并在finalize中进行了监控。结果,确实调用了finalize方法。于是查看了Inflater对应的C代码,发现初始化时使用malloc请求内存,结束时也调用free释放内存。
此时笔者只能怀疑内存空闲时并没有真正释放,于是将Spring Boot打包的InflaterInputStream替换为Java JDK自带的,替换后发现内存问题解决了。
这时候回去查看gperftools的内存分布,发现在使用Spring Boot的时候,内存使用量一直在增加,突然某个点内存使用量下降了很多(使用量直接从3G到700M左右)。这点应该是GC造成的,应该释放内存,但是在操作系统层面看不到内存变化。它不是释放到操作系统并由内存分配器持有吗?
继续探索,发现系统默认的内存分配器(glibc2.12版本)和使用gperftools的内存地址分配差别很大。2.5G地址使用smaps发现属于Native Stack。内存地址分布如下:
至此,基本可以确定内存分配器在耍花招;我搜索了glibc 64M,发现glibc从2.11开始为每个线程引入了一个内存池(64位机器大小为64M内存),原文如下:
根据文字修改MALLOC_ARENA_MAX环境变量,发现没有效果。查看 tcmalloc(gperftools 使用的内存分配器)也使用内存池方法。
为了验证是不是内存池的鬼,作者简单写了一个没有内存池的内存分配器。使用命令 gcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so 生成动态库,然后使用 export LD_PRELOAD=zjbmalloc.so 替换 glibc 内存分配器。代码Demo如下:
#include #include #include #include //作者使用的64位机器,sizeof(size_t)也就是sizeof(long) void* malloc ( size_t size ) { long* ptr = mmap( 0, size + sizeof(long), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0 ); if (ptr == MAP_FAILED) { return NULL; } *ptr = size; // First 8 bytes contain length. return (void*)(&ptr[1]); // Memory that is after length variable } void *calloc(size_t n, size_t size) { void* ptr = malloc(n * size); if (ptr == NULL) { return NULL; } memset(ptr, 0, n * size); return ptr; } void *realloc(void *ptr, size_t size) { if (size == 0) { free(ptr); return NULL; } if (ptr == NULL) { return malloc(size); } long *plen = (long*)ptr; plen--; // Reach top of memory long len = *plen; if (size <= len) { return ptr; } void* rptr = malloc(size); if (rptr == NULL) { free(ptr); return NULL; } rptr = memcpy(rptr, ptr, len); free(ptr); return rptr; } void free (void* ptr ) { if (ptr == NULL) { return; } long *plen = (long*)ptr; plen--; // Reach top of memory long len = *plen; // Read length munmap((void*)plen, len + sizeof(long)); } 复制代码
通过在自定义分配器中埋点,可以发现程序启动后应用实际申请的堆外内存始终在700M-800M之间,gperftools监控显示内存使用量也在700M-800M左右. 但是从操作系统的角度来看,进程占用的内存变化很大(这里只是监控堆外内存)。
作者做了一个测试,使用不同的分配器对包进行不同程度的扫描。占用的内存如下:
为什么自定义malloc申请800M,最终占用的物理内存是1.7G?
因为自定义内存分配器使用mmap分配内存,mmap分配内存并根据需要四舍五入到整数页,所以存在巨大的空间浪费。通过监控发现最终申请的页数约为536k,实际申请到系统的内存等于512k * 4k(pagesize)=2G。为什么这个数据大于1.7G?
由于操作系统采用延迟分配的方式,通过mmap向系统申请内存时,系统只返回内存地址,并不分配真正的物理内存。只有在实际使用时,系统才会产生缺页中断,然后分配实际的物理Page。
总结
整个内存分配过程如上图所示。MCC 包扫描的默认配置是扫描所有 JAR 包。在扫描包时,Spring Boot 并没有主动释放堆外内存,导致扫描阶段堆外内存使用量持续激增。GC发生时,Spring Boot依靠finalize机制释放堆外内存;但是,出于性能考虑,glibc 并没有真正将内存返回给操作系统,而是将其留在内存池中,导致应用层认为发生了“内存泄漏”。于是修改MCC的配置路径为具体的JAR包,问题就解决了。发表这篇文章的时候,发现Spring Boot最新版本(2.0.5.RELEASE)已经修改,并且ZipInflaterInputStream主动释放堆外内存,不再依赖GC;于是Spring Boot升级到最新版本,也可以解决这个问题。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 欧资源网