使用mtrace追蹤JVM堆外記憶體洩露

發表於2023-09-23

原創:扣釘日記(微信公眾號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.jna</groupId>
    <artifactId>jna</artifactId>
    <version>4.2.2</version>
</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庫函式了?

相關文章