一波三折!記一次非堆記憶體洩漏(CXF+Jackson)的排查

sswhsz發表於2022-11-23

起因

表面現象:客戶生產環境,執行一段時間(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版本的用法
        }
    }
}

再次實測效果如下:

問題至此,看上去似乎完美解決了。但殊不知,後面還有一個巨大的坑在等著我們。

文章太長了,後續接著寫。(給一點小提示,看文章標題)

相關文章