一、背景
首先,發現線上某分析應用出現異常,連續好幾天,一直沒有分析數據產出。故登陸到線上查看error.log日誌,發現:
明顯是 YCYX-Task 這個線程出現了內存溢出,導致程序假死。
二、排查歷程
1、初步定位
jinfo
首先,我們使用jinfo pid
查看當前jvm的堆相關參數:
可見,最大堆容量爲:4G。
jstat
接下來,我們使用命令jstat -gcutil pid 1s 5
查看5秒內當前堆佔用情況:
如上,新生代已經滿了(佔用97.33%),老年代也已經滿了(佔用100%),同時FullGC高達967次!
jmap
除了jstat命令外,我們也可以使用jmap -heap pid
查看下當前JVM堆情況:
然後,我們用jmap -F -histo pid | head -n 13
查看前13行打印,即查看TOP10的大對象(最好用head限制一下,否則列出的對象會鋪滿你的屏幕,另外:強制連接參數-F
對jmap -histo:live
是無效的):
如上,可以看到,除了幾大基本類型外(因爲各對象的底層都是幾個基本類型,所以無意外它們會排在top前幾),一個java.util.HashMap
和java.util.ArrayList
非常顯眼……先記下,後面繼續分析先。
最後,我們使用命令jmap -F -dump:file=a.bin pid
將堆dump出來,發現dump出來的文件有4.02G,很恐怖,故使用tar -czvf a.tar.gz a.bin
打包壓縮一下!
2、MAT深入分析
調整MAT最大堆內存
將打包好的a.tar.gz
拉回到本地,並解壓。但是由於a.bin過大,MAT打開肯定會內存溢出,故調整MAT軟件的最大堆內存:
[ MAT根目錄下的MemoryAnalyzer.ini ]
-startup
plugins/org.eclipse.equinox.launcher_1.5.0.v20180512-1130.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.1.700.v20180518-1200
-vmargs
-Xmx6g
-Xmx 改爲6G!
MAT分析大對象
映入眼簾的就是一個超、超、超級大的對象,3GB,佔用了97.25的內存,且位於 YCYX-Task 這個線程內,印證了開頭的error.log報錯日誌YCYX-Task報內存泄漏的情況。然後點開這個java.lang.Object[]
的Details
,如下圖:
可以看到,這個Object數組,的確佔用了3個G的內存,同時也的確在一個ArrayList內部,印證了剛剛jmap -histo pid | head -n 13
一個異常ArrayList
的情況,同時它內部也正是由HashMap
構成!
圖上,可以看到這個ArrayList存了接近31萬個元素,故導致內存泄漏。最終得出結論是由於代碼裏面的一個ArrayList問題!
代碼走讀排查
結合error.log日誌報出的問題JAVA類
、報錯代碼行數
,再結合問題應該出在一個ArrayList上
,很容易就定位到了相關問題代碼塊:
/**
* 按照指定起止時間分析數據
* @param beginTime 起始時間
* @param endTime 截至時間
*/
@Override public void exec(String beginTime, String endTime) {
List<Map<String, Object>> emlWithLoginList = new ArrayList<>();
List<Map<String, Object>> emlList = new ArrayList<>();
while (true) {
try {
//如果已分析到截至時間,則退出。
if (DateUtil.compareTime(beginTime, endTime) > 0) {
break;
}
//每次循環向前推10小時,YCYX_PERIOD=10小時
String tmpTime=DateUtil.addHours(beginTime, YCYX_PERIOD);
//1.構造請求
BoolQueryBuilder bqb = QueryBuilders.boolQuery();
bqb.must(QueryBuilders.rangeQuery(CREATE_TIME).gt(beginTime).lte(tmpTime));
bqb.must(QueryBuilders.termQuery(IS_DELETE, IS_FIELD_VALUE));
bqb.must(QueryBuilders.existsQuery(TOS));
bqb.must(QueryBuilders.existsQuery(FROMS));
bqb.must(QueryBuilders.existsQuery(SEND_TYPE));
bqb.must(QueryBuilders.existsQuery(SESSION_ID));
log.debug("emlAnalysis begin at " + beginTime + ", and end at " + lastTime);
SearchSourceBuilder requestBuilder = new SearchSourceBuilder().query(bqb).size(PAGE_SIZE).sort(CREATE_TIME, SortOrder.ASC).fetchSource(new String[] {"*"},
new String[] {EML_CONTENT});
//2.發起請求
EsHelper.getResponseScroll(EsCluster.DEFAULT, INF_EML_INDEX, "14m", requestBuilder.toString(), result -> {
//3.解析結果
EsSearchHit[] hits = result.getHits().getHits();
List<Map<String, Object>> loginList;
for (EsSearchHit hit : hits) {
//將郵件Map添加到列表中
Map<String, Object> emlMap = hit.getSource();
emlList.add(emlMap);
//將郵件+MailLogin的Map添加到另一個列表中
Map<String, Object> emlWithLoginMap = new HashMap(emlMap);
String sessionId = emlMap.get(SESSION_ID).toString();
MailLogin mailLogin = EsService.getMailLoginBySessionId(sessionId);
emlWithLoginMap.put("Login_Key", mailLogin );
emlWithLoginList.add(emlWithLoginMap);
}
return true;
});
//4.A類檢測邏輯
checkDSYJEml(emlWithLoginList);
emlWithLoginList.clear();
//5.B類檢測邏輯
checkYCYXEml(emlList);
emlList.clear();
beginTime = tmpTime;
} catch (Throwable e) {
log.error("", e);
}
}
}
由上面代碼可見,問題List應該就是 emlWithLoginList
或者 emlList
的其中一個了,而兩個List存儲的內容基本相同,除了emlWithLoginList
內的Map元素額外存了一個叫做Login_Key
的key!
而通過MAT查看了問題List內Map元素的所有Key,並沒有找到相關叫做Login_Key
的元素,故推測問題List應該是這個emlList
!
而直接看上面的代碼邏輯,沒有發現什麼大的問題。且本方法是按照傳入的起、止時間,按每10小時爲時間段,依次進行ES數據查詢、分析的。
因此我們先猜測,是否是因爲當前時間段的ES數據過大導致?
代碼裏面可以看到,ES查詢的數據,是通過CREATE_TIME(該常量值爲@createtime)
進行升序查詢的,故先查的數據,應該位於這個問題List
的開頭,而後查的數據在問題List
的結尾。
因此通過MAT找到問題List
的第一個和最後一個元素,即得到本次查詢的起、止時間:
可以看到,時間段大致爲2019-11-01 16:00:00到2019-11-02 02:00:00!時間符合我們的每批數據查詢的10小時時間段。
圖上說明,查詢這一批數據,程序得到了31萬條數據!
而我又到Kibana查詢了一下這個時間段的數據量:
才兩萬多條,完全對不上啊?
一下子就迷了,這個情況有點說不通,想了半天,於是,又仔細地看了下代碼:
注意到了圖上位置的代碼,這兩個List都在不斷的添加元素,然後執行各自的檢測邏輯,最後調用clear()
方法清空List,爲何emlWithLoginList
沒有問題,而emlList
卻內存溢出了?
那麼就很顯然了:
- 假如在執行
checkYCYXEml(emlList)
時,出現了異常,就會直接被下面的catch
給捕獲; - 因而不會走下面的
emlList.clear()
代碼,同時也不會走beginTime = timeTime
; - 由於try catch位於while循環內部,因此拋出異常後會繼續循環,且因爲沒有執行
beginTime = timeTime
,故查詢的數據還是之前的這個時間段的數據; - 同時,也可以解釋爲什麼
emlWithLoginList
沒有問題了,因爲在異常代碼的前面,可以進行正常的clear()
操作。
那麼,如果是checkYCYXEml(emlList)
時,出現了異常,error.log應該是有異常日誌打印的,通過關鍵字checkYCYXEml
搜了一下:
果然找到了這個方法報出的異常,並且原因是Ice超時。這個我是知道的,由於公司新上的EsService中間件(部署在分佈式RPC框架-Ice上面的),限制了各調用方的ES查詢時間,超過指定時間,會強行返回一個Ice超時錯誤,目的是爲了防止不規範的複雜語句把ES查崩!
按照上面說的,如果是這個原因,這段代碼會重複查詢2019-11-01 16:00:00
到2019-11-02 02:00:00
的數據,且不斷加入到emlList
中,最後撐爆JVM!
那麼MAT中的這個問題List
應該會有多個相同元素存在(數據重複加入進去了嘛)。
如何驗證這一點呢?
因爲這些數據有一個emlkey
字段,是一個唯一主鍵,對應這條記錄在ES中的_id
,因此可通過MAT,根據某一條數據的emlkey
,去查找是否問題emlList
中有多個元素均存在相同的emlkey
,即可證明。
MAT對字符串進行分組
由於這個dump文件很大,故查詢需要花很長時間,需耐心等待~
題外話:我們可以看到,很多String值是相同的,但卻分配了好幾十萬個String對象來存儲,這裏我們可以使用Java 8的-XX:+UseStringDeduplication
功能,來減輕重複字符串的問題。這將導致這幾十萬個String實例,但其底層的數組均指向同一個char數組。
圖上即是對針對String進行的分組操作,這時,我們隨便找一個元素的emlkey
,查詢一下:
然後選中該記錄,右鍵,使用merge shortest paths to gc roots
功能,可查看這些對象到GC ROOT的最短路徑,說白了,就是想通過這個功能,查看當前這個String是屬於哪個對象下面的:
可以看到,有32個String對象,值均爲854742740486326718e
那麼爲什麼有32個String呢,這是因爲每個對象,除了有一個emlkey
屬性,還有一個document_id
屬性,這兩個值是一樣的,均是表示ES的_id
,如圖:
OK,16個對象,相當於重新查詢了ES16遍,每次查詢是22221條,22221x16=355536,數量基本吻合,沒有完全對上,是因爲上面代碼裏的scroll查詢,也出現過IceTimeOut異常,導致22221條還沒有完全查詢完即結束了。
最後這個問題,基本就定位到了,修復也就簡單了,把兩個Clear()方法,都移到catch後面的finally中,即可保證100%會調用,另外就是對接中間件的同事,針對IceTimeOut問題的解決了。