天天看點

Tomcat 應用中并行流帶來的類加載問題

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

本文首發于 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 應用中并行流帶來的類加載問題
Tomcat 應用中并行流帶來的類加載問題

至此已經将問題定位到了是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以後,類加載器的雙親委派模型被廣泛引入。它的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把整個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求時,子加載器才會嘗試自己去加載,流程如下圖。

Tomcat 應用中并行流帶來的類加載問題
Tomcat 應用中并行流帶來的類加載問題

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

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

Tomcat 應用中并行流帶來的類加載問題

是以 Tomcat 預設使用SafeForkJoinWorkerThreadFactory作為ForkJoinWorkerThreadFactory,并将該工廠建立的ForkJoinWorkerThread的contextClassLoader都指定為ForkJoinPool.class.getClassLoader(),而不是JDK預設的繼承父線程的contextClassLoader,進而避免了Tomcat應用中由并行流帶來的類加載器記憶體洩露。

三、總結

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

更多内容敬請關注 vivo 網際網路技術 微信公衆号

Tomcat 應用中并行流帶來的類加載問題

注:轉載文章請先與微信号:labs2020 聯系

分享 vivo 網際網路技術幹貨與沙龍活動,推薦最新行業動态與熱門會議。