天天看點

dump分析工具_記一次 JAVA 的記憶體洩露分析

來源: http:// github.com/jasonGeng88/ blog
  • 背景
  • 離線分析
    • 主要邏輯:
    • 回放用戶端實作(記憶體洩露):
    • 記憶體分析:
    • 代碼優化
    • 結果驗證
  • 總結

背景

前不久,上線了一個新項目,這個項目是一個壓測系統,可以簡單的看做通過回放詞表(http請求資料),不斷地向服務發送請求,以達到壓測服務的目的。在測試過程中,一切還算順利,修複了幾個小bug後,就上線了。在上線後給到第一個業務方使用時,就發現來一個嚴重的問題,應用大概跑了10多分鐘,就收到了大量的 Full GC 的告警。

針對這一問題,我們首先和業務方确認了壓測的場景内容,回放的詞表數量大概是10萬條,回放的速率單機在 100qps 左右,按照我們之前的預估,這遠遠低于單機能承受的極限。按道理是不會産生記憶體問題的。

線上排查

首先,我們需要在伺服器上進行排查。通過 JDK 自帶的 jmap 工具,檢視一下 JAVA 應用中具體存在了哪些對象,以及其執行個體數和所占大小。具體指令如下:

jmap -histo:live `pid of java`

# 為了便于觀察,還是将輸出寫入檔案
jmap -histo:live `pid of java` > /tmp/jmap00
           

經過觀察,确實發現有對象被執行個體化了20多萬,根據業務邏輯,執行個體化最多的也就是詞表,那也就10多萬,怎麼會有20多萬呢,我們在代碼中也沒有找到對此有顯示聲明執行個體化的地方。至此,我們需要對 dump 記憶體,在離線進行進一步分析,dump 指令如下:

jmap -dump:format=b,file=heap.dump `pid of java
           

離線分析

從伺服器上下載下傳了 dump 的 heap.dump 後,我們需要通過工具進行深入的分析。這裡推薦的工具有 mat、visualVM。

我個人比較喜歡使用 visualVM 進行分析,它除了可以分析離線的 dump 檔案,還可以與 IDEA 進行內建,通過 IDEA 啟動應用,進行實時的分析應用的CPU、記憶體以及GC情況(GC情況,需要在visualVM中安裝visual GC 插件)。工具具體展示如下(這裡僅僅為了展示效果,資料不是真的):

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

當然,mat 也是非常好用的工具,它能幫我們快速的定位到記憶體洩露的地方,便于我們排查。展示如下:

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析
場景再現

經過分析,最後我們定位到是使用 httpasyncclient 産生的記憶體洩露問題。

httpasyncclient 是 Apache 提供的一個 HTTP 的工具包,主要提供了 reactor 的 io 非阻塞模型,實作了異步發送 http 請求的功能。

下面通過一個 Demo,來簡單講下具體記憶體洩露的原因。

httpasyncclient 使用介紹:

1.maven 依賴

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpasyncclient</artifactId>
    <version>4.1.3</version>
</dependency>
           

2.HttpAsyncClient 用戶端

public class HttpAsyncClient {

    private CloseableHttpAsyncClient httpclient;

    public HttpAsyncClient() {
        httpclient = HttpAsyncClients.createDefault();
        httpclient.start();
    }

    public void execute(HttpUriRequest request, FutureCallback<HttpResponse> callback){
        httpclient.execute(request, callback);
    }

    public void close() throws IOException {
        httpclient.close();
    }

}
           

主要邏輯:

Demo 的主要邏輯是這樣的,首先建立一個緩存清單,用來儲存需要發送的請求資料。

然後,通過循環的方式從緩存清單中取出需要發送的請求,将其交由 httpasyncclient 用戶端進行發送。

具體代碼如下:
public class ReplayApplication {    public static void main(String[] args) throws InterruptedException {     //建立有記憶體洩露的回放用戶端        ReplayWithProblem replay1 = new ReplayWithProblem();             //加載一萬條請求資料放入緩存        List<HttpUriRequest> cache1 = replay1.loadMockRequest(10000);                //開始循環回放        replay1.start(cache1);    }}
           

回放用戶端實作(記憶體洩露):

這裡以回放百度為例,建立10000條mock資料放入緩存清單。回放時,以 while 循環每100ms 發送一個請求出去。具體代碼如下:

public class ReplayWithProblem {    public List<HttpUriRequest> loadMockRequest(int n){            List<HttpUriRequest> cache = new ArrayList<HttpUriRequest>(n);        for (int i = 0; i < n; i++) {            HttpGet request = new HttpGet("http://www.baidu.com?a="+i);            cache.add(request);        }        return cache;            }    public void start(List<HttpUriRequest> cache) throws InterruptedException {        HttpAsyncClient httpClient = new HttpAsyncClient();        int i = 0;        while (true){            final HttpUriRequest request = cache.get(i%cache.size());            httpClient.execute(request, new FutureCallback<HttpResponse>() {                public void completed(final HttpResponse response) {                    System.out.println(request.getRequestLine() + "->" + response.getStatusLine());                }                public void failed(final Exception ex) {                    System.out.println(request.getRequestLine() + "->" + ex);                }                public void cancelled() {                    System.out.println(request.getRequestLine() + " cancelled");                }            });            i++;            Thread.sleep(100);        }    }}
           

記憶體分析:

啟動 ReplayApplication 應用(IDEA 中安裝 VisualVM Launcher後,可以直接啟動visualvm),通過 visualVM 進行觀察。

1.啟動情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析

2.visualVM 中前後3分鐘的記憶體對象占比情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

說明:代表的是對象本身,1代表的是該對象中的第一個内部類。是以ReplayWithProblem$1: 代表的是ReplayWithProblem類中FutureCallback的回調類。

從中,我們可以發現 FutureCallback 類會被不斷的建立。因為每次異步發送 http 請求,都是通過建立一個回調類來接收結果,邏輯上看上去也正常。不急,我們接着往下看。

3.visualVM 中前後3分鐘的GC情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

從圖中看出,記憶體的 old 在不斷的增長,這就不對了。記憶體中維持的應該隻有緩存清單的http請求體,現在在不斷的增長,就有說明了不斷的有對象進入old區,結合上面記憶體對象的情況,說明了 FutureCallback 對象沒有被及時的回收。

可是該回調匿名類在 http 回調結束後,引用關系就沒了,在下一次 GC 理應被回收才對。我們通過對 httpasyncclient 發送請求的源碼進行跟蹤了一下後發現,其内部實作是将回調類塞入到了http的請求類中,而請求類是放在在緩存隊列中,是以導緻回調類的引用關系沒有解除,大量的回調類晉升到了old區,最終導緻 Full GC 産生。

核心代碼分析:
dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

代碼優化

找到問題的原因,我們現在來優化代碼,驗證我們的結論。因為Listcache1中會儲存回調對象,是以我們不能緩存請求類,隻能緩存基本資料,在使用時進行動态的生成,來保證回調對象的及時回收。

代碼如下:

public class ReplayApplication {

    public static void main(String[] args) throws InterruptedException {

        ReplayWithoutProblem replay2 = new ReplayWithoutProblem();
        List<String> cache2 = replay2.loadMockRequest(10000);
        replay2.start(cache2);

    }
}

public class ReplayWithoutProblem {

    public List<String> loadMockRequest(int n){
        List<String> cache = new ArrayList<String>(n);
        for (int i = 0; i < n; i++) {
            cache.add("http://www.baidu.com?a="+i);
        }
        return cache;
    }

    public void start(List<String> cache) throws InterruptedException {

        HttpAsyncClient httpClient = new HttpAsyncClient();
        int i = 0;

        while (true){

            String url = cache.get(i%cache.size());
            final HttpGet request = new HttpGet(url);
            httpClient.execute(request, new FutureCallback<HttpResponse>() {
                public void completed(final HttpResponse response) {
                    System.out.println(request.getRequestLine() + "->" + response.getStatusLine());
                }

                public void failed(final Exception ex) {
                    System.out.println(request.getRequestLine() + "->" + ex);
                }

                public void cancelled() {
                    System.out.println(request.getRequestLine() + " cancelled");
                }

            });
            i++;
            Thread.sleep(100);
        }
    }

}
           

結果驗證

1.啟動情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析

2.visualVM 中前後3分鐘的記憶體對象占比情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

3.visualVM 中前後3分鐘的GC情況:

dump分析工具_記一次 JAVA 的記憶體洩露分析
dump分析工具_記一次 JAVA 的記憶體洩露分析

從圖中,可以證明我們得出的結論是正确的。回調類在 Eden 區就會被及時的回收掉。old 區也沒有持續的增長情況了。這一次的記憶體洩露問題算是解決了。

總結

關于記憶體洩露問題在第一次排查時,往往是有點不知所措的。我們需要有正确的方法和手段,配上好用的工具,這樣在解決問題時,才能遊刃有餘。當然對JAVA記憶體的基礎知識也是必不可少的,這時你定位問題的關鍵,不然就算工具告訴你這塊有錯,你也不能定位原因。

最後,關于 httpasyncclient 的使用,工具本身是沒有問題的。隻是我們得了解它的使用場景,往往産生問題多的,都是使用的不當造成的。是以,在使用工具時,對于它的了解程度,往往決定了出現 bug 的機率。

繼續閱讀