关于内存泄露:使用mtrace追踪JVM堆外内存泄露

50次阅读

共计 3853 个字符,预计需要花费 10 分钟才能阅读完成。

原创:扣钉日记(微信公众号 ID:codelogs),欢送分享,非公众号转载保留此申明。

简介

在上篇文章中,介绍了应用 tcmalloc 或 jemalloc 定位 native 内存泄露的办法,但应用这个办法相当于更换了原生内存分配器,以至于应用时会有一些顾虑。

通过一些摸索,发现 glibc 自带的 ptmalloc2 分配器,也提供有追踪内存泄露的机制,即 mtrace,这使得产生内存泄露时,可间接定位,而不须要额定装置及重启操作。

mtrace 追踪内存泄露

glibc 中提供了 mtrace 这个函数来开启追踪内存调配的性能,开启后每次应用程序调用 malloc 或 free 函数时,会将内存调配开释操作记录在 MALLOC_TRACE 环境变量所指的文件外面,如下:

$ pid=`pgrep java`

# 配置 gdb 不调试信号,防止 JVM 收到信号后被 gdb 暂停
$ cat <<"EOF" > ~/.gdbinit
handle all nostop noprint pass
handle SIGINT stop print nopass
EOF

# 设置 MALLOC_TRACE 环境变量,将内存调配操作记录在 malloc_trace.log 里
$ gdb -q -batch -ex 'call setenv("MALLOC_TRACE","./malloc_trace.log", 1)' -p $pid

# 调用 mtrace 开启内存调配追踪
$ gdb -q -batch -ex 'call mtrace()' -p $pid

# 一段时间后,调用 muntrace 敞开追踪
$ gdb -q -batch -ex 'call muntrace()' -p $pid

而后查看 malloc_trace.log,内容如下:

能够发现,在开启 mtrace 后,glibc 将所有 malloc、free 操作都记录了下来,通过从日志中找出哪些地方执行了 malloc 后没有 free,即是内存泄露点。

于是 glibc 又提供了一个 mtrace 命令,其作用就是找出下面说的执行了 malloc 后没有 free 的记录,如下:

$ mtrace malloc_trace.log | less -n
Memory not freed:
-----------------
           Address     Size     Caller
0x00007efe08008cc0     0x18  at 0x7efe726e8e5d
0x00007efe08008ea0    0x160  at 0x7efe726e8e5d
0x00007efe6cabca40     0x58  at 0x7efe715dc432
0x00007efe6caa9ad0   0x1bf8  at 0x7efe715e4b88
0x00007efe6caab6d0   0x1bf8  at 0x7efe715e4b88
0x00007efe6ca679c0   0x8000  at 0x7efe715e4947

# 按 Caller 分组统计一下,看看各 Caller 各泄露的次数及内存量
$ mtrace malloc_trace.log | sed '1,/Caller/d'|awk '{s[$NF]+=strtonum($2);n[$NF]++;}END{for(k in s){print k,n[k],s[k]}}'|column -t
0x7efe715e4b88  1010  7231600
0x7efe715dc432  1010  88880
0x7efe715e4947  997   32669696
0x7efe726e8e5d  532   309800
0x7efe715eb2f4  1     72
0x7efe715eb491  1     38

能够发现,0x7efe715e4b88 这个调用点,泄露了 1010 次,那怎么晓得这个调用点在哪个函数里呢?

依据指令地址找函数

之前咱们介绍过 Linux 过程的虚拟内存布局,如下:

  • Stack:栈,向下扩大,为线程调配的栈内存。
  • Memory Mapping Segment:内存映射区域,通过 mmap 调配,如映射的 *.so 动静库、动态分配的匿名内存等。
  • Heap:堆,向上扩大,动静分配内存的区域。
  • Data Segment:数据段,个别用来存储如 C 语言中的全局变量。
  • Code Segment:代码段,对于 JVM 来说,它从 bin/java 二进制文件加载而来。

而对于 JVM 来说,bin/java 只是一个启动过程的壳,真正的代码根本都在动静库中,如 libjvm.so、libzip.so 等。

而在 Linux 中,动静库都是间接加载的,如下:

因而,通过如下步骤,即可晓得某个指令地址来自哪个函数,如下:

  • 依据指令地址,找到其所属的动静库,以及动静库在过程虚拟内存空间中的起始地址。
  • 依据指令地址减去起始地址,算出指令在动静库中的偏移量地址。
  • 反汇编动静库文件,依据偏移量地址查找指令所在函数。
  1. 找动静库及起始地址

    $ pmap -x $pid -p -A 0x7efe715e4b88
    Address           Kbytes     RSS   Dirty Mode  Mapping
    00007efe715d9000     108     108       0 r-x-- /opt/jdk8u222-b10/jre/lib/amd64/libzip.so
    ---------------- ------- ------- -------
    total kB             108  163232  160716

    通过 pmap 的 - A 选项,能够通过内存地址找内存映射区域,如上,Mapping 列就是内存映射区域对应的动静库文件,而 Address 列是其在过程虚拟内存空间中的起始地址。

  2. 计算指令在动静库中的偏移量

    # 指令地址减去动静库起始地址
    $ printf "%x" $((0x7efe715e4b88-0x00007efe715d9000))
    bb88
  3. 反汇编并查找指令

    $ objdump -d /opt/jdk8u222-b10/jre/lib/amd64/libzip.so | less -n

    能够发现,过程地址 0x7efe715e4b88 上的指令,在 inflateInit2_ 函数中。

当然,下面步骤有点简单,其实也能够通过 gdb 来查,如下:

gdb -q -batch -ex 'info symbol 0x7efe715e4b88' -p $pid

这样,咱们找到了泄露的原生函数名,那是什么 java 代码调用到这个函数的呢?

通过原生函数名找 Java 调用栈

通过 arthas 的 profiler 命令,能够采样到原生函数的调用栈,如下:

[arthas@1]$ profiler execute 'start,event=inflateInit2_,alluser'
Profiling started
[arthas@1]$ profiler stop
OK
profiler output file: .../arthas-output/20230923-173944.html

关上这个 html 文件,能够发现相干的 Java 调用栈,如下:

至此,咱们堆外内存泄露的代码门路就找到了,只须要再看看代码,辨认一下哪些代码门路的确会导致内存泄露即可。

注:通过测试,发现 profiler 其实能够间接应用指令地址,所以不转换为函数名称,也是 OK 的。

通过 jna 开启 mtrace

gdb 理论是 C /C++ 的调试程序,通过 gdb 来间接调用 native 函数,可能会呈现一些不确定因素。

家喻户晓,Java 提供了 JNI 机制,可实现 Java 调用 native 函数,而 jna(Java Native Access)则对 JNI 技术进行了封装,大大简化了 Java 调用 native 函数的开发工作。

因而,咱们能够应用 jna 来调用 mtrace 等 native 函数,如下:

  1. 引入 jna 库

    <dependency>
     <groupId>net.java.dev.jna</groupId>
     <artifactId>jna</artifactId>
     <version>4.2.2</version>
    </dependency>
  2. 封装并调用 native 函数

    public class JnaTool {
     public interface CLibrary extends Library {void malloc_stats();
         void malloc_trim(int pad);
         void setenv(String name, String value, int overwrite);
         void mtrace();
         void muntrace();}
    
     private static CLibrary cLibrary;
    
     static {
         try {cLibrary = (CLibrary) Native.loadLibrary("c", CLibrary.class);
         } catch (Exception e) {e.printStackTrace();
         }
     }
    
     public static void mtrace(String traceFile) {if (cLibrary == null) return;
         cLibrary.setenv("MALLOC_TRACE", traceFile, 1);
         cLibrary.mtrace();}
    
     public static void muntrace() {if (cLibrary == null) return;
         cLibrary.muntrace();}
    
     public static void mallocStats() {if (cLibrary == null) return;
         cLibrary.malloc_stats();}
    
     public static void mallocTrim() {if (cLibrary == null) return;
         cLibrary.malloc_trim(0);
     }
    }

    这样,就能够防止应用 gdb 而调用一些 C 库函数了😎

正文完
 0