起因
表面現象:客戶生產環境,執行一段時間(10~20天)後,無法連線kafka服務,這個現象反覆出現。
透過pinpoint監控檢視故障前後的jvm狀態,意外發現一個以前從未留意過的問題:那就是非堆記憶體滿了。
從上圖可以看出來,非堆記憶體滿了之後,系統進行了頻繁的FullGC,但是記憶體並沒有得到回收。
藉助pinpoint,我們往前回溯從上次jvm啟動後,非堆記憶體的變化,發現:
- 9月2日,重啟後, 非堆記憶體佔用:300M
- 9月9日 1.4G
- 9月16日 2.1G
- 9月23日 3G
- 9月29日 4G
一般情況下,對於記憶體,我們會比較關注 堆記憶體,一般的記憶體洩漏,也都發生在堆記憶體中。非堆記憶體一般出問題的很少,所以我們關注也比較少。
初步懷疑
印象中,非堆記憶體(或者metaspace)儲存的內容包括:class物件、字串常量池、java棧記憶體、本地native庫分配的記憶體、DirectByteBuffer分配的記憶體;
(上面描述可能不準確)
由於系統中很少使用DirectByteBuffer,所以首先懷疑嵌入jvm程式的本地native庫rocksdb,作為小檔案讀寫快取,rocksdb嫌疑最大。
於是我們寫了一個小程式對rocksdb的記憶體佔用進行分析,試驗場景:10G資料寫入rocksdb和隨機讀寫,非堆記憶體一直穩定在 300M,並沒有增長。於是排除了rocksdb的嫌疑。
發現問題
在排除rocksdb的嫌疑後,再回想到最初檢視系統日誌時的記憶體溢位提示:CallWebService:java.lang.OutOfMemoryError: Compressed class space,網上搜尋一番,找到一篇知乎小短文:
JVM調優中,壓縮類空間(Compressed Class space)如何理解
看到下面說明:
一般來說,平均一個 Klass 大小可以當成 1K 來算,預設的 1G 大小可以儲存 100 萬的 Klass。
如果遇到了 `java.lang.OutOfMemoryError: Compressed class space`,就是類太多了,需要結合具體情況去選擇 JVM 調優還是 bug 排查。
由於業務系統正常啟動情況下 class 大約是 3萬個,這個溢位說明系統的class 接近 100萬了?
系統出現問題時,由於各種錯誤較多,所以居然忽略了這個重要的錯誤資訊,這也算是走了一段彎路。
結合出錯位置:CallWebService,由於系統中使用了CXF來動態呼叫webservice,動態呼叫webservice的過程包含了:java程式碼生成、class編譯和載入的過程,
難道是這個過程存在class洩漏嗎?
立即開始行動,找到系統使用的CXF版本:2.7.3,編寫一個小程式來驗證:
public static void main(String[] args) throws Exception {
String wsdlUrl = "http://10.1.28.143:8094/services/test?wsdl";
String method = "AAAA";
Object[] params = { "1" };
for (int i = 0; i < 1000; i++) {
Object[] result = invokeWebService(wsdlUrl, method, params);
System.out.println(Arrays.asList(result));
}
new CountDownLatch(1).await();
}
public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
Client client = null;
try {
DynamicClientFactory factory = DynamicClientFactory.newInstance();
client = factory.createClient(wsdlUrl);
return client.invoke(method, params);
}
finally {
if (client != null) {
client.destroy();
}
}
}
使用jconsole觀察呼叫過程,果然class數量在不斷增長,FullGC也不能回收。
第一次解決
就CXF2.7.3動態呼叫webservice可能存在class洩漏問題,在網上檢索一番,好像沒看到有相關的話題。
去maven中心倉庫search.maven.org
查詢CXF的最新版本,如下:
<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-bundle-compatible</artifactId>
<version>3.5.3</version>
<type>bundle</type>
</dependency>
換上新版本再次試驗,結果呼叫正常了。區別是 3.5.3版本Client物件提供一個close方法(2.7.3沒有close方法)
public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
Client client = null;
try {
DynamicClientFactory factory = DynamicClientFactory.newInstance();
client = factory.createClient(wsdlUrl);
return client.invoke(method, params);
}
finally {
if (client != null) {
// client.destroy(); 2.7.3版本使用destroy方法
client.close(); // 3.5.3版本使用close方法
}
}
}
檢視 CXF3.5.3版本close方法的內容:
static class DynamicClientImpl extends ClientImpl implements AutoCloseable {
final ClassLoader cl;
final ClassLoader orig;
DynamicClientImpl(Bus bus, Service svc, QName port,
EndpointImplFactory endpointImplFactory,
ClassLoader l) {
super(bus, svc, port, endpointImplFactory);
cl = l;
orig = Thread.currentThread().getContextClassLoader(); //儲存原始的classloader
}
@Override
public void close() throws Exception {
destroy();
if (Thread.currentThread().getContextClassLoader() == cl) {
Thread.currentThread().setContextClassLoader(orig); //還原原始的classloader
}
}
}
原來,在建立DynamicClientImpl例項時儲存了當前的上下文classloader,同時在close()時,對上下文classloader進行了還原。
我們再一步跟蹤下org.apache.cxf.endpoint.dynamic.DynamicClientFactory.createClient()
的邏輯:
public Client createClient(String wsdlUrl, QName service, ClassLoader classLoader, QName port,
List<String> bindingFiles) {
//為了演示方便,下面只摘抄了關鍵程式碼
//0、例項化clientimpl物件
ClientImpl client = new ClientImpl(bus, svc, port, getEndpointImplFactory());
//1、根據wsdl生成java程式碼
JCodeModel codeModel = intermediateModel.generateCode(null, elForRun);
File src = new File(tmpdir, stem + "-src");
Object writer = JAXBUtils.createFileCodeWriter(src);
codeModel.build(writer);
//2、編譯java程式碼
File classes = new File(tmpdir, stem + "-classes");
setupClasspath(classPath, classLoader);
List<File> srcFiles = FileUtils.getFilesRecurse(src, ".+\\.java$");
compileJavaSrc(classPath.toString(), srcFiles, classes.toString()));
//3、建立classloader
URL[] urls = new URL[] { classes.toURI().toURL() };
ClassLoader cl = ClassLoaderUtils.getURLClassLoader(urls, classLoader);
//4、載入class
JAXBContext context = JAXBContext.newInstance(packageList, cl, contextProperties);
JAXBDataBinding databinding = new JAXBDataBinding();
databinding.setContext(context);
svc.setDataBinding(databinding);
//5、將新的classloader設定到當前執行緒上下文中,這一步的目的,是在後續使用invoke方法呼叫webservice時,能從當前執行緒上下文classloader中找到webservice動態生成的類
ClassLoaderUtils.setThreadContextClassloader(cl);
//6、TypeClass初始化 (這一步含義還不清楚)
ServiceInfo svcfo = client.getEndpoint().getEndpointInfo().getService();
TypeClassInitializer visitor = new TypeClassInitializer(svcfo, intermediateModel, allowWrapperOps());
visitor.walk();
return client;
}
至此,class洩漏的原因應該比較清楚了:
原因是 CXF2.7.3在client.destroy()後, 缺少上下文ClassLoader的還原,導致當前的ClassLoader變成了一個鏈,
每動態呼叫一次,ClassLoader鏈就變長一次,導致所有載入的class都無法解除安裝。
由於升級CXF涉及工作量較大,我們只需在CXF2.7.3之上,在呼叫client前後加上一段小小的邏輯,來手工還原classloader就行了。改造如下:
public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
Client client = null;
ClassLoader orig = Thread.currentThread().getContextClassLoader();
try {
DynamicClientFactory factory = DynamicClientFactory.newInstance();
client = factory.createClient(wsdlUrl);
return client.invoke(method, params);
}
finally {
if (orig != Thread.currentThread().getContextClassLoader()) {
Thread.currentThread().setContextClassLoader(orig); //為2.7.3版本手工還原classloader
}
if (client != null) {
client.destroy(); //2.7.3版本的用法
}
}
}
再次實測效果如下:
問題至此,看上去似乎完美解決了。但殊不知,後面還有一個巨大的坑在等著我們。
文章太長了,後續接著寫。(給一點小提示,看文章標題)