由JDK bug引發(fā)的線上OOM
最近生產(chǎn)環(huán)境的一個應(yīng)用忽然發(fā)生了OOM,還好是業(yè)務(wù)低峰期,沒有導(dǎo)致什么嚴重問題,下面記錄下本次排查的過程;
故障臨時處理
在某天下午,正在愉快的寫代碼時,忽然看到業(yè)務(wù)反饋支付服務(wù)不能用的消息,因為最近沒有發(fā)布,所以感覺不會是什么大事,十有八九是網(wǎng)絡(luò)波動啥的,畢竟之前遇到過好多次,那剩下的就是找證據(jù)了,先看看日志,有沒有報錯(暫時還未接告警,所以要人肉看),結(jié)果不看不要緊,一看嚇一跳,日志密密麻麻全都是OOM報錯

幸好作者這百年Java開發(fā)經(jīng)驗不是白給的,反手就是一個重啟服務(wù),雖然看起來只是一個簡單的重啟,但是操作起來可并不簡單,里邊的道道還是很多的,重啟的時候要注意留一臺作為現(xiàn)場保護起來,同時給這臺保留現(xiàn)場的實例的流量摘除掉,然后給其他實例重啟起來,這樣用戶就能正常使用了;
本來以為事情到這里就結(jié)束了,但是就當我準備繼續(xù)下一步的時候,我發(fā)現(xiàn)重啟的早的那臺機器內(nèi)存已經(jīng)又直線上升上來了;

不過這也難不倒我,既然重啟后服務(wù)內(nèi)存又開始飆升,說明肯定是定時任務(wù)、批處理之類的觸發(fā)了,查看了下日志,果然是有大批的定時任務(wù)在執(zhí)行,將定時任務(wù)暫停后發(fā)現(xiàn)就好了,下面開始對現(xiàn)場進行分析;
現(xiàn)場處理
登錄到我們保留的現(xiàn)場機器上,使用下面的命令執(zhí)行堆dump,方便我們后續(xù)分析:
# 安裝gdb,如果機器上有就無需安裝
yum install -y gdb
# 設(shè)置不限制core dump大小
ulimit -c unlimited
# 生成core dump,文件名叫core,也可以自己起名,100是目標Java進程pid,這個需要根據(jù)實際的來,命令執(zhí)行完畢后會生成一個core.100的core dump
gcore 100 -o core
有的同學(xué)可能看到這里就開始迷糊了,Java堆dump不是用jmap命令嗎,上邊的命令跟jmap也沒什么關(guān)系呀,我們這里之所以用gcore而不是jmap來dump,主要是因為在OOM時,通常JVM已經(jīng)無法正常使用jmap來dump了(針對本次排查就是這種情況),如果你一定要使用jmap來操作,那么他會報錯,無法進行堆dump,同時會在錯誤信息中告訴我們可以嘗試使用jmap -F參數(shù)來進行堆dump,但是加上這個參數(shù)后,你會發(fā)現(xiàn)噩夢開始了,因為此時雖然能正常進行dump,但是速度可以說是慘不忍睹,4G的堆dump時間要按小時算,本質(zhì)上是因為當我們使用jmap -F來進行堆dump的時候?qū)嶋H上底層使用了ptrace來dump(使用ptrace讀取目標進程內(nèi)存然后寫出到文件),由于ptrace一次最多只能讀取4字節(jié)(32位機器),所以導(dǎo)致他的速度也極其的慢;而gcore生成速度相對于正常jmap來說也是比較快的,對于jmap -F就更快了;所以,基于以上幾點,我們選擇了使用gcore來進行堆dump;
當我們使用gcoredump完后,因為最終還是需要使用Java系的工具進行內(nèi)存分析,所以還是要將core dump轉(zhuǎn)換為Java的堆dump,此時我們就可以執(zhí)行以下命令來轉(zhuǎn)換了:
注意,core dump完成后就可以先重啟服務(wù)了,重啟完服務(wù)再進行下面的步驟;
# 生成堆dump
jmap -dump:format=b,file=heap.hprof `which java` core.100
堆dump生成完畢后,將其下載下來,然后導(dǎo)入eclipse Memory Analyzer(MAT)開始分析,發(fā)現(xiàn)大量org.bouncycastle.jce.provider.BouncyCastleProvider實例被javax.crypto.JceSecurity類的verificationResults這個靜態(tài)字段持有,下面就可以開始源碼分析了;
具體怎么分析出是這個地方內(nèi)存泄漏這里不做單獨說明了,可以自行查詢官方使用文檔,后續(xù)也會考慮單獨出一期分析方法的文章;
問題分析
查看javax.crypto.JceSecurity的源碼,發(fā)現(xiàn)verificationResults是一個map,同時只在getVerificationResult這個方法中被放入了數(shù)據(jù),源碼如下:

經(jīng)過結(jié)合我們的業(yè)務(wù)代碼分析,發(fā)現(xiàn)是我們調(diào)用了javax.crypto.Cipher.getInstance(java.lang.String, java.security.Provider)這個方法,這個方法調(diào)用了javax.crypto.JceSecurity.getVerificationResult方法;getVerificationResult這個方法比較簡單,就是對我們提供的java.security.Provider所在的jar進行簽名校驗,校驗完畢后將我們提供的Provider作為key、校驗結(jié)果作為value放入verificationResults這個map緩存,下次就不用校驗了,但是這里有個問題,就是這個緩存沒有任何清理機制,也就意味著我們?nèi)绻l繁調(diào)用javax.crypto.Cipher.getInstance(java.lang.String, java.security.Provider)來獲取AES實例的話,就是可能會導(dǎo)致內(nèi)存泄漏的,經(jīng)過我們驗證,也確實是這樣,可以使用以下代碼復(fù)現(xiàn):
注意指定jvm參數(shù): -Xmx128m
import java.security.NoSuchAlgorithmException;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
/**
* @author JoeKerouac
* @date 2023-08-24 12:50
*/
public class Test {
public static void main(String[] args) throws NoSuchAlgorithmException, NoSuchPaddingException {
for (int i = 0; i < 500; i++) {
Cipher c = Cipher.getInstance("AES", new BouncyCastleProvider());
}
}
}
問題解決
既然問題定位到了,那解決起來就比較簡單了,我們可以把Provider實例全局共享,或者使用Provider的名字來獲取AES實例,這樣不去反復(fù)創(chuàng)建Provider而是使用同一個Provider,在JceSecurity中自然也不會內(nèi)存泄漏,代碼如下:
至于作者為什么不使用單例?那是因為AES并不是線程安全的,無法全局共享,當然,可以使用單例然后自行控制并發(fā),或者使用對象池技術(shù)、ThreadLocal等來解決;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.Security;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
/**
* @author JoeKerouac
* @date 2023-08-24 12:50
*/
public class Test {
private static BouncyCastleProvider provider = new BouncyCastleProvider();
public static void main(String[] args)
throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
// 使用全局共享的provider
Cipher cipher = plan1();
// 使用provider的名字獲取AES實例,其實本質(zhì)上也是全局共享了provider
// 注意,如果要使用provider的名字獲取AES實例,要先注冊
Security.addProvider(new BouncyCastleProvider());
cipher = plan2();
}
public static Cipher plan1() throws NoSuchAlgorithmException, NoSuchPaddingException {
return Cipher.getInstance("AES", provider);
}
public static Cipher plan2() throws NoSuchAlgorithmException, NoSuchPaddingException, NoSuchProviderException {
return Cipher.getInstance("AES", "BC");
}
}
問題溯源
這應(yīng)該是一個比較容易發(fā)現(xiàn)的問題,既然這樣,那有沒有人提出這個問題呢,想到這里,作者打開了Google,經(jīng)過一番搜索后(其實很容易就能搜到,只需要搜索關(guān)鍵字javax.crypto.JceSecurity#getVerificationResult即可),發(fā)現(xiàn)確實有人給jdk提了這個bug,而且也給出了解決方案,代碼已經(jīng)合并到了master,不過截至發(fā)文時,在作者使用的eclipse jdk(Temurin)中,jdk8、jdk17這兩個版本的最新發(fā)布中(2023-07-25發(fā)布)仍然存在該問題,并未修復(fù),所以如果遇到該問題,還是需要使用上邊的解決方案來處理;
官方bug記錄:https://bugs.openjdk.org/browse/JDK-8168469
至此,我們的問題已經(jīng)解決了,通過本篇文章,你應(yīng)該大概知道了線上發(fā)生OOM時的處理流程了,以后碰到類似問題可以按照本流程來直接套用,至少大多數(shù)場景下都是可以的;
聯(lián)系我-
作者微信:JoeKerouac
-
微信公眾號(文章會第一時間更新到公眾號,如果搜不出來可能是改名字了,加微信即可=_=|):代碼深度研究院
-
GitHub:https://github.com/JoeKerouac
