背景
为了更好地实现对我的项目的治理,咱们将组内一个我的项目迁徙到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层面的工具定位内存区域(堆内内存、Code区域或者应用unsafe.allocateMemory和DirectByteBuffer申请的堆外内存)
笔者在我的项目中增加-XX:NativeMemoryTracking=detailJVM参数重启我的项目,应用命令jcmd pid VM.native_memory detail查看到的内存散布如下:
发现命令显示的committed的内存小于物理内存,因为jcmd命令显示的内存蕴含堆内内存、Code区域、通过unsafe.allocateMemory和DirectByteBuffer申请的内存,然而不蕴含其余Native Code(C代码)申请的堆外内存。所以猜想是应用Native Code申请内存所导致的问题。
为了避免误判,笔者应用了pmap查看内存散布,发现大量的64M的地址;而这些地址空间不在jcmd命令所给出的地址空间外面,基本上就判定就是这些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去dump可疑内存
因为应用strace没有追踪到可疑内存申请;于是想着看看内存中的状况。就是间接应用命令gdp -pid pid进入GDB之后,而后应用命令dump memory mem.bin startAddress endAddressdump内存,其中startAddress和endAddress能够从/proc/pid/smaps中查找。而后应用strings mem.bin查看dump的内容,如下:
从内容上来看,像是解压后的JAR包信息。读取JAR包信息应该是在我的项目启动的时候,那么在我的项目启动之后应用strace作用就不是很大了。所以应该在我的项目启动的时候应用strace,而不是启动实现之后。
再次,我的项目启动时应用strace去追踪零碎调用
我的项目启动应用strace追踪零碎调用,发现的确申请了很多64M的内存空间,截图如下:
应用该mmap申请的地址空间在pmap对应如下:
最初,应用jstack去查看对应的线程
因为strace命令中曾经显示申请内存的线程ID。间接应用命令jstack pid去查看线程栈,找到对应的线程栈(留神10进制和16进制转换)如下:
这里基本上就能够看出问题来了:MCC(美团对立配置核心)应用了Reflections进行扫包,底层应用了Spring Boot去加载JAR。因为解压JAR应用Inflater类,须要用到堆外内存,而后应用Btrace去追踪这个类,栈如下:
而后查看应用MCC的中央,发现没有配置扫包门路,默认是扫描所有的包。于是批改代码,配置扫包门路,公布上线后内存问题解决。
3.为什么堆外内存没有开释掉呢?
尽管问题曾经解决了,然而有几个疑难:
- 为什么应用旧的框架没有问题?
- 为什么堆外内存没有开释?
- 为什么内存大小都是64M,JAR大小不可能这么大,而且都是一样大?
- 为什么gperftools最终显示应用的的内存大小是700M左右,解压包真的没有应用malloc申请内存吗?
带着疑难,笔者间接看了一下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。带着这样的狐疑,笔者把Inflater进行包装在Spring Boot Loader外面替换成本人包装的Inflater,在finalize进行打点监控,后果finalize办法的确被调用了。于是笔者又去看了Inflater对应的C代码,发现初始化的应用了malloc申请内存,end的时候也调用了free去开释内存。
此刻,笔者只能狐疑free的时候没有真正开释内存,便把Spring Boot包装的InflaterInputStream替换成Java JDK自带的,发现替换之后,内存问题也得以解决了。
这时,再返过去看gperftools的内存散布状况,发现应用Spring Boot时,内存应用始终在减少,忽然某个点内存应用降落了好多(使用量间接由3G降为700M左右)。这个点应该就是GC引起的,内存应该开释了,然而在操作系统层面并没有看到内存变动,那是不是没有开释到操作系统,被内存分配器持有了呢?
持续探索,发现零碎默认的内存分配器(glibc 2.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<sys/mman.h>#include<stdlib.h>#include<string.h>#include<stdio.h>//作者应用的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降级到最新版本,这个问题也能够失去解决。
作者 | 纪兵
起源 | http://suo.im/5MABXL