Java應用堆外內存泄漏排查

背景

我司商城系統生產服務隔一段時間就掛掉一次,所有的機器都有這個問題,而且問題出現的越來越頻繁,從最開始的半個月一次,到後來一週一次、3天一次,一直到最後的1天1次甚至2次,導致服務極其不穩定,查找泄漏源成了迫切要解決的問題

初步排查和猜測

1、首先獲取應用pid

ps -ef|grep marketing-center

2、根據pid查詢java應用堆內存使用情況,以及應用進程佔用系統內存情況

#查看java程序GC情況以及堆內內存使用情況
jstat -gc $pid 2000
#結果如下
[www@idc06-c-marketingcenter-03 ~]$ jstat -gc 14626 2000
 S0C   S1C   S0U   S1U     EC       EU       OC         OU       MC     MU   CCSC   CCSU   YGC     YGCT   FGC   FGCT     GCT  
 0.0   65536.0  0.0   65536.0 2068480.0 737280.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 925696.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1118208.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1372160.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1554432.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1667072.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1699840.0 3436544.0   258445.0  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1839104.0 3436544.0   259483.3  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   65536.0  0.0   65536.0 2068480.0 1880064.0 3436544.0   259483.3  98432.0 92081.3 11392.0 10229.1    191   11.714   0      0.000   11.714
 0.0   51200.0  0.0   51200.0 2082816.0 274432.0 3436544.0   245594.6  98432.0 92081.3 11392.0 10229.1    192   11.786   0      0.000   11.786
 0.0   51200.0  0.0   51200.0 2082816.0 321536.0 3436544.0   245594.6  98432.0 92081.3 11392.0 10229.1    192   11.786   0      0.000   11.786
 
#查看應用進程佔用系統內存情況,可以用top代替
ps -p $pid -o rss,vsz
#結果如下:
[www@idc06-c-marketingcenter-03 ~]$ ps -p 14626 -o rss,vsz
 RSS   VSZ
12303720 19423396

根據以上信息可以看出,堆內內存使用也就5G左右,但是Java應用實際佔用內存卻高達12G左右,而且堆內內存一切正常,Young GC也比較正常平穩,Full GC也保持在一個較低的頻率,通過以上數據基本可以斷定發生了Java堆外內存泄漏,爲了驗證我的猜想,使用pmap命令查看一下系統內存分配

#查看系統內存分配情況
pmap -x $pid | sort -k3 -n
#結果如下,內容較多,截取關鍵部分展示
[www@idc06-c-marketingcenter-03 ~]$ pmap -x 14626 | sort -k3 -n
...
00007f44d8000000   65536   64712   64712 rw---   [ anon ]
00007f4578000000   65508   64744   64744 rw---   [ anon ]
00007f44d4000000   65524   64860   64860 rw---   [ anon ]
00007f43fc000000   65536   64996   64996 rw---   [ anon ]
00007f4464000000   65508   65004   65004 rw---   [ anon ]
00007f4528000000   65536   65044   65044 rw---   [ anon ]
00007f43dc000000   65536   65060   65060 rw---   [ anon ]
00007f45a4000000   65524   65148   65148 rw---   [ anon ]
00007f43d0000000   65508   65156   65156 rw---   [ anon ]
00007f45a0000000   65516   65180   65180 rw---   [ anon ]
...

發現大量64MB左右的內存塊,且分配地址在堆外,以上內容驗證了我的猜想,確實有堆外內存泄漏

詳細排查經過

由於是堆外內存泄漏,JDK自帶的工具已經不好用了,首先借助谷歌的內存分配監測工具gperftools來排查具體哪段代碼進行了堆外內存申請,具體安裝使用請參考:Java直接內存泄漏排查工具gperftools使用方法

#結果較多,截取部分
Total: 125704.3 MB
 94257.6  75.0%  75.0%  94257.6  75.0% updatewindow
 20573.0  16.4%  91.3%  20573.0  16.4% inflateInit2_
  9946.9   7.9%  99.3%   9946.9   7.9% os::malloc@91de80
   457.8   0.4%  99.6%    457.8   0.4% init
   253.1   0.2%  99.8%  20826.1  16.6% Java_java_util_zip_Inflater_init
   149.0   0.1%  99.9%    149.0   0.1% readCEN
    38.4   0.0% 100.0%     38.4   0.0% __GI__dl_allocate_tls
    14.7   0.0% 100.0%     14.7   0.0% deflateInit2_
     3.8   0.0% 100.0%    156.2   0.1% ZIP_Put_In_Cache0
     2.4   0.0% 100.0%      2.4   0.0% _dl_new_object
     2.2   0.0% 100.0%      2.2   0.0% newEntry
     1.3   0.0% 100.0%      1.3   0.0% __GI___strdup
     0.9   0.0% 100.0%      0.9   0.0% __res_context_send
     0.7   0.0% 100.0%      0.7   0.0% JLI_MemAlloc
...

通過分析結果我們可以將目光聚焦在updatewindowJava_java_util_zip_Inflater_init上,由於updatewindow不是Java方法申請的內存,我們可以忽略不計,將重心放在Java Native 方法Java_java_util_zip_Inflater_init上,根據這個結果我們可以去項目中搜索所有使用Inflater類的代碼,最終將範圍縮小到GZIPInputStreamGZIPOutputStream這兩個類,但是由於項目中使用這些類的地方還是比較多,所以依然無法確認問題代碼

定位問題代碼並解決

使用 gdb 命令 dump 出了那些64M的內存塊,然後通過查看dump出來的結果最終定位到問題

注意:dump內存會掛起應用進程,一定要確保沒有流量流入再使用

1、找出那些64MB內存的地址

#命令
less /proc/$pid/smaps
#結果
[www@idc06-c-marketingcenter-07 ~]$ less /proc/14626/smaps
7f43dc000000-7f440e000000 ---p 00000000 00:00 0
Size:              65536 kB
Rss:               65536 kB
Pss:               65044 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:         65536 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd
...

2、使用gdb命令dump出內存

#先連接程序
gdb -pid $pid
#進入gdb調試模式dump內存
dump memory mem.bin 7f43dc000000 7f440e000000
#mem.bin是內存dump出來的文件,後面是地址
strings mem.bin > mem.log  #將二進制文件讀取成字符串並輸出到文件,方便查閱
less mem.log

內存dump文件內容如下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-GbawwRmb-1579961597080)(D:\Infomation\zzw\微信截圖_20190521154932.png)]

上圖省略了一部分內容,我發現很多個64MB文件全都是GroupProductItemAi對象,最後終於肯定泄漏的對象就是 marketing-center 中的 POJO com.mryt.marketing.center.domain.GroupProductItemAi然後在項目中全局搜索該POJO然後根據上面得到的GZIPInputStreamGZIPOutputStream直接申請內存等關鍵信息定位到最終的問題代碼

/**
 * 問題代碼
 */
public Map<String, T> hgetAllObject(String key, int seconds) {
    if (key == null) {
        return null;
    }
    Map<byte[], byte[]> hMap = null;
    ShardedJedis commonJedis = null;
    Map<String, T> returnMap = null;
    T object = null;
    try {
        commonJedis = jedisPool.getResource();
        hMap = commonJedis.hgetAll(key.getBytes());
        if (hMap == null) {
            return null;
        }
        returnMap = new HashMap<String, T>();
        Set<byte[]> keySet = hMap.keySet();
        ByteArrayInputStream i = null;
        GZIPInputStream gzin = null;
        ObjectInputStream in = null;
        // 這裏循環創建了i gzin in等三個對象的多個副本
        for (Iterator<byte[]> it = keySet.iterator(); it.hasNext(); ) {
            byte[] keyItem = it.next();
            byte[] valueItem = hMap.get(keyItem);
            // 建立字節數組輸入流
            i = new ByteArrayInputStream(valueItem);
            // 建立gzip解壓輸入流
            gzin = new GZIPInputStream(i);
            // 建立對象序列化輸入流
            in = new ObjectInputStream(gzin);
            // 按制定類型還原對象
            object = (T) in.readObject();
            returnMap.put(new String(keyItem), object);
        }
        // 這裏只釋放了最後一個,造成了中間對象沒有調用close方法釋放內存
        if (i != null && gzin != null && in != null) {
            i.close();
            gzin.close();
            in.close();
        }
        if (seconds > 0) {
            commonJedis.expire(key, getRealCacheTime(seconds));
        }
    } catch (Exception e) {
        jedisPool.returnBrokenResource(commonJedis);
        RedisException.exceptionJedisLog(logger, key, commonJedis, e, "hgetAllObject");
        commonJedis = null;
    } finally {
        if (commonJedis != null) {
            jedisPool.returnResource(commonJedis);
        }
    }
    return returnMap;
}

上面的代碼在for循環裏創建了多個GZIPInputStream但是卻只在for循環之後釋放了最後一個GZIPInputStream對象,所以造成了大量的內存泄漏,至此,終於找到了泄露源,然後根據業務情況選擇修復方式即可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章