記一次 JAVA 的內存洩露分析

背景

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

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


線上排查

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

<code>jmap -

histo:

live

`pid of java`

jmap -

histo:

live

`pid of java`

>

/tmp/jmap

0

0

/<code>

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

<code>jmap -

dump

:

format

=b,file=heap.

dump

`pid of java/<code>


離線分析

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

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

記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析

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

記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析


場景再現

經過分析,最後我們定位到是使用 httpasyncclient 產生的內存洩露問題。

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

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


httpasyncclient 使用介紹:

1.maven 依賴

<code>

<

dependency

>

<

groupId

>

org.apache.httpcomponents

groupId

>

<

artifactId

>

httpasyncclient

artifactId

>

<

version

>

4.1.3

version

>

dependency

>

/<code>

2.HttpAsyncClient 客戶端

<code>

public

class

HttpAsyncClient

{

private

CloseableHttpAsyncClient httpclient;

public

HttpAsyncClient

()

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

public

void

execute

(HttpUriRequest request, FutureCallback callback)

{ httpclient.execute(request, callback); }

public

void

close

()

throws

IOException

{ httpclient.close(); } }/<code>


主要邏輯:


Demo 的主要邏輯是這樣的,首先創建一個緩存列表,用來保存需要發送的請求數據。

然後,通過循環的方式從緩存列表中取出需要發送的請求,將其交由 httpasyncclient 客戶端進行發送。

具體代碼如下:

<code>

public

class

ReplayApplication

{

public

static

void

main

(String[] args)

throws

InterruptedException

{ ReplayWithProblem replay1 =

new

ReplayWithProblem(); List cache1 = replay1.loadMockRequest(

10000

); replay1.start(cache1); } }/<code>


回放客戶端實現(內存洩露):

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

<code>

public

class

ReplayWithProblem

{

public

List

loadMockRequest

(

int

n

)

{ List cache =

new

ArrayList(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 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() {

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

); } } }/<code>


內存分析:

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

1.啟動情況:


記一次 JAVA 的內存洩露分析


2.visualVM 中前後3分鐘的內存對象佔比情況:

記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析

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

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

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


記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析

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

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


核心代碼分析:


記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析


代碼優化


找到問題的原因,我們現在來優化代碼,驗證我們的結論。因為List cache1中會保存回調對象,所以我們不能緩存請求類,只能緩存基本數據,在使用時進行動態的生成,來保證回調對象的及時回收。

代碼如下:

<code>

public

class

ReplayApplication

{

public

static

void

main

(String[] args)

throws

InterruptedException

{ ReplayWithoutProblem replay2 =

new

ReplayWithoutProblem(); List cache2 = replay2.loadMockRequest(

10000

); replay2.start(cache2); } }/<code>


<code>

public

class

ReplayWithoutProblem

{

public

List

loadMockRequest

(

int

n

)

{ List cache =

new

ArrayList(n);

for

(

int

i =

0

; i < n; i++) { cache.

add

(

"http://www.baidu.com?a="

+i); }

return

cache; }

public

void

start

(

List 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() {

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

); } } }/<code>


結果驗證

1.啟動情況:


記一次 JAVA 的內存洩露分析


2.visualVM 中前後3分鐘的內存對象佔比情況:

記一次 JAVA 的內存洩露分析

記一次 JAVA 的內存洩露分析


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

記一次 JAVA 的內存洩露分析


記一次 JAVA 的內存洩露分析


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


總結

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

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


分享到:


相關文章: