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

前言

本文章是系列文章「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...

相关推荐
0zxm31 分钟前
06 - Django 视图view
网络·后端·python·django
m0_7482571831 分钟前
Spring Boot FileUpLoad and Interceptor(文件上传和拦截器,Web入门知识)
前端·spring boot·后端
小_太_阳1 小时前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师2 小时前
Spring基础分析13-Spring Security框架
java·后端·spring
搬码后生仔3 小时前
asp.net core webapi项目中 在生产环境中 进不去swagger
chrome·后端·asp.net
凡人的AI工具箱3 小时前
每天40分玩转Django:Django国际化
数据库·人工智能·后端·python·django·sqlite
Lx3524 小时前
Pandas数据重命名:列名与索引为标题
后端·python·pandas
小池先生4 小时前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
百罹鸟4 小时前
【vue高频面试题—场景篇】:实现一个实时更新的倒计时组件,如何确保倒计时在页面切换时能够正常暂停和恢复?
vue.js·后端·面试
小蜗牛慢慢爬行5 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate