关于springboot:Spring-Boot内存泄露排查竟这么难

50次阅读

共计 4837 个字符,预计需要花费 13 分钟才能阅读完成。

背景

为了更好地实现对我的项目的治理,咱们将组内一个我的项目迁徙到 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

正文完
 0