首頁>技術>

背景

有一個功能,這個功能裡需要呼叫幾個不同的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(() -> {                 },new MyExecutorService());

那就是不使用CompletableFuture的預設執行緒池ForkJoinPool,轉而使用我們的自定義執行緒池

連結:https://juejin.cn/post/6909445190642040846

3
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • Redis Lua指令碼編寫快速指南