工作三年,为什么你还不会排查堆外内存泄漏?(上)

前言

本文章是系列文章「Java经典故障案例分享」的第二篇(第一篇:<<# Java故障案例分析第一期:父子任务使用不当线程池死锁>>),本次分享一个完整的排查堆外内存泄漏的过程,你将在本文章中了解到:

  • Native Memory Track是什么以及如何发现可能存在的内存泄漏问题
  • 如何使用pmap命令查看Java进程的内存映射状态
  • 如何结合NMTpmap排查内存泄漏

本文的主要内容:

  1. 故障描述
  2. 故障排查过程
  3. 提供一个小工具代码帮助快速分析

本文分为上下两篇,上篇主要讲述一般的内存泄漏排查过程,下篇讲述Netty堆外内存泄漏排查。

接下来就让我们开始吧!

故障描述

线上服务机器内存逐渐降低,最终触发内存不足告警,经过初步排查,进程所占用内存(通过ps的RSS查看)和JVM通过Native Memory Track显示的内存相差较大,达到2G以上。

故障排查过程

PS查看结果: NMT查看结果:

ps结果 - nmt结果 = 2098913 Kbytes(进程总占用 - JVM占用) 约等于 2G,这2G内存到底是什么占用的呢?

简单介绍下我们这个应用:该应用是一个监控系统后端,是基于美团开源的CAT魔改的,它直接使用Netty从网络上读取业务应用上传的埋点数据,经过聚合计算后将结果存储到磁盘和数据库里。所以我们第一时间怀疑是不是Netty的问题,因为CAT是直接使用的Netty没有其他封装。

是不是Netty?

为了排查是不是Netty造成的,我们从Netty暴露的PooledByteBufAllocatorMetric中抓取使用的直接内存大小并配置监控,发现并没有一直增长:

而且通过应用日志,也没有Netty的泄漏日志。

通过调研,Netty的直接内存使用是在NMT track的一部分,在Internal项里:

ini 复制代码
...
-                  Internal (reserved=1100166KB, committed=1100166KB)
                            (malloc=1100134KB #68063) 
                            (mmap: reserved=32KB, committed=32KB) 
...

既然不是Netty造成的,那还有其他可能吗?

有的,比如:使用Native方法分配内存没有正确释放(即没有使用DirectByteBuffer,但是自己使用JNI等方式分配了不受JVM管理的内存) ,而我们极有这种情况。

那该如何排查呢?

有两种方式:

  • 直接点,找谁在分配内存,即找调用分配内存的函数的堆栈
  • 泄漏内存的数据发现一些特点,顺藤摸瓜找到原因

第一种方法可以用perf工具找(如果是较新版本的内核可以用eBPF相关的工具如bcc、bpftrace排查,但我们线上内核版本是3.10)。但内存分配是非常频繁的操作,这种方法很难从大量调用中找到那个存在问题的,而且可能导致性能问题。所以我们使用第二种方法。

下面我们介绍这种情况的排查方式,即如何利用NMT + pmap分析泄漏的内存中的数据。

首先第一步要找到泄漏内存,第二步根据其中的数据找到原因

使用NMT & pmap找泄漏内存

什么是Native Memory Track?

首先介绍下NMT,全称是Native Memory Tracking (NMT) ,它是Hotspot VM用来分析VM内部内存使用情况的一个功能。我们可以利用jcmd(jdk自带)这个工具来访问NMT的数据。

开启方法:在启动参数中加上:

-XX:NativeMemoryTracking=summary或者-XX:NativeMemoryTracking=detail,在我们的排查中需要使用detail,但是detail有一定性能损失,切记不要长时间开启。detail相比summary包含了虚拟内存映射的信息和造成内存使用的调用栈信息。

查看方式:

jcmd $(pid) VM.native_memory summary 或 jcmd $(pid) VM.native_memory detail

我们使用NMT查看各个JVM内存区域占用的大小和虚拟内存预设情况,大致输出如下:

ini 复制代码
31748:

Native Memory Tracking:

Total: reserved=29167352KB, committed=18409052KB
-                 Java Heap (reserved=25165824KB, committed=15990784KB)
                            (mmap: reserved=25165824KB, committed=15990784KB) 
 
-                     Class (reserved=1138901KB, committed=101205KB)
                            (classes #14554)
                            (malloc=2261KB #32680) 
                            (mmap: reserved=1136640KB, committed=98944KB) 
 
-                    Thread (reserved=249421KB, committed=249421KB)
                            (thread #434)
                            (stack: reserved=247492KB, committed=247492KB)
                            (malloc=1421KB #2185) 
                            (arena=507KB #854)
 
-                      Code (reserved=260320KB, committed=104388KB)
                            (malloc=10720KB #13161) 
                            (mmap: reserved=249600KB, committed=93668KB) 
 
-                        GC (reserved=1161029KB, committed=820549KB)
                            (malloc=194373KB #712117) 
                            (mmap: reserved=966656KB, committed=626176KB) 
 
-                  Compiler (reserved=1045KB, committed=1045KB)
                            (malloc=915KB #1843) 
                            (arena=131KB #15)
 
-                  Internal (reserved=1100166KB, committed=1100166KB)
                            (malloc=1100134KB #68063) 
                            (mmap: reserved=32KB, committed=32KB) 
 
-                    Symbol (reserved=19431KB, committed=19431KB)
                            (malloc=16770KB #169790) 
                            (arena=2661KB #1)
 
-    Native Memory Tracking (reserved=16399KB, committed=16399KB)
                            (malloc=622KB #8758) 
                            (tracking overhead=15777KB)
 
-               Arena Chunk (reserved=5664KB, committed=5664KB)
                            (malloc=5664KB) 
 
-                   Unknown (reserved=49152KB, committed=0KB)
                            (mmap: reserved=49152KB, committed=0KB)
Virtual memory map:
 
[0x00000001f0000000 - 0x00000007f0000000] reserved 25165824KB for Java Heap from
    [0x00007fce85c50c84] ReservedHeapSpace::ReservedHeapSpace(unsigned long, unsigned long, bool, char*)+0xb4
    [0x00007fce85c1896f] Universe::reserve_heap(unsigned long, unsigned long)+0x3bf
    [0x00007fce8571773d] G1CollectedHeap::initialize()+0x15d
    [0x00007fce85c18fca] Universe::initialize_heap()+0x16a

	[0x00000001f0000000 - 0x00000005c0000000] committed 15990784KB from
            [0x00007fce857317c4] G1PageBasedVirtualSpace::commit_internal(unsigned long, unsigned long)+0x224
            [0x00007fce8573184e] G1PageBasedVirtualSpace::commit(unsigned long, unsigned long)+0x7e
            [0x00007fce85734345] G1RegionsLargerThanCommitSizeMapper::commit_regions(unsigned int, unsigned long)+0x35
            [0x00007fce8579f636] HeapRegionManager::commit_regions(unsigned int, unsigned long)+0x76
 
[0x00000007f0000000 - 0x0000000830000000] reserved 1048576KB for Class from
    [0x00007fce85c50021] ReservedSpace::ReservedSpace(unsigned long, unsigned long, bool, char*, unsigned long)+0x91
    [0x00007fce85a0e1bf] Metaspace::allocate_metaspace_compressed_klass_ptrs(char*, unsigned char*)+0x4f
    [0x00007fce85a0ec74] Metaspace::global_initialize()+0x574
    [0x00007fce85c192e1] universe_init()+0x61 

这里列出了JVM内部各个区域如堆、Class、线程等占用的内存。

而在 Virtual memory map: 下方的就是虚拟内存每个地址区域是用来做什么的,比如0x00000001f0000000 - 0x00000007f0000000这24GB的地址空间是用来映射堆内存的,而我们应用的最大堆内存就是24GB。

但是这里均是JVM管理下的内存,我们没办法从这里找出为什么RSS占用的内存大小比NMT中的多的原因,如果我们能够知道Java进程所有的内存映射然后和这里的比较,找出在NMT中没有的就能够知道是什么在占用我们的内存,那么如何知道一个进程所有的内存映射呢?

接下来介绍pmap命令。

pmap命令

PMAP命令用于展示一个或多个进程的内存映射,命令格式如下:

pmap [options ] pid [ ...]

下面是一个例子:

lua 复制代码
pmap 25693

000055bde4835000      8K r-x-- gedit

000055bde4a36000      4K r---- gedit

000055bde4a37000      4K rw--- gedit

000055bde5d32000  13944K rw---   \[ anon ]

00007fc910000000    132K rw---   \[ anon ]

00007fc910021000  65404K -----   \[ anon ]

00007fc918000000    896K rw---   \[ anon ]

00007fc9180e0000  64640K -----   \[ anon ]

00007fc91c750000    204K r---- UbuntuMono-R.ttf

00007fc91c783000    644K r-x-- libaspell.so.15.2.0

00007fc91c824000   2048K ----- libaspell.so.15.2.0

00007fc91ca24000     20K r---- libaspell.so.15.2.0

00007fc91ca29000      4K rw--- libaspell.so.15.2.0

00007fc91ca2a000      8K r-x-- libenchant\_aspell.so

00007fc91ca2c000   2044K ----- libenchant\_aspell.so

00007fc91cc2b000      4K r---- libenchant\_aspell.so

00007fc91cc2c000      4K rw--- libenchant\_aspell.so

00007fc91cc2d000     44K r-x-- libenchant\_hspell.so

00007fc91cc38000   2044K ----- libenchant\_hspell.so

00007fc91ce37000      4K r---- libenchant\_hspell.so

00007fc91ce38000     12K rw--- libenchant\_hspell.so

00007fc91ce3b000    428K r-x-- libhunspell-1.6.so.0.0.1

00007fc91cea6000   2044K ----- libhunspell-1.6.so.0.0.1

00007fc91d0a5000      4K r---- libhunspell-1.6.so.0.0.1

00007fc91d0a6000     16K rw--- libhunspell-1.6.so.0.0.1

00007fc91d0aa000     16K r-x-- libenchant\_myspell.so

00007fc91d0ae000   2048K ----- libenchant\_myspell.so

00007fc91d2ae000      4K r---- libenchant\_myspell.so

...

...

...

可以看到每个映射的起始地址和大小,而如果和NMT下面的输出一起来看是不是能够分析出哪些映射是在NMT中没有的?

结合pmap和NMT

我们可以按照这个思路手动在pmap的输出中找哪些没有在nmt输出中出现的,或者写一个shell脚本来匹配。

这里我写了一个脚本在github上:github.com/hengyoush/J...,或者直接下载,使用方法:./memleak.sh show pid

发现几个64MB的块很可疑,如:

但是该怎么查看这个地址块的内容呢?

泄漏内存分析

这些地址块可以通过下面的脚本查看:

bash 复制代码
 cat /proc/$1/maps | grep -Fv ".so" | grep " 0 " | awk '{print $1}' | grep $2 | ( IFS="-"
  while read a b; do
    dd if=/proc/$1/mem bs=$( getconf PAGESIZE ) iflag=skip_bytes,count_bytes \
    skip=$(( 0x$a )) count=$(( 0x$b - 0x$a )) of="$1_mem_$a.bin"
    color_text "$GREEN$BOLD" "Dump文件已输出至: ./$1_mem_$a.bin"  
  done )

将上述脚本保存为dump.sh, 然后执行:./dump.sh ${pid} ${address}

比如:./dump.sh 31748 7fcbfb622000

可以得到如下的文件:31748_mem_7fcbfb622000.bin

这个文件中包含的就是该地址块的内容,但是是二进制的,可以使用如下命令转为字符串方便查看:strings 31748_mem_7fcbfb622000.bin > 31748_mem_7fcbfb622000.bin.txt

查看转换后的内容:

我们发现这应该是一个接口返回的监控报表数据(我们的这个服务是一个监控服务,会有客户端拉取监控数据),查找了相关代码后终于水落石出了!!原来在使用servlet输出流写响应时没有正确使用try-with-resource,当客户端应用主动关闭连接时(发布或者重启),写入失败,但是流没有关闭,相关代码大致如下:

修复上线之后,内存不再诡异减少了,RSS和NMT输出也不会越来越大了。

总结

我帮你总结了一下排查步骤: 当你怀疑线上出现内存泄漏时,首先确定是堆外内存造成的还是堆内,堆外内存造成的有如下特点:

  1. JVM本身没有大量FGC等情况
  2. 机器内存不足
  3. RSS - NMT统计的内存 差距较大

如果确定是堆外内存造成,按照如下方式排查:

  1. 确定是否是Netty内存泄漏,排查方式有:应用日志有没有Netty的泄漏日志,Netty的直接内存指标有没有一直上升。

  2. 如果不是Netty内存泄漏,按照如下流程:

    1. 使用pmap -x {pid} > ./pmap.txt 获取内存映射信息到文件里。
    2. 使用jcmd ${pid} VM.native_memory detail > nmt.txt获取NMT统计的JVM内存详细信息。
    3. 找nmt中没有但是在pmap中有的内存块地址(tips1:最好找64MB的内存块)
    4. 使用上面的dump.sh:./dump.sh {pid} {addr} dump对应内存块的内容到磁盘上。
    5. 使用 strings {dump文件名} > dumped.txt转换为可读的文本,然后根据文本内容进一步寻找问题的原因

是不是还挺复杂的,没关系,使用我写的一个小工具JavaMemLeak(Github地址)可以帮你自动化上述流程,只要执行:./memleak.sh show pid列出可能存在泄漏的内存映射地址:

css 复制代码
00007f2824000000   19396   19396   19396 rw---   [ anon ]
00007f28252f1000   46140       0       0 -----   [ anon ]
00007f2830000000    9752    9672    9672 rw---   [ anon ]
00007f2830986000   55784       0       0 -----   [ anon ]
00007f2834000000   11624   11624   11624 rw---   [ anon ]

命令行执行:./memleak.sh dump pid addr 得到内存块里的内容进一步分析,输出如下:

makefile 复制代码
Dump文件已输出至: ./11983_mem_7f2964000000.bin

最终会产出一个泄漏内存块的文件,你只需要从第五步开始就可以啦!

如果有帮到你的话欢迎star:github.com/hengyoush/J...

相关推荐
canonical_entropy4 分钟前
最小变更成本 vs 最小信息表达:第一性原理的比较
后端
渣哥4 分钟前
代理选错,性能和功能全翻车!Spring AOP 的默认技术别再搞混
javascript·后端·面试
间彧20 分钟前
Java泛型详解与项目实战
后端
间彧30 分钟前
PECS原则在Java集合框架中的具体实现有哪些?举例说明
后端
间彧32 分钟前
Java 泛型擦除详解和项目实战
后端
间彧35 分钟前
在自定义泛型类时,如何正确应用PECS原则来设计API?
后端
间彧36 分钟前
能否详细解释PECS原则及其在项目中的实际应用场景?
后端
武子康1 小时前
大数据-132 Flink SQL 实战入门 | 3 分钟跑通 Table API + SQL 含 toChangelogStream 新写法
大数据·后端·flink
李辰洋1 小时前
go tools安装
开发语言·后端·golang
wanfeng_091 小时前
go lang
开发语言·后端·golang