热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

SpringBoot引起的“内存泄漏”。。。

点击上方[全栈开发者社区]→右上角[]→[设为星标⭐点击领取全栈资料:全栈资料背景为了更好地实现对项目的管理,我们将组内一个项目迁移到MDP框架

点击上方[全栈开发者社区]右上角[...][设为星标⭐a9ba1c7b086dbbff47d7650dea4facba.gif

点击领取全栈资料:全栈资料
背景

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

实际使用的物理内存如下图所示:

89925f4f63587e1f01b1fb8d1698ca6e.png

top 命令显示的内存情况

排查过程

| 使用 Java 层面的工具定位内存区域

堆内内存、Code 区域或者使用 unsafe.allocateMemory 和 DirectByteBuffer 申请的堆外内存。

笔者在项目中添加 -XX:NativeMemoryTracking=detailJVM 参数重启项目,使用命令 jcmd pid VM.native_memory detail 查看到的内存分布如下:

13509cdf6c217ff34504e210496497ef.png

jcmd 显示的内存情况

发现命令显示的 committed 的内存小于物理内存,因为 jcmd 命令显示的内存包含堆内内存、Code 区域、通过 unsafe.allocateMemory 和 DirectByteBuffer 申请的内存,但是不包含其他 Native Code(C 代码)申请的堆外内存。

所以猜测是使用 Native Code 申请内存所导致的问题。

为了防止误判,笔者使用了 pmap 查看内存分布,发现大量的 64M 的地址;而这些地址空间不在 jcmd 命令所给出的地址空间里面,基本上就断定就是这些 64M 的内存所导致。

d1498b241dfae2871f4b652598236007.png

pmap 显示的内存情况

| 使用系统层面的工具定位堆外内存

因为笔者已经基本上确定是 Native Code 所引起,而 Java 层面的工具不便于排查此类问题,只能使用系统层面的工具去定位问题。

首先,使用了 gperftools 去定位问题,gperftools 的使用方法可以参考 gperftools:

https://github.com/gperftools/gperftoolsgperftools 的监控如下:

e8e675ccc850e862a9d9356993fd3950.png

gperftools 监控

从上图可以看出:使用 malloc 申请的的内存最高到 3G 之后就释放了,之后始终维持在 700M-800M。

笔者第一反应是:难道 Native Code 中没有使用 malloc 申请,直接使用 mmap/brk 申请的?(gperftools 原理就使用动态链接的方式替换了操作系统默认的内存分配器(glibc)。)

然后,使用 strace 去追踪系统调用。因为使用 gperftools 没有追踪到这些内存,于是直接使用命令“strace -f -e”brk,mmap,munmap” -p pid”追踪向 OS 申请内存请求,但是并没有发现有可疑内存申请。strace 监控如下图所示:

c2f1ff7cc1bf13280a9de809941242ec.png

strace 监控

接着,使用 GDB 去 dump 可疑内存,因为使用 strace 没有追踪到可疑内存申请;于是想着看看内存中的情况。
就是直接使用命令 gdp -pid pid 进入 GDB 之后,然后使用命令 dump memory mem.bin startAddress endAddressdump 内存,其中 startAddress 和 endAddress 可以从 /proc/pid/smaps 中查找。然后使用 strings mem.bin 查看 dump 的内容,如下:

e7d2192e461daecfea3d1bcbd7b58d10.png

gperftools 监控

从内容上来看,像是解压后的 JAR 包信息。读取 JAR 包信息应该是在项目启动的时候,那么在项目启动之后使用 strace 作用就不是很大了。所以应该在项目启动的时候使用 strace,而不是启动完成之后。

再次,项目启动时使用 strace 去追踪系统调用,项目启动使用 strace 追踪系统调用,发现确实申请了很多 64M 的内存空间。
截图如下:

1700390f193a91b1f69782f5f7b03b7a.png

strace 监控

使用该 mmap 申请的地址空间在 pmap 对应如下:

91913b61dbf6ad912f75f9615ba9c977.png

strace 申请内容对应的 pmap 地址空间

最后,使用 jstack 去查看对应的线程,因为 strace 命令中已经显示申请内存的线程 ID。

直接使用命令 jstack pid 去查看线程栈,找到对应的线程栈(注意 10 进制和 16 进制转换)如下:

e034054715f7ad38961bc9675db62abe.png

strace 申请空间的线程栈

这里基本上就可以看出问题来了:MCC(美团统一配置中心)使用了 Reflections 进行扫包,底层使用了 SpringBoot 去加载 JAR。

因为解压 JAR 使用 Inflater 类,需要用到堆外内存,然后使用 Btrace 去追踪这个类,栈如下:

bb7ca0ce8e6fadff914b04f3166937f0.png

btrace 追踪栈

然后查看使用 MCC 的地方,发现没有配置扫包路径,默认是扫描所有的包。于是修改代码,配置扫包路径,发布上线后内存问题解决。

| 为什么堆外内存没有释放掉呢?

虽然问题已经解决了,但是有几个疑问:

  • 为什么使用旧的框架没有问题?

  • 为什么堆外内存没有释放?

  • 为什么内存大小都是 64M,JAR 大小不可能这么大,而且都是一样大?

  • 为什么 gperftools 最终显示使用的的内存大小是 700M 左右,解压包真的没有使用 malloc 申请内存吗?

带着疑问,笔者直接看了一下 SpringBoot Loader 那一块的源码。发现 SpringBoot 对 Java JDK 的 InflaterInputStream 进行了包装并且使用了 Inflater,而 Inflater 本身用于解压 JAR 包的需要用到堆外内存。

而包装之后的类 ZipInflaterInputStream 没有释放 Inflater 持有的堆外内存。于是笔者以为找到了原因,立马向 SpringBoot 社区反馈了这个 Bug。

但是反馈之后,笔者就发现 Inflater 这个对象本身实现了 finalize 方法,在这个方法中有调用释放堆外内存的逻辑。也就是说 SpringBoot 依赖于 GC 释放堆外内存。

笔者使用 jmap 查看堆内对象时,发现已经基本上没有 Inflater 这个对象了。于是就怀疑 GC 的时候,没有调用 finalize。

带着这样的怀疑,笔者把 Inflater 进行包装在 SpringBoot Loader 里面替换成自己包装的 Inflater,在 finalize 进行打点监控,结果 finalize 方法确实被调用了。

于是笔者又去看了 Inflater 对应的 C 代码,发现初始化的使用了 malloc 申请内存,end 的时候也调用了 free 去释放内存。

此刻,笔者只能怀疑 free 的时候没有真正释放内存,便把 SpringBoot 包装的 InflaterInputStream 替换成 Java JDK 自带的,发现替换之后,内存问题也得以解决了。

这时,再返过来看 gperftools 的内存分布情况,发现使用 SpringBoot 时,内存使用一直在增加,突然某个点内存使用下降了好多(使用量直接由 3G 降为 700M 左右)。

这个点应该就是 GC 引起的,内存应该释放了,但是在操作系统层面并没有看到内存变化,那是不是没有释放到操作系统,被内存分配器持有了呢?

继续探究,发现系统默认的内存分配器(glibc 2.12 版本)和使用 gperftools 内存地址分布差别很明显,2.5G 地址使用 smaps 发现它是属于 Native Stack。

内存地址分布如下:

13db50ad72280e22c941efc349e529aa.png

gperftools 显示的内存地址分布

到此,基本上可以确定是内存分配器在捣鬼;搜索了一下 glibc 64M,发现 glibc 从 2.11 开始对每个线程引入内存池(64 位机器大小就是 64M 内存)。

原文如下:

517e870ac52c0bd42f3bfdc851ee73b6.png

glib 内存池说明

按照文中所说去修改 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 &#61;&#61; 0) {free(ptr);return NULL;}if (ptr &#61;&#61; NULL) {return malloc(size);}long *plen &#61; (long*)ptr;plen--;                          // Reach top of memorylong len &#61; *plen;if (size <&#61; len) {return ptr;}void* rptr &#61; malloc(size);if (rptr &#61;&#61; NULL) {free(ptr);return NULL;}rptr &#61; memcpy(rptr, ptr, len);free(ptr);return rptr;
}void free (void* ptr )
{if (ptr &#61;&#61; NULL) {return;}long *plen &#61; (long*)ptr;plen--;                          // Reach top of memorylong len &#61; *plen;               // Read lengthmunmap((void*)plen, len &#43; sizeof(long));
}

通过在自定义分配器当中埋点可以发现其实程序启动之后应用实际申请的堆外内存始终在 700M-800M 之间&#xff0c;gperftools 监控显示内存使用量也是在 700M-800M 左右。但是从操作系统角度来看进程占用的内存差别很大&#xff08;这里只是监控堆外内存&#xff09;。

笔者做了一下测试&#xff0c;使用不同分配器进行不同程度的扫包&#xff0c;占用的内存如下&#xff1a;

e1281809ddf3b256c76917d1bb435122.png

内存测试对比

为什么自定义的 malloc 申请 800M&#xff0c;最终占用的物理内存在 1.7G 呢&#xff1f;

因为自定义内存分配器采用的是 mmap 分配内存&#xff0c;mmap 分配内存按需向上取整到整数个页&#xff0c;所以存在着巨大的空间浪费。

通过监控发现最终申请的页面数目在 536k 个左右&#xff0c;那实际上向系统申请的内存等于 512k * 4k&#xff08;pagesize&#xff09; &#61; 2G。

为什么这个数据大于 1.7G 呢&#xff1f;因为操作系统采取的是延迟分配的方式&#xff0c;通过 mmap 向系统申请内存的时候&#xff0c;系统仅仅返回内存地址并没有分配真实的物理内存。

只有在真正使用的时候&#xff0c;系统产生一个缺页中断&#xff0c;然后再分配实际的物理 Page。

总结

5084e2b919d2c1d8f395de5edaab9938.png

流程图

整个内存分配的流程如上图所示。MCC 扫包的默认配置是扫描所有的 JAR 包。在扫描包的时候&#xff0c;SpringBoot 不会主动去释放堆外内存&#xff0c;导致在扫描阶段&#xff0c;堆外内存占用量一直持续飙升。

当发生 GC 的时候&#xff0c;SpringBoot 依赖于 finalize 机制去释放了堆外内存&#xff1b;但是 glibc 为了性能考虑&#xff0c;并没有真正把内存归返到操作系统&#xff0c;而是留下来放入内存池了&#xff0c;导致应用层以为发生了“内存泄漏”。所以修改 MCC 的配置路径为特定的 JAR 包&#xff0c;问题解决。

笔者在发表这篇文章时&#xff0c;发现 SpringBoot 的最新版本&#xff08;2.0.5.RELEASE&#xff09;已经做了修改&#xff0c;在 ZipInflaterInputStream 主动释放了堆外内存不再依赖 GC&#xff1b;所以 SpringBoot 升级到最新版本&#xff0c;这个问题也可以得到解决。

  • 觉得本文对你有帮助&#xff1f;请分享给更多人关注「全栈开发者社区」加星标&#xff0c;提升全栈技能
    本公众号会不定期给大家发福利&#xff0c;包括送书、学习资源等&#xff0c;敬请期待吧&#xff01;
    如果感觉推送内容不错&#xff0c;不妨右下角点个在看转发朋友圈或收藏&#xff0c;感谢支持。
    好文章&#xff0c;留言、点赞、在看和分享一条龙



推荐阅读
  • 深入解析Spring框架中的双亲委派机制突破方法
    在探讨Spring框架中突破双亲委派机制的方法之前,首先需要了解类加载器的基本概念。类加载器负责将类的全限定名转换为对应的二进制字节流。每个类在被特定的类加载器加载后,其唯一性得到保证。然而,这种机制在某些场景下可能会限制灵活性,因此Spring框架提供了一些策略来突破这一限制,以实现更加动态和灵活的类加载。这些策略不仅能够提升系统的可扩展性,还能在复杂的运行环境中确保类的正确加载和管理。 ... [详细]
  • Ceph API微服务实现RBD块设备的高效创建与安全删除
    本文旨在实现Ceph块存储中RBD块设备的高效创建与安全删除功能。开发环境为CentOS 7,使用 IntelliJ IDEA 进行开发。首先介绍了 librbd 的基本概念及其在 Ceph 中的作用,随后详细描述了项目 Gradle 配置的优化过程,确保了开发环境的稳定性和兼容性。通过这一系列步骤,我们成功实现了 RBD 块设备的快速创建与安全删除,提升了系统的整体性能和可靠性。 ... [详细]
  • Java中高级工程师面试必备:JVM核心知识点全面解析
    对于软件开发人员而言,随着技术框架的不断演进和成熟,许多高级功能已经被高度封装,使得初级开发者只需掌握基本用法即可迅速完成项目。然而,对于中高级工程师而言,深入了解Java虚拟机(JVM)的核心知识点是必不可少的。这不仅有助于优化性能和解决复杂问题,还能在面试中脱颖而出。本文将全面解析JVM的关键概念和技术细节,帮助读者全面提升技术水平。 ... [详细]
  • 2019年后蚂蚁集团与拼多多面试经验详述与深度剖析
    2019年后蚂蚁集团与拼多多面试经验详述与深度剖析 ... [详细]
  • JVM参数设置与命令行工具详解
    JVM参数配置与命令行工具的深入解析旨在优化系统性能,通过合理设置JVM参数,确保在高吞吐量的前提下,有效减少垃圾回收(GC)的频率,进而降低系统停顿时间,提升服务的稳定性和响应速度。此外,本文还将详细介绍常用的JVM命令行工具,帮助开发者更好地监控和调优JVM运行状态。 ... [详细]
  • Android目录遍历工具 | AppCrawler自动化测试进阶(第二部分):个性化配置详解
    终于迎来了“足不出户也能为社会贡献力量”的时刻,但有追求的测试工程师绝不会让自己的生活变得乏味。与其在家消磨时光,不如利用这段时间深入研究和提升自己的技术能力,特别是对AppCrawler自动化测试工具的个性化配置进行详细探索。这不仅能够提高测试效率,还能为项目带来更多的价值。 ... [详细]
  • 在Android平台上利用FFmpeg的Swscale组件实现YUV与RGB格式互转
    本文探讨了在Android平台上利用FFmpeg的Swscale组件实现YUV与RGB格式互转的技术细节。通过详细分析Swscale的工作原理和实际应用,展示了如何在Android环境中高效地进行图像格式转换。此外,还介绍了FFmpeg的全平台编译过程,包括x264和fdk-aac的集成,并在Ubuntu系统中配置Nginx和Nginx-RTMP-Module以支持直播推流服务。这些技术的结合为音视频处理提供了强大的支持。 ... [详细]
  • 本文深入探讨了Java枚举类型的使用与实践,详细解析了枚举的基本用法及其在实际开发中的应用。首先介绍了枚举作为常量的替代方案,自JDK 1.5起,通过枚举可以更加简洁、安全地定义常量,避免了传统方式中可能出现的错误。此外,文章还探讨了枚举在实现单例模式、状态机等场景中的优势,并提供了多个实际案例,帮助开发者更好地理解和运用这一强大的语言特性。 ... [详细]
  • 本文将深入探讨Java编程语言中顶级类`Object`的源码实现,旨在为Java新手提供进阶指导。`Object`类是所有Java类的基类,了解其内部机制对于提升编程技能至关重要。文章首先介绍了API文档的使用方法,这对于有开发经验的Java程序员来说是不可或缺的工具。通过详细解析`Object`类的关键方法和属性,读者可以更好地理解Java的核心原理和设计思想。此外,文章还提供了实际代码示例,帮助读者在实践中掌握这些知识。 ... [详细]
  • 在Java应用程序中调用`response.getStatus()`方法时遇到了`NoSuchMethodError`异常,经过分析,初步判断为依赖冲突问题。通过检查项目依赖树发现,当前项目版本与某些库的版本不兼容,导致该方法无法被正确识别。建议通过更新相关依赖版本或使用依赖管理工具(如Maven或Gradle)来解决此问题,确保所有依赖项版本一致且兼容。 ... [详细]
  • 深入解析Spring Boot源码的序章
    本系列文章旨在深入解析Spring Boot的源代码,分享笔者在学习过程中的心得与体会。内容涵盖核心源码分析,可能会对初学者造成一定理解难度,建议读者结合笔者提供的详细注释进行阅读,以获得更好的学习体验。 ... [详细]
  • 阿里巴巴Java后端开发面试:TCP、Netty、HashMap、并发锁与红黑树深度解析 ... [详细]
  • 深入解析:字符串与对象的对比及应用
    本文深入探讨了字符串与对象在编程中的对比及其应用场景。通过分析字符串作为不可变对象的特性,以及对象在内存中的存储方式,揭示了两者在性能和使用上的差异。文章还详细解析了Python中所有类均继承自`object`类的机制,并介绍了`getClass()`方法的底层实现,强调了`native`关键字的作用。此外,结合实际案例,讨论了在不同场景下选择字符串或对象的最佳实践。 ... [详细]
  • Spring Batch 异常处理与任务限制优化策略 ... [详细]
  • 表面缺陷检测数据集综述及GitHub开源项目推荐
    本文综述了表面缺陷检测领域的数据集,并推荐了多个GitHub上的开源项目。通过对现有文献和数据集的系统整理,为研究人员提供了全面的资源参考,有助于推动该领域的发展和技术进步。 ... [详细]
author-avatar
mobiledu2502930087
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有