• 使用mtrace追踪JVM堆外内存泄露


    原创:扣钉日记(微信公众号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,内容如下:
    image_2023-09-23_20230923162642
    可以发现,在开启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进程的虚拟内存布局,如下:
    linux_pmem

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

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

    而在Linux中,动态库都是直接加载的,如下:
    image_2023-09-23_20230923171505
    因此,通过如下步骤,即可知道某个指令地址来自哪个函数,如下:

    • 根据指令地址,找到其所属的动态库,以及动态库在进程虚拟内存空间中的起始地址。
    • 根据指令地址减去起始地址,算出指令在动态库中的偏移量地址。
    • 反汇编动态库文件,根据偏移量地址查找指令所在函数。
    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列是其在进程虚拟内存空间中的起始地址。

    1. 计算指令在动态库中的偏移量
    # 指令地址减去动态库起始地址
    $ printf "%x" $((0x7efe715e4b88-0x00007efe715d9000))
    bb88
    
    1. 反汇编并查找指令
    $ objdump -d /opt/jdk8u222-b10/jre/lib/amd64/libzip.so | less -n
    

    image_2023-09-23_20230923172923
    可以发现,进程地址0x7efe715e4b88上的指令,在inflateInit2_函数中。

    当然,上面步骤有点复杂,其实也可以通过gdb来查,如下:

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

    gdb_symbol

    这样,我们找到了泄露的原生函数名,那是什么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调用栈,如下:
    java_stack
    至此,我们堆外内存泄露的代码路径就找到了,只需要再看看代码,识别一下哪些代码路径确实会导致内存泄露即可。

    注:经过测试,发现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.jnagroupId>
        <artifactId>jnaartifactId>
        <version>4.2.2version>
    dependency>
    
    1. 封装并调用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库函数了😎

  • 相关阅读:
    【计算机毕业设计】奖学金管理系统源码
    第三章:Java运算符
    BOM系列之Navigator对象
    [Hadoop全分布部署]安装JDK、Hadoop
    Android Jetpack之LiveData源码分析
    了解MySQL InnoDB多版本MVCC(Multi-Version Concurrency Control)
    16. Thymeleaf教程(10分钟入门)
    【week307-amazon】leetcode6154. 感染二叉树需要的总时间
    Wpf 使用 Prism 实战开发Day02
    【考研数学】概率论与数理统计 —— 第六章 | 数理统计基本概念(2,三个重要的抽样分布)
  • 原文地址:https://www.cnblogs.com/codelogs/p/17725023.html