Tomcat 應用中並行流帶來的類載入問題

  • 2019 年 10 月 3 日
  • 筆記

本文首發於 vivo互聯網技術 微信公眾號 
鏈接:https://mp.weixin.qq.com/s/f-X3n9cvDyU5f5NYH6mhxQ
作者:肖銘軒、王道環

隨著 Java8 的不斷流行,越來越多的開發人員使用並行流(parallel)這一特性提升程式碼執行效率。但是,作者發現在 Tomcat 容器中使用並行流會出現動態載入類失敗的情況,通過對比 Tomcat 多個版本的源碼,結合併行流和 JVM 類載入機制的原理,成功定位到問題來源。本文對這個問題展開分析,並給出解決方案。

一、問題場景

在某應用中,服務啟動時會通過並行流調用 Dubbo,調用程式碼如下:

Lists.partition(ids, BATCH_QUERY_LIMIT).stream()       .parallel()       .map(Req::new)       .map(client::batchQuery)       .collect(Collectors.toList());
調用日誌中發現大量的 WARN 日誌com.alibaba.com.caucho.hessian.io.SerializerFactory.getDeserializer Hessian/Burlap:‘XXXXXXX’ is an unknown class in null:java.lang.ClassNotFoundException: XXXXXXX,在使用介面返回結果的時候拋出錯誤 java.lang.ClassCastException: java.util.HashMap cannot be cast to XXXXXXX。

二、原因分析

1、初步定位

首先根據錯誤日誌可以看到,由於依賴的 Dubbo 服務返回參數的實體類沒有找到,導致 Dubbo 返回的數據報文在反序列化時無法轉換成對應的實體,類型強制轉化中報了java.lang.ClassCastException。通過對執行緒堆棧和WARN日誌定位到出現問題的類為com.alibaba.com.caucho.hessian.io.SerializerFactory,由於 _loader 為 null 所以無法對類進行載入,相關程式碼如下:

try {         Class cl = Class.forName(type, false, _loader);         deserializer = getDeserializer(cl);     } catch (Exception e) {         log.warning("Hessian/Burlap: '" + type + "' is an unknown class in " + _loader + ":n" + e);      log.log(Level.FINER, e.toString(), e);     }  

 接下來繼續向上定位為什麼** _loader** 會為 null,SerializerFactory 構造方法中對 _loader 進行了初始化,初始化程式碼如下,可以看出 _loader 使用的是當前執行緒的 contextClassLoader。

public SerializerFactory() {      this(Thread.currentThread().getContextClassLoader());  }    public SerializerFactory(ClassLoader loader) {      _loader = loader;  }  

 根據堆棧看到當前執行緒為ForkJoinWorkerThread,ForkJoinWorkerThread是Fork/Join框架內的工作執行緒(Java8 並行流使用的就是Fork/Join)。JDK文檔指出:

The context ClassLoader is provided by the creator of the thread for use by code running in this thread when loading classes and resources. If not set, the default is the ClassLoader context of the parent Thread.

因此當前的執行緒contextClassLoader應該和創建此執行緒的父執行緒保持一致才對,不應該是null啊?

繼續看ForkJoinWorkerThread創建的源碼,首先使用ForkJoinWorkerThreadFactory創建一個執行緒,然後將創建的執行緒註冊到ForkJoinPool中,執行緒初始化的邏輯和普通執行緒並無差別,發現單獨從JDK自身難以發現問題,因此將分析轉移到Tomcat中。

2、Tomcat升級帶來的問題

取 Tomcat7.0.x 的一些版本做了實驗和對比,發現7.0.74之前的版本無此問題,但7.0.74之後的版本出現了類似問題,實驗結果如下表。

至此已經將問題定位到了是Tomcat的版本所致,通過源程式碼比對,發現7.0.74版本之後的Tomcat中多了這樣的程式碼:

if (forkJoinCommonPoolProtection && IS_JAVA_8_OR_LATER) {      // Don't override any explicitly set property      if (System.getProperty(FORK_JOIN_POOL_THREAD_FACTORY_PROPERTY) == null) {          System.setProperty(FORK_JOIN_POOL_THREAD_FACTORY_PROPERTY,                  "org.apache.catalina.startup.SafeForkJoinWorkerThreadFactory");      }  }  

 

private static class SafeForkJoinWorkerThread extends ForkJoinWorkerThread {       protected SafeForkJoinWorkerThread(ForkJoinPool pool) {         super(pool);         setContextClassLoader(ForkJoinPool.class.getClassLoader());     }  }  

在 Java8 環境下,7.0.74 版本之後的 Tomcat 會默認將 SafeForkJoinWorkerThreadFactory 作為 ForkJoinWorkerThread 的創建工廠,同時將該執行緒的 contextClassLoader 設置為ForkJoinPool.class.getClassLoader(),ForkJoinPool 是屬於rt.jar包的類,由BootStrap ClassLoader載入,所以對應的類載入器為null。至此,_loader為空的問題已經清楚,但是Tomcat為什麼要多此一舉,將null作為這個 ForkJoinWorkerThread的contextClassLoader呢?

繼續對比Tomcat的changeLog http://tomcat.apache.org/tomcat-7.0-doc/changelog.html 發現Tomcat在此版本修復了由ForkJoinPool引發的記憶體泄露問題 Bug 60620 - [JRE] Memory leak found in java.util.concurrent.ForkJoinPool,為什麼執行緒的contextClassLoader會引起記憶體泄露呢?

3、contextClassLoader記憶體泄露之謎

在JDK1.2以後,類載入器的雙親委派模型被廣泛引入。它的工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把整個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器回饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入,流程如下圖。

然而雙親委派的模型並不能保證應用程式載入類的過程,一個典型的例子就是JNDI服務,這些介面定義在rt.jar並由第三方提供實現,Bootstrap ClassLoader顯然不認識這些程式碼。為了解決這個問題,JDK1.2同時引入了執行緒上下文類載入器(Thread Context ClassLoader)進行類載入,作為雙親委派模型的補充。

回到記憶體泄漏的問題上,設想一個場景,如果某個執行緒持有了ClassLoaderA(由ClassLoaderA載入了若干類),當應用程式需要對ClassLoaderA以及由ClassLoaderA載入出來的類卸載完成後,執行緒A仍然持有了ClassLoaderA的引用,然而業務方以為這些類以及載入器已經卸載乾淨,由於類載入器和其載入出的類雙向引用,這就造成了類載入器和其載入出來的類無法垃圾回收,造成記憶體泄露。在並行流中,ForkJoinPool和ForkJoinWorkerThreadFactory默認是靜態且共享的(JDK官方推薦,創建執行緒本身是相對重的操作,盡量避免重複創建ForkJoinWorkerThread 造成資源浪費),下圖描繪了發生記憶體泄露的場景:

因此 Tomcat 默認使用SafeForkJoinWorkerThreadFactory作為ForkJoinWorkerThreadFactory,並將該工廠創建的ForkJoinWorkerThread的contextClassLoader都指定為ForkJoinPool.class.getClassLoader(),而不是JDK默認的繼承父執行緒的contextClassLoader,進而避免了Tomcat應用中由並行流帶來的類載入器記憶體泄露。

三、總結

在開發過程中,如果在計算密集型任務中使用了並行流,請避免在子任務中動態載入類;其他業務場景請盡量使用執行緒池,而非並行流。總之,我們需要避免在Tomcat應用中通過並行流進行自定義類或者第三方類的動態載入。

更多內容敬請關注 vivo 互聯網技術 微信公眾號

註:轉載文章請先與微訊號:labs2020 聯繫

Tags: