0厅瞎、背景
前不久饰潜,上線了一個新項目,這個項目是一個壓測系統(tǒng)和簸,可以簡單的看做通過回放詞表(http請求數(shù)據(jù))彭雾,不斷地向服務(wù)發(fā)送請求锁保,以達到壓測服務(wù)的目的薯酝。在測試過程中半沽,一切還算順利,修復(fù)了幾個小bug后吴菠,就上線了者填。在上線后給到第一個業(yè)務(wù)方使用時,就發(fā)現(xiàn)來一個嚴重的問題做葵,應(yīng)用大概跑了10多分鐘占哟,就收到了大量的 Full GC 的告警。
針對這一問題酿矢,我們首先和業(yè)務(wù)方確認了壓測的場景內(nèi)容榨乎,回放的詞表數(shù)量大概是10萬條,回放的速率單機在 100qps 左右瘫筐,按照我們之前的預(yù)估蜜暑,這遠遠低于單機能承受的極限。按道理是不會產(chǎn)生內(nèi)存問題的策肝。
1肛捍、線上排查
首先,我們需要在服務(wù)器上進行排查之众。通過 JDK 自帶的 jmap 工具拙毫,查看一下 JAVA 應(yīng)用中具體存在了哪些對象,以及其實例數(shù)和所占大小酝枢。具體命令如下:
jmap -histo:live `pid of java`
# 為了便于觀察恬偷,還是將輸出寫入文件
jmap -histo:live `pid of java` > /tmp/jmap00
經(jīng)過觀察悍手,確實發(fā)現(xiàn)有對象被實例化了20多萬帘睦,根據(jù)業(yè)務(wù)邏輯,實例化最多的也就是詞表坦康,那也就10多萬竣付,怎么會有20多萬呢,我們在代碼中也沒有找到對此有顯示聲明實例化的地方滞欠。至此古胆,我們需要對 dump 內(nèi)存,在離線進行進一步分析筛璧,dump 命令如下:
jmap -dump:format=b,file=heap.dump `pid of java
2逸绎、離線分析
從服務(wù)器上下載了 dump 的 heap.dump 后,我們需要通過工具進行深入的分析夭谤。這里推薦的工具有 mat棺牧、visualVM。
我個人比較喜歡使用 visualVM 進行分析朗儒,它除了可以分析離線的 dump 文件颊乘,還可以與 IDEA 進行集成参淹,通過 IDEA 啟動應(yīng)用,進行實時的分析應(yīng)用的CPU乏悄、內(nèi)存以及GC情況(GC情況浙值,需要在visualVM中安裝visual GC 插件)。工具具體展示如下(這里僅僅為了展示效果檩小,數(shù)據(jù)不是真的):
當然开呐,mat 也是非常好用的工具,它能幫我們快速的定位到內(nèi)存泄露的地方规求,便于我們排查负蚊。展示如下:
3、場景再現(xiàn):經(jīng)過分析颓哮,最后我們定位到是使用 httpasyncclient 產(chǎn)生的內(nèi)存泄露問題家妆。httpasyncclient 是 Apache 提供的一個 HTTP 的工具包,主要提供了 reactor 的 io 非阻塞模型冕茅,實現(xiàn)了異步發(fā)送 http 請求的功能伤极。下面通過一個 Demo,來簡單講下具體內(nèi)存泄露的原因姨伤。
4哨坪、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 的主要邏輯是這樣的,首先創(chuàng)建一個緩存列表乍楚,用來保存需要發(fā)送的請求數(shù)據(jù)当编。
然后,通過循環(huán)的方式從緩存列表中取出需要發(fā)送的請求徒溪,將其交由 httpasyncclient 客戶端進行發(fā)送忿偷。
具體代碼如下:
public class ReplayApplication {
public static void main(String[] args) throws InterruptedException {
//創(chuàng)建有內(nèi)存泄露的回放客戶端
ReplayWithProblem replay1 = new ReplayWithProblem();
//加載一萬條請求數(shù)據(jù)放入緩存
List<HttpUriRequest> cache1 = replay1.loadMockRequest(10000);
//開始循環(huán)回放
replay1.start(cache1);
}
}
回放客戶端實現(xiàn)(內(nèi)存泄露):
這里以回放百度為例,創(chuàng)建10000條mock數(shù)據(jù)放入緩存列表臊泌±鹎牛回放時,以 while 循環(huán)每100ms 發(fā)送一個請求出去渠概。具體代碼如下:
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);
}
}
}
內(nèi)存分析:
啟動 ReplayApplication 應(yīng)用(IDEA 中安裝 VisualVM Launcher后茶凳,可以直接啟動visualvm),通過 visualVM 進行觀察播揪。
1.啟動情況:
2.visualVM 中前后3分鐘的內(nèi)存對象占比情況:
說明:1代表的是該對象中的第一個內(nèi)部類。所以ReplayWithProblem$1: 代表的是ReplayWithProblem類中FutureCallback的回調(diào)類猪狈。從中箱沦,我們可以發(fā)現(xiàn) FutureCallback 類會被不斷的創(chuàng)建。因為每次異步發(fā)送 http 請求罪裹,都是通過創(chuàng)建一個回調(diào)類來接收結(jié)果饱普,邏輯上看上去也正常运挫。不急,我們接著往下看套耕。
3.visualVM 中前后3分鐘的GC情況:
從圖中看出谁帕,內(nèi)存的 old 在不斷的增長,這就不對了冯袍。內(nèi)存中維持的應(yīng)該只有緩存列表的http請求體匈挖,現(xiàn)在在不斷的增長,就有說明了不斷的有對象進入old區(qū)康愤,結(jié)合上面內(nèi)存對象的情況儡循,說明了 FutureCallback 對象沒有被及時的回收。歡迎關(guān)注公眾號"Java學習之道"征冷,查看更多干貨择膝!可是該回調(diào)匿名類在 http 回調(diào)結(jié)束后,引用關(guān)系就沒了检激,在下一次 GC 理應(yīng)被回收才對肴捉。我們通過對 httpasyncclient 發(fā)送請求的源碼進行跟蹤了一下后發(fā)現(xiàn),其內(nèi)部實現(xiàn)是將回調(diào)類塞入到了http的請求類中叔收,而請求類是放在在緩存隊列中齿穗,所以導致回調(diào)類的引用關(guān)系沒有解除,大量的回調(diào)類晉升到了old區(qū)饺律,最終導致 Full GC 產(chǎn)生窃页。
核心代碼分析:
代碼優(yōu)化:
找到問題的原因,我們現(xiàn)在來優(yōu)化代碼复濒,驗證我們的結(jié)論脖卖。因為List<HttpUriRequest> cache1中會保存回調(diào)對象,所以我們不能緩存請求類芝薇,只能緩存基本數(shù)據(jù)胚嘲,在使用時進行動態(tài)的生成作儿,來保證回調(diào)對象的及時回收洛二。
代碼如下:
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);
}
}
}
結(jié)果驗證
1.啟動情況:
2.visualVM 中前后3分鐘的內(nèi)存對象占比情況:
3.visualVM 中前后3分鐘的GC情況:
從圖中,可以證明我們得出的結(jié)論是正確的攻锰×浪唬回調(diào)類在 Eden 區(qū)就會被及時的回收掉。old 區(qū)也沒有持續(xù)的增長情況了娶吞。這一次的內(nèi)存泄露問題算是解決了垒迂。
5、總結(jié)
關(guān)于內(nèi)存泄露問題在第一次排查時妒蛇,往往是有點不知所措的机断。我們需要有正確的方法和手段楷拳,配上好用的工具,這樣在解決問題時吏奸,才能游刃有余欢揖。當然對JAVA內(nèi)存的基礎(chǔ)知識也是必不可少的,這時你定位問題的關(guān)鍵奋蔚,不然就算工具告訴你這塊有錯她混,你也不能定位原因。最后泊碑,關(guān)于 httpasyncclient 的使用坤按,工具本身是沒有問題的。只是我們得了解它的使用場景馒过,往往產(chǎn)生問題多的臭脓,都是使用的不當造成的。所以腹忽,在使用工具時谢鹊,對于它的了解程度,往往決定了出現(xiàn) bug 的機率留凭。