当前位置: 首页 > 科技观察

震惊!SpringBoot内存泄漏,排查难度这么大!

时间:2023-03-19 14:05:50 科技观察

为了更好的管理项目,我们将组内的一个项目迁移到了MDP框架(基于SpringBoot),然后发现系统会频繁报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",实际使用的物理内存如下图:top命令显示的内存情况调查过程1.使用Java级别的工具定位内存区(堆内存,Code区,或者heap申请使用unsafe.allocateMemory和DirectByteBufferExternalmemory)笔者在项目中加入-XX:NativeMemoryTracking=detailJVM参数重启项目,使用命令jcmdpidVM.native_memorydetail查看内存分布如下:jcmd显示的内存状态发现命令显示的committed内存小于物理内存,因为jcmd命令显示的内存包括堆内内存、Code区、通过unsafe.allocateMemory申请的内存和DirectByteBuffer,但不包括其他本机代码(C代码)请求的堆外内存。所以猜测是使用NativeCode申请内存导致的问题。为了防止误判,作者使用pmap查看内存分布,发现大量64M地址;而这些地址空间不在jcmd命令给出的地址空间内,基本可以断定是这64M内存造成的。pmap显示的内存状态2.使用系统级工具定位堆外内存因为笔者已经基本确定是NativeCode造成的,Java级工具不容易排查此类问题,所以我们可以只能使用系统级工具来定位问题。首先使用gperftools定位问题。gperftools的使用方法请参考gperftools。gperftools的监控如下:gperftoolsmonitoring从上图可以看出,malloc申请的内存在达到最大3G后就释放了,之后一直维持在700M-800M。笔者的第一反应是:难不成NativeCode没有使用malloc申请,而是直接使用mmap/brk申请?(gperftools的原理是用动态链接替换操作系统默认的内存分配器(glibc)。)然后,使用strace跟踪系统调用,因为使用gperftools没有跟踪内存,所以直接使用命令“strace-f-e"brk,mmap,munmap"-ppid"跟踪到操作系统的内存请求,但没有发现可疑的内存请求。strace监控如下图所示:strace监控接下来使用GDB转储可疑内存,因为strace的使用并没有追踪到可疑内存的申请;所以想看看内存中的情况。就是直接使用命令gdp-pidpid进入GDB,然后使用命令dumpmemorymem.binstartAddressendAddress将内存转储出来,其中startAddress和endAddress可以从/proc/pid/smaps中找到。然后使用stringsmem.bin查看dump的内容,如下:gperftoolsmonitoring从内容上看,像是解压后的JAR包信息。读取JAR包信息应该是在项目启动时,所以在项目启动后使用strace用处不大。所以strace应该在项目启动时使用,而不是在启动完成后使用。再次在项目启动时使用strace跟踪系统调用,在项目启动时使用strace跟踪系统调用,发现确实申请了很多64M内存空间。最后用jstack查看对应的线程,因为在strace命令中已经显示了申请内存的线程ID。直接使用命令jstackpid查看线程栈,找到对应的线程栈(注意十进制和十六进制的转换)如下:strace应用空间的线程栈基本可以看出这里的问题:MCC(美团统一配置)中)使用Reflections扫描包,底层使用SpringBoot加载JAR。因为解压后的JAR使用了Inflater类,所以需要使用堆外内存,然后使用Btrace跟踪这个类。堆栈如下:btrace跟踪堆栈,查看使用MCC的地方。发现没有配置包扫描路径。默认是扫描所有包。于是修改代码,配置包扫描路径,解决发布上线后的内存问题。3、为什么堆外内存没有释放?虽然问题解决了,但是有几个问题:为什么用老框架就没有问题了?为什么堆外内存没有被释放?为什么内存大小是64M,JAR大小不能这么大,而且都是一样的大小?为什么gperftools最后显示已用内存大小在700M左右,解压包真的没有使用malloc申请内存吗?带着疑惑,笔者直接查看了SpringBootLoader的源码。发现SpringBoot封装了JavaJDK的InflaterInputStream并使用了Inflater,而Inflater本身需要使用堆外内存来解压JAR包。封装类ZipInflaterInputStream并没有释放Inflater持有的堆外内存。所以我以为我找到了原因,并立即向SpringBoot社区报告了这个错误。但是笔者反馈后发现Inflater对象本身实现了finalize方法,并且在这个方法中有调用和释放堆外内存的逻辑。也就是说,SpringBoot是依赖GC来释放堆外内存的。笔者在使用jmap查看堆中的对象时,发现基本没有Inflater对象。所以我怀疑GC的时候,并没有调用finalize。带着这样的疑惑,作者在SpringBootLoader中封装了Inflater,换成了自己封装的Inflater,并在finalize中监控。结果确实调用了finalize方法。于是笔者去看了下Inflater对应的C代码,发现在初始化的时候使用了malloc申请内存,最后还调用了free释放内存。此时笔者只能怀疑free时内存并没有真正释放,于是将SpringBoot封装的InflaterInputStream替换为JavaJDK自带的,发现替换后内存问题解决了。这时候回过头来看gperftools的内存分布,发现在使用SpringBoot的时候,内存占用一直在增加,突然某个点内存占用下降了很多(直接从3G下降到约700M)。这个点应该是GC引起的。应该释放内存,但在操作系统级别看不到内存变化。它不是释放给操作系统并由内存分配器持有吗?继续探索,发现系统默认的内存分配器(glibcversion2.12)和使用gperftools分配内存地址明显不同。使用smaps发现2.5G地址属于NativeStack。内存地址分布如下:gperftools显示的内存地址分布在这里,基本可以确定是内存分配器在搞鬼;搜索glibc64M后发现glibc从2.11开始为每个线程引入了一个内存池(64位机器大小为64M内存),原文如下:glib内存池指令根据修改MALLOC_ARENA_MAX环境变量文,发现没有效果。检查tcmalloc(gperftools使用的内存分配器)是否也使用内存池方法。为了验证是不是内存池的幽灵,作者索性写了一个没有内存池的内存分配器。使用命令gcczjbmalloc.c-fPIC-shared-ozjbmalloc.so生成动态库,然后使用exportLD_PRELOAD=zjbmalloc.so替换glibc的内存分配器。其中代码Demo如下:#include#include#include#include//作者使用的64位机器,sizeof(size_t)也是sizeof(long)void*malloc(size_tsize){long*ptr=mmap(0,size+sizeof(long),PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,0,0);if(ptr==MAP_FAILED){returnNULL;}*ptr=size;//First8bytescontainlength.return(void*)(&ptr[1]);//Memorythatisafterlengthvariable}void*calloc(size_tn,size_tsize){void*ptr=malloc(n*size);if(ptr==NULL){returnNULL;}memset(ptr,0,n*size);returnptr;}void*realloc(void*ptr,size_tsize){if(size==0){free(ptr);returnNULL;}if(ptr==NULL){returnmalloc(size);}long*plen=(long*)ptr;plen--;//Reachtopofmemorylonglen=*plen;if(size<=len){returnptr;}void*rptr=malloc(size);if(rptr==NULL){free(ptr);returnNULL;}rptr=memcpy(rptr,ptr,len);free(ptr);returnrptr;}voidfree(void*ptr){if(ptr==NULL){return;}long*plen=(long*)ptr;plen--;//Reachtopofmemorylonglen=*plen;//Readlengthmunmap((void*)plen,len+sizeof(long));}通过在自定义分数可以发现程序启动后应用实际申请的堆外内存总是在700M-800M之间,gperftools监控显示内存使用量也在700M-800M左右,但是从运行的角度来看系统,进程占用的内存有很大的不同。Large(这里只监控堆外内存)。笔者做了一个测试,使用不同的allocator对数据包进行不同程度的扫描,占用内存如下:内存测试对比为什么自定义malloc申请了800M,最后占用了1.7G的物理内存?因为custommemoryallocator使用mmap分配内存,而mmap分配的内存根据需要四舍五入到整数页数,所以存在巨大的空间浪费。通过监控,发现最终申请的page数量约为536k,实际申请到系统的内存等于512k*4k(pagesize)=2G。为什么这个数据大于1.7G?由于操作系统采用延迟分配的方式,通过mmap向系统申请内存时,系统只返回内存地址,并不分配真正的物理内存。只有在实际使用时,系统才会产生缺页中断,然后分配实际的物理Page。SummaryFlowchart整个内存分配过程如上图所示。MCC包扫描的默认配置是扫描所有的JAR包。SpringBoot在扫描包时不会主动释放堆外内存,导致扫描阶段堆外内存使用量持续激增。当GC发生时,SpringBoot依赖finalize机制释放堆外内存;但是glibc出于性能考虑,并没有真正将内存归还给操作系统,而是留在了内存池中,导致应用层认为发生了“内存泄漏”。于是将MCC的配置路径修改为具体的JAR包,问题解决。作者发表本文时,发现修改了最新版本的SpringBoot(2.0.5.RELEASE),ZipInflaterInputStream主动释放堆外内存,不再依赖GC;于是将SpringBoot升级到最新版本,这个问题也可以解决。