血的教訓之背景:使用執行緒池對存量資料進行遷移,但是總有一批資料遷移失敗,無異常日誌列印
凶案起因
聽說parallelStream
並行流是個好東西,由於日常開發stream
序列流的場景比較多,這次需要寫遷移程式剛好可以用得上,那還不趕緊拿來裝*一下,此時不裝更待何時。機智的我還知道在 JVM 的後臺,使用通用的 fork/join 池來完成上述功能,該池是所有並行流共享的,預設情況,fork/join 池會為每個處理器分配一個執行緒,對應的變通方案就是建立自己的執行緒池如
ForkJoinPool pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());
pool.submit(() -> {
list.parallelStream().collect(Collectors.toList());
});
於是地雷就是從這裡埋下的。
submit還是execute
public static void main(String[] args) throws InterruptedException, ExecutionException {
final ExecutorService pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());
List<Integer> list = Lists.newArrayList(1, 2, 3, null);
//1.使用submit
pool.submit(() -> {
list.parallelStream().map(a -> a.toString()).collect(Collectors.toList());
});
TimeUnit.SECONDS.sleep(3);
//2.使用 execute
pool.execute(() -> {
list.parallelStream().map(a -> a.toString()).collect(Collectors.toList());
});
//3.使用submit,呼叫get()
pool.submit(() -> {
list.parallelStream().map(a -> a.toString()).collect(Collectors.toList());
}).get();
TimeUnit.SECONDS.sleep(3);
}
讀者自行跑一下上面的用例,會發現單獨使用submit
方法的並不會列印出錯誤日誌,而使用execute
方法列印出了錯誤日誌,但是對submit
返回的FutureJoinTask
呼叫get()
方法,又會丟擲異常。於是真相大白,部分批次中的資料存在髒資料,為null值,遍歷到該null值的時候出現了異常,但是異常日誌在submit
方法中給catch住,沒有列印出來(心痛的感覺),而被捕獲的異常,被包裝在返回的結果類FutureJoinTask
中,並沒有再次丟擲。
如果不需要非同步返回結果,請不要用submit
方法
結論先行,我犯的錯誤就是,淺顯的認為submit
和execute
的區別就只是一個有返回非同步結果,一個沒有返回一步結果,但是事實是殘酷的。在submit()
中邏輯一定包含了將非同步任務丟擲的異常捕獲,而因為使用方法不當而導致該異常沒有再次丟擲。
現在提出一個問題,ForkJoinPool#submit()
中返回的ForkJoinTask
可以獲取非同步任務的結果,現這個非同步丟擲了異常,我們嘗試獲取該任務的結果會是如何? 我們直接看ForkJoinTask#get()
的原始碼。
public final V get() throws InterruptedException, ExecutionException {
int s = (Thread.currentThread() instanceof ForkJoinWorkerThread) ?
doJoin() : externalInterruptibleAwaitDone();
Throwable ex;
if ((s &= DONE_MASK) == CANCELLED)
throw new CancellationException();
//這裡可以直接看到,非同步任務出現異常會在呼叫get()獲取結果的時候,會被包裝成ExecutionException再次丟擲
if (s == EXCEPTIONAL && (ex = getThrowableException()) != null)
throw new ExecutionException(ex);
return getRawResult();
}
非同步任務出現異常會在呼叫get()獲取結果的時候,會被包裝成ExecutionException
再次丟擲,但是異常是在哪裡被捕獲的呢?萬變不離其宗,所有執行緒的執行緒都需要重寫Thread#run()
方法, 投遞到ForkJoinPool
的執行緒會被包裝成ForkJoinWorkerThread
,因此我們看一下ForkJoinWorkerThread#run()
的實現.
public void run() {
if (workQueue.array == null) { // only run once
Throwable exception = null;
try {
onStart();
pool.runWorker(workQueue);
} catch (Throwable ex) {
//出現異常,捕獲,再次丟擲會在呼叫ForkJoinTask#get()的時候
exception = ex;
} finally {
try {
onTermination(exception);
} catch (Throwable ex) {
if (exception == null)
exception = ex;
} finally {
pool.deregisterWorker(this, exception);
}
}
}
}
上面的分析是基於ForkJoinPool
的,是不是所有的執行緒池的submit
和execute
方法的實現都是類似這樣,我們常用的執行緒池ThreadPoolThread
實現會是怎樣的,同樣的思路,我們需要找到投遞到ThreadPoolThread
的非同步任務最終被包裝為哪個Thread
的子類或者是實現java.lang.Runnable#run
,答案就是java.util.concurrent.FutureTask
public void run() {
...
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
//捕獲異常
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);
}
}
....
}
總結
java.util.concurrent.ExecutorService#submit(java.lang.Runnable)
為何執行緒池會有這種設定,實際上我們的思路不應該侷限於執行緒池,而是放在獲取非同步任務結果,異常是否也是屬於非同步結果,FutureTask
作為JDK提供的併發工具類的實現中,已經給出了很好的答案,即獲取非同步任務結果,異常也是屬於非同步結果,如果非同步任務出現執行時異常,那麼在獲取該任務的結果時,該異常會被重新包裝丟擲
作者:plz叫我紅領巾
出處:https://juejin.im/post/5d15c430f265da1bab29c1fe
本部落格歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。碼子不易,您的點贊是我習作最大的動力