记一次堆外内存泄漏分析

1. 背景

系统上线前压测发现某个图片分类服务频繁调用时出现:“内存一直不断上涨,到一段时间后逐渐平稳”,内存如下图所示:

在这里插入图片描述

该接口内部逻辑比较简单(示例代码如下):

  • Step 1:先将图片 resize 成算法要求的尺寸 224x224x3
  • Step 2:构造算法分类参数
  • Step 3:算法分类并返回结果
/**
* 功能:对图片对进行分类
* imageUrl:图片 url, 如 http://alicdn.imag.cn/i2/fdsafsdafsfs.png
**/
public Boolean isSizeImage(String imageUrl) {
  // 1. 对图片进行 resize
  float[] content = resize(imageUrl);
  
  // 2. 构造图片分类参数
  ImageClsRequest request = buildImageClsRequest(content);
  
  // 3. 图片分类
  return algorithmClient.predict(request);
}

private float[] resize(String imageUrl){
  // 读取图片
  BufferedImage image = ImageIO.read(imageUrl);
  // 对图片进行 resize
  BufferedImage newImage = new BufferedImage(..);
  // 图片进行重新绘制
  float[] content = new float[224*224*3];
  //.....
  return content;
}

图片的处理很容易产生内存泄漏的问题,通过系统监控发现负载、cpu、jvm 堆内存随着该接口的调用结束而恢复正常,GC频率和范围也在正常范围内,唯有内存上涨后未进行回收, 难道是堆外内存产生了泄漏?

2. JVM 内存分布与分析

2.1 JVM 内存分布

在这里插入图片描述

  • Metaspace:存储被虚拟机加载的类型信息(Java8将方法区的类型信息迁移到 metaspace)。

  • 程序计数器:当前线程所执行的字节码的行号指示器,各线程之间计数器互不影响,独立存储。

  • 本地方法栈: 作用与虚拟机栈发挥作用类似,本地方法栈是为虚拟机使用的 native 方法服务。

    可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法(如下图)。

    在这里插入图片描述

  • Code Cache: JVM 将字节码编译成汇编指令时,将这些指令存储在一个称为 Code Cache 的特殊非堆数据区域中。可使用-XX:InitialCodeCacheSize (默认160KB)和 -XX:ReservedCodeCacheSize (默认48MB)调整初始值和最大值。

  • Thread Stack: 所有线程分配的内存大小,可使用 -Xss 调整,64位操作系统中默认1MB。

  • GC算法使用的堆空间和 Compiler 自身操作需要的空间

  • Internal: 如命令行解析器使用的内存、JVMTI等。

  • Direct Memory(直接内存): jdk1.4 引入NIO,NIO 使用Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作,这样能避免在 Java 堆和 Native 堆中来回复制数据。直接内存受本地总内存大小以及处理器寻址空间的限制(可使用 -XX:MaxDirectMemorySize 配置最大内存)。

  • Mapped Memory: 通过系统调用 mmap 函数将某个文件映射到内存中,真正分配在操作系统内核,使用场景如 java 的零拷贝,将减少用户态和内核态的数据复制次数。

  • JNI Memory: 通过 java JNI 调用的 native 方法分配的内存。

附:通过 JVM NMT(native memory tracking)来追踪分析堆外内存,只能 Track JVM 自身的内存分配,第三方的 Native 库内存无法 track,不能 Trace JNI 里直接调用 malloc 时的内存分配,典型场景如 ZipInputStream 场景。

2.2 堆外内存泄漏分析思路

在这里插入图片描述

2.3 服务器 JVM 参数配置及实际内存分布

通过查看机器 jvm 参数如下(机器4c8g):

-Xms4g -Xmx4g #初始堆内存与最大堆内存4GB
-Xmn2g -XX:SurvivorRatio=10 #年轻代大小2GB,Eden 区与 Survivor 区的大小比值为10,即 Eden 区 1706.75MB,两个 Survior 各 170.625MB
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m #元空间256MB,最大512MB
-XX:MaxDirectMemorySize=1g  #直接内存大小 1GB
-XX:+UseConcMarkSweepGC  # 使用CMS作为老年代垃圾回收器

其中 NewRatio 的默认值是 2,即年轻的和老年代的内存比例1:2,但实际上不一定生效。内存实际分布图如下图:
在这里插入图片描述

通过 jmap 命令查看(会自动触发一次 full gc,慎用)内存分布如下:
在这里插入图片描述

2.4 JVM native 内存查看

JVM 具有 Native Memory Tracking 功能,通过 JVM 加上启动参数 -XX:NativeMemoryTracking=detail 可以统计内存使用详细信息。通过以下命令查看 jvm native 内存如下(NMT 的 committed 实际并非都是 JVM 进程已经占用的内存,而是系统分配了这么多内存):

jcmd 1942 VM.native_memory detail scale=MB

在这里插入图片描述

由上图看NMT 并无监控到大量的 native 内存分配的信息( 通过这点可知,NMT 只能追踪到部分 navtive 内存,追踪不到如直接内存等分布)。

2.5 手动触发 Full GC

手动 Full GC 触发前后机器内存并无发生变化,且堆内存表现正常,附常用 Full GC 方式:

 jcmd pid GC.run  # 方式一
 jmap -histo:live <pid> # 方式二 
 jmap -dump:live,file=dump_001.bin PID  # 方式三(jvm在执行该命令前会自动进行一次 full gc)

这时初步猜测: jvm 层面的监控与操作系统层面的监控存在差异,难道是 jvm 已经做回收了,而操作系统层面并无实质释放?

3. 问题排查经历

3.1 定位内存泄漏的位置及初步猜想
1)定位 RES 区域存在内存泄漏

找到 java 进程发现:RES 区域的内存大小(5GB)明显多于正常机器(2.7G),经过计算 RES 区域多的 2.3G 刚好是内存上涨的 29%(因为机器标准是 8G 内存,2.3G/8G=28.75%,监控上看是从37.41%上涨到66.94%)

ps -ef | grep "java"  # 找到运行的 java 服务进程,以1898为例
top -Hp 1898   # 查看该进行下线程内存监控情况(如下图)

在这里插入图片描述
在这里插入图片描述

这里对 VIRT 和 RES 两个区域做下解释:

  • VIRT(virtual memory usage):进程“需要的”虚拟内存大小,包括进程使用的库、代码、数据,以及malloc、new分配的堆空间和分配的栈空间等,另外 VIRT = SWAP + RES,其中 swap 区域是需要内存置换的区域(可参考操作系统虚拟内存原理)。
  • RES(resident memory usage):又称为 RSS,是进程在 RAM 中真正占用的内存大小。RES 包含了它所链接的动态库被加载到物理内存中的内存、栈内存和堆内存。动态链接库占用的内存会被多个进程共享,所以RSS并不能准确反映单进程的内存占用情况。

有人对 RES 区域做了详细的解释如下图:

在这里插入图片描述

即 RSS 内存区域包括了线程堆栈直接内存通过mmap映射的文件JVM字节码等。详细说明参考:https://stackoverflow.com/questions/38597965/difference-between-resident-set-size-rss-and-java-total-committed-memory-nmt

2)分析 RES 内存区域

pmap 命令用于显示进程的内存映射关系,如下图所示,最大块内存使用了4225732KB(4.03GB,正常机器只使用了1.97GB),其中 Address 列表示内存的开始位置, Mapping 列表示占用内存的文件(“anon”表示匿名映射内存)。

匿名映射就是用户空间需要分配一定的物理内存来存储数据,这部分内存不属于任何文件,内核就使用匿名映射将内存中的某段物理地址与用户空间一一映射,这样用户就可用直接操作虚拟地址来范围这段物理内存。比如使用 malloc 申请内存。

pmap -x 1898 | less # 显示进程的内存映射关系

在这里插入图片描述

经过查阅资源发现大多数 RES 区域内存泄漏总是是因 Linux glibc 内存碎片引起的,为了消除心中的疑虑,需要进一步排查是否因 glibc 原因引起。

3.2 解决 Linux glibc 内存碎片问题(66.94.12%下降到64.16%)

glibc 使用内存池为 java 应用通过 malloc 申请堆外内存,当 jvm 回收内存归还操作系统时,操作系统在 free 的时候并不会真正释放内存,而是维护到 glibc 的内存池中供下次使用,从而避免重复申请(当时也有个想法 jvm 和 linux 层面的内存监控不一致,是不是 linux 并未真正释放内存),为了验证想法,去机器上查看使用的 glibc 库,如下图:

在这里插入图片描述

上面看确实是原生的 glibc 版本,为了验证是否能自动释放内存碎片,手动执行调用 malloc_trim 函数:

gdb --batch --pid=1898 -ex "call (int)malloc_trim(0)"

验证结果返回1表明有内存释放(0表示无内存释放),说明存在内存优化空间,经查阅资料操作系统更好的内存管理是 jemalloc,有两种方式进行替换(这里使用的第二种):

  • 方式一:官网下载 jemalloc 安装更换 java 程序链接库即可,实践行得通(jemalloc 文档介绍:http://jemalloc.net/jemalloc.3.html#tuning)
sudo bash -c "sudo su"
sudo yum install git autoconf gcc make
git clone https://github.com/jemalloc/jemalloc
cd jemalloc
sh autogen.sh
./configure --enable-prof
make
make install

export LD_PRELOAD=/usr/local/lib/libjemalloc.so.2
  • 方式二:升级应用启动的 ajdk 版本(新版本使用 jemalloc 来进行内存分配和释放)
# 旧的,底层内存分配使用 glibc
http://***/7/x86_64/current/ali-jdk/ali-jdk-8.4.7-1519273.alios7.x86_64.rpm
# 新的,底层内存分配使用 jemalloc
http://***/7/x86_64/current/ajdk/ajdk-8.10.15_fp13-20210830151435.alios7.x86_64.rpm

经过替换后,如下图所示已经成功替换为 jemalloc:
在这里插入图片描述

再次压测内存从 66.94.12%下降到64.16%,有一定的效果但并不明显,还要进一步排查。

3.3 使用 gdb dump并分析RES 内存区域,尝试解决流未关闭问题,未奏效
1)安装 gdb 并 dump 下 RES 内存
vim /proc/1984/smaps # 查看分配的内存地址空间 6c0000000-7c1f00000
sudo yum install gdb  # 安装 gdb
gdb attach 1984 
dump memory /tmp/fc-01.dump 0x6c0000000 0x700000000 # 分三次进行dump
dump memory /tmp/fc-02.dump 0x700000000 0x7b1f00000
dump memory /tmp/fc-03.dump 0x7b1f00000 0x7c1f00000
strings /tmp/fc-03.dump # 查看内存内容

在这里插入图片描述

2)分析 RES 内存内存,猜测可能流未关闭

通过查看 RES 内存内容(如下图),发现里面很多字节流内容:

在这里插入图片描述

怀疑是不是在进行 http 请求时流未关闭,核心代码如下(左边依赖中间件原代码,右边改造后代码):
在这里插入图片描述

使用 arthas 热部署工具 ArthasHotSwap 修改代码后,再次压测堆外内存上涨问题仍然存在,排除依赖http流未关闭问题。

3.4 使用 jemalloc 工具进行内存引用分析,并定位问题

上面探索无果后,开始深挖堆外内存分配原理,堆外内存的分配一般是通过以下两种方式进行分配:

  • 方式一:java.nio.ByteBuffer#allocateDirect
  • 方式二:sun.misc.Unsafe#allocateMemory

第一种方式使用直接内存,从监控上在整个压测过程只分配2MB左右,忽略不计。所以猜测大部分来自 sun.misc.Unsafe#allocateMemory 分配,为了进一步分析内存分配情况,以下在压测过程中结合 jemalloc 工具来进行具体分析。

1)使用 jemalloc 工具生成内存引用

升级 ajdk 后便自动完成 jemalloc 的安装,这里增加配置每分配1MB内存就自动 dump 内存文件,手动配置如下:

export MALLOC_CONF="prof:true,lg_prof_interval:30,lg_prof_sample:17,prof_prefix:/home/admin/fc/prof_prefix"

上述配置命令参数如下:

  • lg_prof_interval:比如值是 20 表示1MB(2^20),即程序在运行时,每分配(大约)1MB就会 dump 产生一个文件
  • prof:true,表示打开profiling
  • lg_prof_sample:分配样本之间的平均间隔(以log2为基数),以分配活动的字节数衡量。增加采样间隔不仅会降低轮廓保真度,而且会降低计算开销。默认采样间隔为 512 KiB (2^19 B)。
  • prof_prefix:profile dump 文件名前缀。如果前缀设置为空字符串,则不会发生自动转储。示例:jeprof.25851.42.i42.heap

压测过程中生成的 dump 文件如下:

在这里插入图片描述

然后对 dump 文件转换成 svg 图片,再上传到 oss 上下载到本地进行分析(如下图所示):

yum install graphviz
jeprof --svg /***/java jeprof.25959.0.f.heap > 25959.svg # 服务器上生成 svg 图片

在这里插入图片描述

jemalloc 工具分配内存的方式是使用 jemalloc 系统调用函数来分配的对象内存,整个压测过程中jemalloc 分配内存的大小从 220MB 上升到 863MB(上涨643MB),所以有以下结论:

  • sun.misc.Unsafe#allocateMemory:分配内存的主要来源,重点分析
  • com.sun.imageio.plugins.jpeg.JPEGImageReader#readImage:本身不是通过 sun.misc.Unsafe#allocateMemory 分配的内存,可忽略
  • java.util.zip.Inflater#inflateBytes :文件压缩使用,占比不是特别大
2)定位 sun.misc.Unsafe#allocateMemory 上层内存分配函数

由于 sun.misc.Unsafe#allocateMemory 是 JNI 函数,Arthas 和 Btrace 工具都无法对其进行追踪,只得采用本地模拟线上进行不间断请求(另一种方式可使用 arthas 或者 btrace 对 JNI 上游函数进行追踪),结合 JProfiler 插件对 sun.misc.Unsafe#allocateMemory 进行 incoming refrence 分析的方式,引用关系如下图所示:

在这里插入图片描述
在这里插入图片描述

逐一排查(查看哪个类内部调用了 sun.misc.Unsafe#allocateMemory)发现以下三个引用:

  • sun.java2d.pipe.RenderBuffer:图片resize时调用
  • sun.nio.ch.NativeObject:NIO线程分配
  • java.nio.DirectByteBuffer:NIO线程分配

通过本地 debug 方式发现每次调用图片 resize 都会创建 Graphics2D ,同时会创建 GraphicsEnvironment,而在初始化GraphicsEnvironment的实例CGraphicsEnvironment时, 会创建并初始化 OGLRenderQueue,这里一次性分配了32000 byte(约32KB,粗估算了下压测请求共请求约 16888 次,不包含后续内存的写入,该类初始化分配需要的堆外内存约 16888*32/1024MB = 527MB) ,源码如下:

在这里插入图片描述

GraphicsEnvironment为Java应用程序提供了特定平台的GraphicsDevice对象和Font 对象集合。 这些GraphicsDevice可以是各种本机和远端机器的资源,如屏幕、打印机或者是Image Buffer,甚至是Graphics2D绘图方法的目标对象

OGL-specific implementation of RenderQueue. This class provides a single (daemon) thread that is responsible for periodically flushing
the queue, thus ensuring that only one thread communicates with the native OpenGL libraries for the entire process.

3.5 原因最终分析与解决方案

​ 经上述分析产生堆外内存上涨的原因是由于使用 java 原生 jdk 重新绘图引起的,理论上不会存在代码漏洞,所以猜测一次请求(一个线程)使用的堆外内存大小是有限的,压测过程中会不断复用线程的上下文信息,再加上栈帧对垃圾回收的影响(线程变量槽复用,垃圾回收不掉),所以得出结论:给定压力(线程数)情况下,使用的堆外内存是一定,不存在堆外内存泄漏的情况,也就是说在当前压力下,必须要使用这么大的堆外内存。 后来为了验证进行反复压测,发现堆外内存并不会随着压测的次数增加而上涨,而是稳定在一个固定值上。

1)代码显示调用 Sytem.gc() 释放内存,内存由原来的 64.16%下降到 50.11%

​ JNI 申请的内存分配在 jvm 堆外,不受垃圾回收器的管理,需要手动调用 Sytem.gc() 方法来释放内存,但 java 代码中一般不建议直接使用 Sytem.gc() 方法来释放内存,触发 Sytem.gc() 方法会 stop the world,从而影响 java 进程正常工作,这里可以结合 -XX:+ExplicitGCInvokesConcurrent 参数,表示可以进行并发的混合回收,这是 jvm 对 Native memory 的一个优化(jvm参数中不能带 -XX:+DisableExplicitGC)


事实上显式调用 System.gc ,是希望通过 Full GC 来强迫已经无用的 DirectByteMemory 对象释放掉它们关联的 Native Memory,HotSpot VM 只会在 Old GC 的时候才会对 Old 中的对象做 Reference Processing,而在 Young GC 时只会对 Young 里的对象做 Reference Processing。Young 中的 DirectByteBuffer 对象会在 Young GC 时被处理,也就是说,做 CMS GC 的话会对 Old 做 Reference Processing,进而能触发 Cleaner 对已死的 DirectByteBuffer 对象做清理工作。但如果很长一段时间里没做过 GC 或者只做了 Young GC 的话则不会在 Old 触发 Cleaner 的工作,那么就可能让本来已经死亡,但已经晋升到 Old 的 DirectByteBuffer 关联的 Native Memory 得不到及时释放。这几个实现特征使得依赖于 System.gc 触发 GC 来保证 DirectByteMemory 的清理工作能及时完成。

摘自美团技术博客:Java中9种常见的CMS GC问题分析与解决:https://tech.meituan.com/2020/11/12/java-9-cms-gc.html

2)压测稳定后,设置接口限流

为了防止过大流量耗尽机器堆外内存产生 OOM,所以需要对接口限流。

  • 7
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
Java堆外内存泄露是指在使用Java NIO进行堆外内存分配时,由于程序逻辑或代码问题导致堆外内存没有得到正确的释放,从而导致占用的内存越来越多,最终导致内存泄露。 下面是一些可能导致Java堆外内存泄露的原因和解决方案: 1. 内存分配和释放不匹配。在使用Java NIO进行堆外内存分配时,需要及时释放内存,否则会导致内存泄露。解决方法是确保内存分配和释放匹配,特别是在使用DirectByteBuffer分配堆外内存时,需要显式地调用ByteBuffer的clear()或compact()方法释放内存。 2. 使用ByteBuffer时没有适当的缓冲区管理。当使用ByteBuffer时,需要及时刷新缓冲区,否则会导致内存泄露。解决方法是及时刷新缓冲区,例如使用ByteBuffer的flip()方法刷新缓冲区。 3. 内存泄露分析工具不够好。目前有很多内存泄露分析工具,但是并不是所有工具都能准确地检测Java堆外内存泄露。解决方法是选择一款专门针对Java堆外内存泄露的工具进行分析。 4. 不合理的内存分配策略。在使用Java NIO进行堆外内存分配时,需要注意内存分配策略,例如一次分配太多内存可能会导致内存泄露。解决方法是根据实际情况选择合适的内存分配策略,例如分段分配内存等。 希望这些解决方案能帮助您解决问题。如果仍然存在问题,请提供更多详细信息,以便更好地了解问题并提供更好的建议。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bboyzqh

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值