如何解决Spring Boot内存泄漏问题

本文概述

  • 背景
  • 如何解决问题
  • 总结
  • 参考文献
将项目迁移到Spring Boot之后, 出现了过多的内存使用问题。本文介绍了整个故障排除过程和所使用的工具, 对于解决其他堆外内存问题也非常有用。
背景 为了更好地管理项目, 我们将组中的一个项目迁移到了MDP框架(基于Spring Boot), 然后我们发现系统经常报告交换区使用过多的异常。打电话给我帮助查找原因, 但发现为堆上内存配置了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

实际使用的物理内存如下所示:
如何解决Spring Boot内存泄漏问题

文章图片
如何解决问题 1.使用Java级工具查找内存区域(使用unsafe.allocateMemory和DirectByteBuffer请求的堆内内存, 代码段或堆外内存)
我向项目添加了-XX:NativeMemoryTracking = detail的JVM参数以重新启动项目, 并使用jcmd pid VM.native_memory detail命令查看了内存分配, 如下所示:
如何解决Spring Boot内存泄漏问题

文章图片
我发现已提交的内存小于物理内存, 因为jcmd命令显示的内存包含内存中的内存, 代码段以及unsafe.allocateMemory和DirectByteBuffer请求的内存, 但不包含其他本机代码(C代码)请求的堆外内存。因此, 我猜测该问题是由使用本机代码的请求内存引起的。
为了防止错误判断, 我还使用了pmap来查看内存分配, 并发现了大量内存为64M的地址。但是这些地址空间不在jcmd命令给定的地址空间中, 因此我几乎可以判断出问题是由这64M内存引起的。
如何解决Spring Boot内存泄漏问题

文章图片
2.使用系统级工具查找堆外内存
由于我确定它是由本机代码引起的, 并且Java级别的工具对于解决此类问题不是很有用, 因此我必须寻求系统级别的工具的帮助。
首先, 使用gperftools查找问题。
你可以参考gperftools来了解gperftools的用法。来自gperftools的监视如下:
如何解决Spring Boot内存泄漏问题

文章图片
从上图可以看出, malloc请求的内存在3G内存释放时就释放了, 然后一直保持在700M-800M。我的第一个响应是:它不是通过直接使用mmap / brkby而不是在本机代码中使用malloc来请求吗? (gperftools的原理是用动态链接替换操作系统的默认内存分配器(glibc)。)
然后, 使用strace跟踪系统调用。
由于无法使用gperftools跟踪内存, 因此我使用了strace -f -e“ brk, mmap, munmap” -p pid命令来跟踪对OS的内存请求, 但是没有找到任何可疑的内存请求。通过strace进行的监视如下所示:
如何解决Spring Boot内存泄漏问题

文章图片
接下来, 使用GDB转储可疑的内存。
由于strace无法追踪出可疑的内存请求, 因此我考虑了检查内存。因此, 我直接通过命令gdp -pid pid进入GDB, 然后使用命令转储内存mem.bin startAddress endAddress转储内存(可以从/ proc / pid / smaps找到startAddress和endAddress)。然后, 我通过字符串mem.bin查看了结果, 如下所示:
如何解决Spring Boot内存泄漏问题

文章图片
看起来像解压缩的JAR包信息。 JAR软件包的信息应在项目开始时读取, 因此在项目启动后strace不能很好地工作。因此, 你应该在项目启动期间使用strace, 而不是在启动完成之后使用。
同样, 在项目启动期间, 使用strace跟踪系统调用。
因此, 在项目启动期间, 我使用strace跟踪系统调用, 发现确实确实有大量的64M内存请求。屏幕截图如下:
如何解决Spring Boot内存泄漏问题

文章图片
mmmap请求的地址空间使用的内存可以在pmap工具中看到:
如何解决Spring Boot内存泄漏问题

文章图片
最后, 使用jstack查看相应的线程。
由于strace命令已经显示了所请求内存的线程ID, 因此我可以直接使用命令jstack pid来查看线程堆栈以查找相应的线程堆栈(注意十进制和十六进制之间的转换):
如何解决Spring Boot内存泄漏问题

文章图片
现在, 我发现了问题:MCC(MtConfigClient)使用Reflections扫描软件包, 并使用Spring Boot在底部加载JAR。需要使用堆外内存, 因为在解压缩JAR时将使用Inflater类。然后, 我使用Btrace来跟踪类:
如何解决Spring Boot内存泄漏问题

文章图片
然后, 我查看了使用MCC的位置, 发现没有配置扫描软件包的路径。默认为扫描所有软件包。因此, 我只需要修改代码并配置扫描软件包的路径即可解决内存问题。
3.为什么不释放堆外内存?
尽管问题已解决, 但仍然存在几个问题:
  • 为什么旧框架没有问题?
  • 为什么未释放堆外内存?
  • 为什么记忆全是64M?
  • 为什么gperftools最终显示使用的内存约为700M, 并且解压缩程序包确实不使用malloc来请求内存?
考虑到这些问题, 我检查了Spring Boot Loader的相关源代码块, 发现在Spring Boot中, 包装了Java JDK中的InflaterInputStream并使用了Inflater, 并且Inflater本身需要涉及到堆外解压缩JAR包时的内存。但是, 打包的类ZipInflaterInputStream并未释放Inflater拥有的堆外内存。因此, 我认为这是原因, 并立即将错误报告给Spring Boot社区。但是不久之后, 我发现Inflater对象本身实现了finalize方法, 其中存在一种可以调用以释放堆外内存的逻辑。换句话说, Spring Boot依靠GC释放堆外内存。
然后, 当我通过jmap查看堆中的对象时, 我发现几乎没有Inflater对象。因此, 我开始怀疑它在GC期间是否没有调用finalize。我用自己包装的Inflater替换了Spring Boot Loader中的Inflater, 然后将断点添加到finalize方法中进行调试。事实是finalize方法实际上是被调用的。因此我去检查了与Inflater对应的C代码, 发现它在初始化期间通过malloc请求了内存, 并在结束时调用了free以释放内存。
目前, 我只能怀疑在调用free时它并没有真正释放内存, 因此我将Spring Boot包装的InflaterInputStream替换为Java JDK随附的InflaterInputStream。然后, 解决了内存问题。
现在, 我回过头来查看gperftools的内存分配, 发现使用Spring Boot时内存使用量一直在增加, 但是在某个时候它减少了很多(使用量从3G减少到700M)。这是由GC引起的, 内存已释放。但是, 在操作系统级别看不到内存更改。内存是否没有释放给操作系统, 而是由内存分配器保存?
然后, 我发现默认系统内存分配器(glibc 2.12)的内存地址分配与使用gperftools的分配完全不同。通过使用smap, 发现地址2.5G属于本机堆栈。内存地址分配如下:
如何解决Spring Boot内存泄漏问题

文章图片
因此, 毫无疑问这归因于内存分配器。我搜索了glibc 64M, 发现glibc从2.11(64位计算机为64M内存)开始向每个线程引入了内存池。原始内容如下:
增强的动态内存分配(malloc)行为可在许多套接字和内核之间实现更高的可伸缩性。这是通过为线程分配自己的内存池并在某些情况下避免锁定来实现的。可以使用环境变量MALLOC_ARENA_TEST和MALLOC_ARENA_MAX来控制用于内存池的附加内存量(如果有)。 MALLOC_ARENA_TEST指定一旦内存池数量达到该值, 就对内核数量进行测试。 MALLOC_ARENA_MAX设置使用的最大内存池数量, 而不管内核数量如何。
我按照文章中的描述修改了MALLOC_ARENA_MAX的环境变量, 但发现它不起作用。然后我检查了tcmalloc(gperftools使用的内存分配器), 并知道它也使用内存池的方法。
为了验证结论, 我编写了一个没有内存池的简单内存分配器。然后, 我使用命令gcc zjbmalloc.c -fPIC -shared -o zjbmalloc.so生成了一个动态库, 然后用export LD_PRELOAD = zjbmalloc.so替换了glibc的内存分配器。代码如下:
#include < sys/mman.h> #include < stdlib.h> #include < string.h> #include < stdio.h> void* malloc(size_t size) { long* ptr = mmap(0, size + sizeof(long), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS); if(ptr == MAP_FAILED) { return NULL; } *ptr = size; // First 8 bytes contain lenght. 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 lenght munmap((void*)plen, len + sizeof(long)); }

可以发现, 在向自定义分配器添加事件跟踪后, 程序启动后, 实际上请求的堆外内存始终在700M-800M之间。 gperftools中显示的内存使用量也约为700M-800M。但是, 该进程占用的内存在操作系统级别上是非常不同的(此处仅监视堆外内存)。
我对使用不同的分配器以不同程度扫描程序包进行了测试。所占据的记忆如下:
分配者名称 不扫描所有软件包 一次扫描所有软件包 两次扫描所有包裹
glibc 750M 1.5克 2.3克
tcmalloc 900M 2.0克 2.77G
customize 1.7G 1.7G 1.7G
为什么自定义malloc请求800M, 但最终占用的物理内存为1.7G?
原因是自定义内存分配器通过mmap方式分配了内存, 而mmap需要在分配内存时按需舍入到整数个页面。因此, 这浪费了空间。还可以发现, 最终请求的页面数约为536k, 而实际向系统请求的内存等于512k * 4k(pagesize)= 2G。
为什么它大于1.7G?
操作系统使用延迟分配的方式, 因此当mmap向系统请求内存时, 系统仅返回内存地址, 而没有分配实际的物理内存。仅当实际使用内存时, 系统才会生成页面错误中断, 然后分配实际的物理页面。
总结
如何解决Spring Boot内存泄漏问题

文章图片
上图显示了整个内存分配的流程。 MCC扫描仪的默认配置是扫描所有JAR软件包。扫描软件包时, Spring Boot不会自动释放堆外内存, 这将使堆外内存消耗在扫描阶段持续上升。当发生GC时, Spring Boot依赖于finalize机制释放堆外内存。但是出于性能原因, glibc并未将内存实际返回给操作系统, 而是将内存留在了内存池中, 这使应用程序层认为发生了“内存泄漏”。因此, 应将MCC的配置路径修改为特定的JAR软件包, 以解决此问题。
【如何解决Spring Boot内存泄漏问题】在我发表本文时, 我发现最新版本的Spring Boot(2.0.5.RELEASE)已经进行了更改, 并且ZipInflaterInputStream可以主动释放堆外内存, 而不再依赖于GC;因此也可以通过将Spring Boot升级到最新版本来解决该问题。 ??

    推荐阅读