Java服務假死後續之記憶體溢位

一、現象分析

上篇部落格說到,Java服務假死的原因是使用了Guava快取,30分鐘的有效期導致Full GC無法回收記憶體。經過最佳化後,已經不再使用Guava快取,實時查詢資料。從短期效果來看,確實解決了無法回收記憶體的問題,但是服務執行幾天後,發現記憶體又逐漸被佔滿,Full GC後只能回收一小部分。

Java服務假死後續之記憶體溢位

從上圖可以看出,一次Full GC後,老年代基本上沒有回收多少記憶體,佔比從99。86%降到99。70%。

二、原因排查

到底是什麼物件佔據這麼大的記憶體,並且無法被JVM垃圾回收呢。在上一篇部落格中已經移除了Guava快取,按理說不應該有無法回收的物件了。那麼,很明顯這應該是程式碼問題導致了記憶體洩露,現在需要知道哪些物件無法被回收,從而定位出程式碼哪裡有BUG。這裡採用

jmap -histo:live 201349|head -10

命令打印出GC後存活的物件。

Java服務假死後續之記憶體溢位

從上圖可以看出,還是之前存在Guava快取裡面的物件佔據著大部分記憶體,程式碼修改為實時查詢後,每次用完資料都會從Map中剔除,按理不應該有強引用去引用這些物件。光看程式碼無法排查出哪裡導致了記憶體洩露,只能將GC後的記憶體檔案匯出來進行分析。這裡採用

jmap -dump:format=b,file=/data/heap.hprof

命令將記憶體檔案匯出來,用JDK自帶的visualVM開啟。

Java服務假死後續之記憶體溢位

這裡拿ECBug物件進行分析,從引用關係可以看出,ECBug物件被DataSetCenter引用,DataSetCenter就是實時查詢資料進行儲存的一個ConcurrentHashMap,但每次用完資料後都會進行remove操作,具體程式碼如下所示。

private List realTimeQueryBusinessModelData(IDataSetKey accessCacheDataSetKey,Set mappers, Set filters, Set sorters) throws DataNotFoundException, IllegalAccessException, CloneNotSupportedException, InstantiationException { List resultBeans = null; try { lock。lock(); if (!dataSetCenter。containsKey(accessCacheDataSetKey)) { log。info(“put DataSetKey into DataSetCenter,dataSetKey is {}”,accessCacheDataSetKey); int count = businessModelQuery。count(accessCacheDataSetKey); if (count == 0) throw new DataNotFoundException(); Class modelClass = businessModelCenter。getDataModelClass(accessCacheDataSetKey。getModelId()); if (modelClass == null) { throw new DataNotFoundException(); } dataSetCenter。put(accessCacheDataSetKey, new DataSet(count, modelClass)); } List cachedBeans = dataSetCenter。get(accessCacheDataSetKey)。getData(); resultBeans = getModelDataInternal(accessCacheDataSetKey, businessModelQuery, mappers, filters, sorters, cachedBeans); }finally { lock。unlock(); if(!lock。isLocked()){ dataSetCenter。remove(accessCacheDataSetKey); } } return resultBeans; }

從程式碼來看,每次 dataSetCenter。put(accessCacheDataSetKey, new DataSet(count, modelClass))後,都會在finally裡面呼叫dataSetCenter。remove(accessCacheDataSetKey)把key刪除掉,這樣在GC時會自動回收Value值。但是忽略了一個方法getModelDataInternal,該方法可能會遞迴呼叫realTimeQueryBusinessModelData方法,如果存在遞迴呼叫的話,那麼由於可重入鎖lock還沒有完成解鎖,所以無法進入if(!lock。isLocked())條件語句中進行刪除key的操作,這樣就造成了一部分資料無法被刪除,隨著時間的推移,記憶體中的資料會越來越多。

三、故障解決

基於上述的程式碼分析,改造如下所示。

private List realTimeQueryBusinessModelData(IDataSetKey accessCacheDataSetKey,Set mappers, Set filters, Set sorters) throws DataNotFoundException, IllegalAccessException, CloneNotSupportedException, InstantiationException { List resultBeans = null; try { queryLock。lock(); modelQueryLock。lock(); if (!dataSetCenter。containsKey(accessCacheDataSetKey)) { log。info(“put DataSetKey into DataSetCenter,dataSetKey is {}”,accessCacheDataSetKey); int count = businessModelQuery。count(accessCacheDataSetKey); if (count == 0) throw new DataNotFoundException(); Class modelClass = businessModelCenter。getDataModelClass(accessCacheDataSetKey。getModelId()); if (modelClass == null) { throw new DataNotFoundException(); } dataSetCenter。put(accessCacheDataSetKey, new DataSet(count, modelClass)); } List cachedBeans = dataSetCenter。get(accessCacheDataSetKey)。getData(); resultBeans = getModelDataInternal(accessCacheDataSetKey, businessModelQuery, mappers, filters, sorters, cachedBeans); }finally { modelQueryLock。unlock(); if(!modelQueryLock。isLocked()){ removeDataSetKeys(); } queryLock。unlock(); } return resultBeans; }

這裡當modelQueryLock可重入鎖完全解鎖後,呼叫removeDataSetKeys方法,該方法會將dataSetCenter裡面的key全部刪除,這樣在GC時就會回收不用的資料物件。這裡採用兩個可重入鎖的目的是,如果只用一個modelQueryLock可重入鎖,那麼當modelQueryLock完全解鎖後,正在執行removeDataSetKeys方法時,其他執行緒就可以進入該方法區,發現dataSetCenter裡面還沒有刪除完全,從而獲取裡面的資料,即if (!dataSetCenter。containsKey(accessCacheDataSetKey))為false,從而透過List cachedBeans = dataSetCenter。get(accessCacheDataSetKey)。getData()直接獲取dataSetCenter裡面的資料,但是下一刻dataSetCenter裡面可能已經為空。因此,採用兩個可重入鎖,防止出現異常。