Tomcat容器應用中使用CompletableFuture時,關于ClassLoader引起的問題

背景

有一個功能,這個功能里需要調用幾個不同的RPC請求,一開始不以為然,沒覺得什么,所以所有的RPC請求都是 串行 執行,后來發現部分RPC返回時間比較長導致此功能接口時間耗時較長,于是乎就使用了JDK8新特性CompletableFuture打算將這些不同的RPC請求異步執行,等所有的RPC請求結束后,再返回請求結果。

因為功能比較簡單沒什么特殊的,所以這里在使用CompletableFuture的時候,并沒有自定義線程池,默認那么就是ForkJoinPool。下面看下偽代碼:

        CompletableFuture task1 = CompletableFuture.runAsync(()->{
            /**
             * 這里會調用一個RPC請求,而這個RPC請求處理的過程中會通過SPL機制load指定接口的實現,這個接口所在jar存在于WEB-INFO/lib
             */
            System.out.println("任務1執行");
        });

        CompletableFuture task2 = CompletableFuture.runAsync(()->{
            System.out.println("任務2執行");
        });

        CompletableFuture task3 = CompletableFuture.runAsync(()->{
            System.out.println("任務3執行");
        });

        // 等待所以任務執行完成返回
        CompletableFuture.allOf(task1,task2,task3).join();

        return result;

其實初步上看,這段代碼沒什么特別的,每個任務都是調用一個RPC請求。初期測試這段代碼的時候是通過IDEA啟動項目,也就是用的是 SpringBoot 內嵌 Tomcat啟動的,這段代碼功能正常。然后呢,代碼開始commit,merge。

到了第二天之后,同事測試發現這段代碼拋出了異常,而且這個功能是主入口,那么就是說大大的阻塞啊,此時我心里心情是這樣的


立馬上后臺看日志,但是卻發現這個異常是RPC內部處理時拋出來的,第一反應那就是找上游服務提供方,問他們是不是改接口啦?準備開始甩鍋!


然后結果就是沒有!!! 于是乎我又跑了下項目,測試了一下接口,沒問題!確實沒問題!臥槽???還有更奇怪的事情,那就是同時裝了好幾套環境,其他環境是沒問題的,此時就沒再去關注,后來發現只有在重啟了服務器之后,這個問題就會作為必現問題,著實頭疼。

問題定位

到這里只能老老實實去debug RPC調用過程的源碼了。也就是代碼示例中寫的,RPC調用過程中,會使用ServiceLoader去找XX接口對應的實現類,而這個配置是在RPC框架的jar包中,這個jar包那自然肯定是在對應微服務的WEB-INFO/lib里了。

這段源碼大概長這樣吧:

       ArrayList list = new ArrayList<String>();
        ServiceLoader<T> serviceLoader = ServiceLoader.load(xxx interface);
        serviceLoader.forEach(xxx->{
            list.add(xxx)
        });

這步執行完后,如果list是空的,那就會拋個異常,這個異常就是前面所說RPC調用過程中的異常了。

到這里,加載不到,那就要懷疑ClassLoader了,先看下ClassLoader加載范圍

  • Bootstrap ClassLoader
    %JRE_HOME%\lib 下的 rt.jar、resources.jar、charsets.jar 和 class

  • ExtClassLoader
    %JRE_HOME%\lib\ext 目錄下的jar包和class

  • AppClassLoader
    當前應用ClassPath指定的路徑中的類

  • ParallelWebappClassLoader
    這個就屬于Tomcat自定義ClassLoader了,可以加載當前應用下WEB-INFO/lib

再看下ServiceLoader的實現:

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {
        return new ServiceLoader<>(service, loader);
    }

    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload();
    }

調用load的時候,先獲取當前線程的上下文ClassLoader,然后調用new,進入到ServiceLoader的私有構造方法中,這里重點有一句 loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; ,如果傳入的classLoader是null(null就代表是BootStrapClassLoader),就使用ClassLoader.getSystemClassLoader(),其實就是AppClassLoader了。

然后就要確定下執行ServiceLoader.load方法時,最終ServiceLoader的loader到底是啥?

  • 1.Debug 通過Sring Boot 內嵌Tomcat啟動的應用
    在這種情況下ClassLoader是org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader

  • 2.Debug 通過Tomcat啟動的應用
    在這種情況下ClassLoader是AppClassLoader,通過Thread.currentThread().getContextClassLoader()獲取到的是null

真相已經快要接近,為啥同樣的代碼,Tomcat應用啟動的獲取到的線程當前上下文類加載器卻是BootStrapClassLoader呢?

問題就在于CompletableFuture.runAsync這里,這里并沒有顯示指定Executor,所以會使用ForkJoinPool線程池,而ForkJoinPool中的線程不會繼承父線程的ClassLoader。enmm,很奇妙,為啥不繼承,也不知道。。。

問題印證

下面通過例子來證實下,先從基本的看下,這里主要是看子線程會不會繼承父線程的上下文ClassLoader,先自定義一個ClassLoader,更加直觀:

class MyClassLoader extends ClassLoader{
    
}

測試一

    private static void test1(){
        MyClassLoader myClassLoader = new MyClassLoader();

        Thread.currentThread().setContextClassLoader(myClassLoader);

        // 創建一個新線程
       new Thread(()->{
           System.out.println( Thread.currentThread().getContextClassLoader());
       }).start();

    }

輸出

classloader.MyClassLoader@4ff782ab

測試結論: 通過普通new Thread方法創建子線程,會繼承父線程的上下文ClassLoader

*源碼分析:
查看new Thread創建線程源碼發現有如下代碼

        if (security == null || isCCLOverridden(parent.getClass()))
            this.contextClassLoader = parent.getContextClassLoader();
        else
            this.contextClassLoader = parent.contextClassLoader;

所以子線程的上下文ClassLoader會繼承父線程的上下文ClassLoader

測試二

Tomcat容器環境下執行下述代碼

        MyClassLoader myClassLoader = new MyClassLoader();

        Thread.currentThread().setContextClassLoader(myClassLoader);

        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            System.out.println(Thread.currentThread().getContextClassLoader());
        });

輸出

null

但是如果通過main函數執行上述代碼,依然是會打印出自定義類加載器

為啥呢?查了一下資料,Tomcat 默認使用SafeForkJoinWorkerThreadFactory作為ForkJoinWorkerThreadFactory,然后看下SafeForkJoinWorkerThreadFactory源碼

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

這里發現,ForkJoinPool線程設置的ClassLoader是java.util.concurrent.ForkJoinPool的類加載器,而此類位于rt.jar包下,那它的類加載器自然就是BootStrapClassLoader了

問題解決

解決方式一:

        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            Thread.currentThread().setContextClassLoader(contextClassLoader);
        });

那就是在ForkJoinPool線程中再重新設置一下上下文ClassLoader

解決方式二:

        CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
            Thread.currentThread().setContextClassLoader(contextClassLoader);
        },new MyExecutorService());

那就是不使用CompletableFuture的默認線程池ForkJoinPool,轉而使用我們的自定義線程池

后續補充

為什么在Tomcat中CompletableFutrue的默認線程池ForkJoinPool的threadFactory卻是SafeForkJoinWorkerThreadFactory?

1)首先查看ForkJoinPool設置ThreadFactory的地方源碼

private static ForkJoinPool makeCommonPool() {
        int parallelism = -1;
        ForkJoinWorkerThreadFactory factory = null;
        UncaughtExceptionHandler handler = null;
        try {  // ignore exceptions in accessing/parsing properties
   、、、、、、省略不重要代碼
            String fp = System.getProperty
                ("java.util.concurrent.ForkJoinPool.common.threadFactory");
            if (fp != null)
                factory = ((ForkJoinWorkerThreadFactory)ClassLoader.
                           getSystemClassLoader().loadClass(fp).newInstance());
   、、、、、、省略不重要代碼
        } catch (Exception ignore) {
        }
        if (factory == null) {
            if (System.getSecurityManager() == null)
                factory = defaultForkJoinWorkerThreadFactory;
            else // use security-managed default
                factory = new InnocuousForkJoinWorkerThreadFactory();
        }
          、、、、、、省略不重要代碼

可以看到,如果可以從java.util.concurrent.ForkJoinPool.common.threadFactory中獲取到值,那么就使用這個值作為ThreadFactory,相當于是一個擴展。

2)那么這個值是在哪里設置進去的呢?
設置的地方就是org.apache.catalina.core.JreMemoryLeakPreventionListener。這個類實現了LifecycleListener。其中有一行代碼

                if (forkJoinCommonPoolProtection && !JreCompat.isJre9Available()) {
                    // 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,
                                SafeForkJoinWorkerThreadFactory.class.getName());
                    }
                }

可以看到就是這里的鍋。

參考

https://segmentfault.com/a/1190000022798514
http://www.lxweimin.com/p/8fcce16ae507
http://blog.itpub.net/69912579/viewspace-2658278/
https://tomcat.apache.org/tomcat-8.5-doc/config/listeners.html

https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8172726
http://tomcat.apache.org/tomcat-8.5-doc/changelog.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。