記一次Java的內存泄露分析

當前環境

  1. jdk == 1.8
  2. httpasyncclient == 4.1.3

代碼地址

git 地址:https://github.com/jasonGeng88/java-network-programmingjava

背景

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

針對這一問題,咱們首先和業務方確認了壓測的場景內容,回放的詞表數量大概是10萬條,回放的速率單機在 100qps 左右,按照咱們以前的預估,這遠遠低於單機能承受的極限。按道理是不會產生內存問題的。git

線上排查

首先,咱們須要在服務器上進行排查。經過 JDK 自帶的 jmap 工具,查看一下 JAVA 應用中具體存在了哪些對象,以及其實例數和所佔大小。具體命令以下:github

jmap -histo:live `pid of java`

# 爲了便於觀察,仍是將輸出寫入文件
jmap -histo:live `pid of java` > /tmp/jmap00

通過觀察,確實發現有對象被實例化了20多萬,根據業務邏輯,實例化最多的也就是詞表,那也就10多萬,怎麼會有20多萬呢,咱們在代碼中也沒有找到對此有顯示聲明實例化的地方。至此,咱們須要對 dump 內存,在離線進行進一步分析,dump 命令以下:shell

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

離線分析

從服務器上下載了 dump 的 heap.dump 後,咱們須要經過工具進行深刻的分析。這裏推薦的工具備 mat、visualVM。apache

我我的比較喜歡使用 visualVM 進行分析,它除了能夠分析離線的 dump 文件,還能夠與 IDEA 進行集成,經過 IDEA 啓動應用,進行實時的分析應用的CPU、內存以及GC狀況(GC狀況,須要在visualVM中安裝visual GC 插件)。工具具體展現以下(這裏僅僅爲了展現效果,數據不是真的):緩存

固然,mat 也是很是好用的工具,它能幫咱們快速的定位到內存泄露的地方,便於咱們排查。 展現以下:服務器

場景再現

通過分析,最後咱們定位到是使用 httpasyncclient 產生的內存泄露問題。httpasyncclient 是 Apache 提供的一個 HTTP 的工具包,主要提供了 reactor 的 io 非阻塞模型,實現了異步發送 http 請求的功能。異步

下面經過一個 Demo,來簡單講下具體內存泄露的緣由。async

httpasyncclient 使用介紹:

  • maven 依賴
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpasyncclient</artifactId>
    <version>4.1.3</version>
</dependency>
  • 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 進行觀察。

  • 啓動狀況:

  • visualVM 中先後3分鐘的內存對象佔比狀況:

說明:$0表明的是對象自己,$1表明的是該對象中的第一個內部類。因此ReplayWithProblem$1: 表明的是ReplayWithProblem類中FutureCallback的回調類。

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

  • visualVM 中先後3分鐘的GC狀況:

從圖中看出,內存的 old 在不斷的增加,這就不對了。內存中維持的應該只有緩存列表的http請求體,如今在不斷的增加,就有說明了不斷的有對象進入old區,結合上面內存對象的狀況,說明了 FutureCallback 對象沒有被及時的回收。

但是該回調匿名類在 http 回調結束後,引用關係就沒了,在下一次 GC 理應被回收纔對。咱們經過對 httpasyncclient 發送請求的源碼進行跟蹤了一下後發現,其內部實現是將回調類塞入到了http的請求類中,而請求類是放在在緩存隊列中,因此致使回調類的引用關係沒有解除,大量的回調類晉升到了old區,最終致使 Full GC 產生。

  • 核心代碼分析:

代碼優化

找到問題的緣由,咱們如今來優化代碼,驗證咱們的結論。由於List<HttpUriRequest> cache1中會保存回調對象,因此咱們不能緩存請求類,只能緩存基本數據,在使用時進行動態的生成,來保證回調對象的及時回收。

代碼以下:

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);
        }
    }

}

結果驗證

  • 啓動狀況:

  • visualVM 中先後3分鐘的內存對象佔比狀況:

  • visualVM 中先後3分鐘的GC狀況:

從圖中,能夠證實咱們得出的結論是正確的。回調類在 Eden 區就會被及時的回收掉。old 區也沒有持續的增加狀況了。這一次的內存泄露問題算是解決了。

總結

關於內存泄露問題在第一次排查時,每每是有點不知所措的。咱們須要有正確的方法和手段,配上好用的工具,這樣在解決問題時,才能遊刃有餘。固然對JAVA內存的基礎知識也是必不可少的,這時你定位問題的關鍵,否則就算工具告訴你這塊有錯,你也不能定位緣由。

最後,關於 httpasyncclient 的使用,工具自己是沒有問題的。只是咱們得了解它的使用場景,每每產生問題多的,都是使用的不當形成的。因此,在使用工具時,對於它的瞭解程度,每每決定了出現 bug 的機率。

相關文章
相關標籤/搜索