背景
有一個功能,這個功能裏需要調用幾個不同的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。
到了第二天之後,同事測試發現這段代碼拋出了異常,而且這個功能是主入口,那麼就是說大大的阻塞啊,此時我心裏心情是這樣的
[圖片上傳失敗...(image-320b40-1608800133019)]
立馬上後臺看日誌,但是卻發現這個異常是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,轉而使用我們的自定義線程池