前言
本文章是系列文章「Java经典故障案例分享」的第二篇(第一篇:<<# Java故障案例分析第一期:父子任务使用不当线程池死锁>>),本次分享一个完整的排查堆外内存泄漏的过程,你将在本文章中了解到:
- Native Memory Track是什么以及如何发现可能存在的内存泄漏问题
- 如何使用pmap命令查看Java进程的内存映射状态
- 如何结合NMT 和pmap排查内存泄漏
本文的主要内容:
- 故障描述
- 故障排查过程
- 提供一个小工具代码帮助快速分析
本文分为上下两篇,上篇主要讲述一般的内存泄漏排查过程,下篇讲述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输出也不会越来越大了。
总结
我帮你总结了一下排查步骤: 当你怀疑线上出现内存泄漏时,首先确定是堆外内存造成的还是堆内,堆外内存造成的有如下特点:
- JVM本身没有大量FGC等情况
- 机器内存不足
RSS - NMT
统计的内存 差距较大
如果确定是堆外内存造成,按照如下方式排查:
-
确定是否是Netty内存泄漏,排查方式有:应用日志有没有Netty的泄漏日志,Netty的直接内存指标有没有一直上升。
-
如果不是Netty内存泄漏,按照如下流程:
- 使用
pmap -x {pid} > ./pmap.txt
获取内存映射信息到文件里。 - 使用
jcmd ${pid} VM.native_memory detail > nmt.txt
获取NMT统计的JVM内存详细信息。 - 找nmt中没有但是在pmap中有的内存块地址(tips1:最好找64MB的内存块)
- 使用上面的
dump.sh:./dump.sh {pid} {addr}
dump对应内存块的内容到磁盘上。 - 使用
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...