關於多執行緒中拋異常的這個面試題我再說最後一次!

why技術發表於2020-09-21

這裡why的第 66 篇原創文章

一道面試題

我一年前寫過這篇文章《有的執行緒它死了,於是它變成一道面試題》,這是早期作品,遣詞造句,排版行文都有一點稚嫩,但是不知咋地,還是有很多人看過。

甚至已經進入了某網紅公司的面試題庫裡面。

所以我後面應該會重寫一下,翻新翻新,再補充一點新的東西進去。

現在先回顧一下這篇文章丟擲的問題和問題的答案:

一個執行緒池中的執行緒異常了,那麼執行緒池會怎麼處理這個執行緒?

這個題是我遇到的一個真實的面試題,當時並沒有回答的很好。然後通過上面的文章,我在原始碼中尋找到了答案。

先給大家看兩個案例。

當執行方式是 execute 方法時,在控制檯會列印堆疊異常:

當執行方式是 submit 方法時,在控制檯不會列印堆疊異常:

那麼怎麼獲取這個 submit 方法提交時的異常資訊呢?

得呼叫返回值 future 的 get 方法:

具體原因,我在之前的文章裡面詳細分析過,就不贅述了,直接看結論:

然後一個讀者找我聊天,說為什麼他這樣寫,通過 future.get 方法沒有丟擲異常呢,和我文章裡面說的不一樣呢?

我說:那肯定是你操作不對,你把程式碼發給我看看。

然後我收到了一份這樣的程式碼:

public class ExecutorsTest {

    public static void main(String[] args) {
        ThreadPoolExecutor executorService = new ThreadPoolExecutor(2, 2,
                30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));
        Future future = executorService.submit(() -> {
            try {
                sayHi("submit");
            } catch (Exception e) {
                System.out.println("sayHi Exception");
                e.printStackTrace();
            }
        });

        try {
            future.get();
        } catch (Exception e) {
            System.out.println("future.get Exception");
            e.printStackTrace();
        }
    }

    private static void sayHi(String name) throws RuntimeException {
        String printStr = "【thread-name:" + Thread.currentThread().getName() + ",執行方式:" + name + "】";
        System.out.println(printStr);
        throw new RuntimeException(printStr + ",我異常啦!哈哈哈!");
    }
}

這個程式的輸出結果是這樣的:

我尋思這沒毛病呀,這不是很正常嗎?不就是應該這樣輸出嗎?

那個哥們說:和你說的不一樣啊,你說的是呼叫 future.get 方法的時候會丟擲異常的?我這裡並沒有輸出“future.get Exception”,說明 future.get 方法沒有丟擲異常。

我回答到:你這不是把會丟擲執行時異常的 sayHi 方法用 try/catch 程式碼塊包裹起來了嗎?異常在子執行緒裡面就處理完了,也就不會封裝到 Future 裡面去了。你把 try/catch 程式碼塊去掉,異常就會封裝到 Future 裡面了。

過了一小會,他應該是實驗完了,又找過來了。

他說:牛逼呀,確實是這樣的。那你的這個面試題是有問題的啊,描述不清楚,正確的描述應該是一個執行緒池中的執行緒丟擲了未經捕獲的執行時異常,那麼執行緒池會怎麼處理這個執行緒?

看到他的這個回覆的時候,我竟然鼓起掌來,這屆讀者真是太嚴格了!但是他說的確實是沒有錯,嚴謹點好。

他還追問到:怎麼實現的呢?為什麼當 submit 方法提交任務的時候,子執行緒捕獲了異常,future.get 方法就不丟擲異常了呢?

其實聽到這個問題的時候都把我幹懵了。

這問法,難道你是想再拋一次異常出來?

其實大家按照正常的思維去想,都能知道如果子執行緒捕獲了一次,future.get 方法就不應該丟擲異常了。

所以,現在的問題是,這個小小的功能,線上程池裡面是怎麼實現的?

現在的面試題在原來的基礎上再加一層:

好,你說當執行方法是 submit 的時候,如果子執行緒丟擲未經捕獲的執行時異常,將會被封裝到 Future 裡面?那麼如果子執行緒捕獲了異常,該異常還會封裝到 Future 裡面嗎?是怎麼實現的呢?

尋找答案-FUTURE

來,一起去原始碼裡面尋找答案。

現在是用 submit 的方式往執行緒池裡面提交任務,而執行的這個任務會丟擲執行時異常。

對於丟擲的這個異常,我們分為兩種情況:

  • 子執行緒中捕獲了異常,則呼叫返回的 future 的 get 方法,不會丟擲異常。

  • 子執行緒中沒有捕獲異常,則呼叫返回的 future 的 get 方法,會丟擲異常。

兩種情況都和 future.get 方法有關,那我們就從這個方法的原始碼入手。

這個 Future 是一個介面:

而這個介面有非常多的實現類。我們找哪個實現類呢?

就是下面這個實現類:

java.util.concurrent.FutureTask

至於是怎麼找到它的,你慢慢往後看就知道了。

先看看 FutureTask 的 get 方法:

get 方法的邏輯很簡單,首先判斷當前狀態是否已完成,如果不是,則進入等待,如果是,則進入 report 方法。

一進 get 方法,我們就看到了 state 這個東西,這是 FutureTask 裡面一個非常重要的東西:

在 FutureTask 裡面,一共有 7 種狀態。這 7 種狀態之間的流轉關係已經在註釋裡面寫清楚了。

狀態之間只會按照這四個流程去流轉。

所以,一目瞭然,一個任務的終態有四種:NORMAL、EXCEPTIONAL、CANCELLED、INTERRUPTED。

而我們主要關心 NORMAL、EXCEPTIONAL。

所以再回頭看看 get 方法:

如果當前狀態是小於 COMPLEING 的。

也就是當前狀態只能是 NEW 或者 COMPLEING,總之就是任務還沒有完成。所以進入 awaitDone 方法。這個方法不是本文關心的地方,接著往下看。

程式能往下走,說明當前的狀態肯定是下面圈起來的狀態中的某一個:

記住這幾種狀態,然後看這個 report 方法:

這個方法是幹啥的?

註解說的很清楚了:對於已經完成了的 task,返回其結果或者丟擲異常。

這裡面的邏輯就很簡單了,把 outcome 變數賦值給 x 。

然後判斷當前狀態,如果是 NORMAL,即 2,說明正常完成,直接返回 x。

如果是大於等於 CANCELLED,即大於等於 4 ,即這幾種狀態,就丟擲 CancellationException。

剩下的情況就丟擲 ExecutionException。

而這個“剩下的情況”是什麼情況?

不就只剩下一個 EXCEPTIONAL 的情況了。

所以,經過前面的描述,我們可以總結一下。

當 FutureTask 的 status 為 NORMAL 時正常返回結果,當 status 為 EXCEPTIONAL 時丟擲異常。

而當終態為 NORMAL 或者 EXCEPTIONAL 時,按照註釋描述,狀態的流程只能是這樣的:

那麼到底是不是這樣的呢?

這就需要我們去執行緒池裡面驗證一下了。

尋找答案-執行緒池

先回答上一節的一個問題:我怎麼知道是看 Future 這個介面的 FutureTask 這個實現類的:

submit 方法提交的時候把任務包裹了一層,就是用 FutureTask 包裹的:

可以看到,FutureTask 的構造方法裡面預設了狀態為 NEW。

然後直接在 runWorker 方法的 task.run 方法處打上斷點:

這個 task 是一個 FutureTask,所以 run 方法其實是 FutureTask 的 run 方法。

跟著斷點進去之後,就是 FutureTask 的 run 方法:

答案都藏在這個方法裡面。

java.util.concurrent.FutureTask#run

標號為 ① 的地方是執行我們的任務,call 的就是示例程式碼裡面的 sayHi 方法。

如果 sayHi 方法沒有捕獲執行時異常,則會在標號為 ② 的這個 catch 裡面被捕獲。然後執行標號為 ② 的這個程式碼。

如果 sayHi 方法捕獲了執行時異常,則會進入標號為 ③ 的這個邏輯裡面。

我們分別看一下標號為 ② 和 ③ 的邏輯:

首先,兩個方法都是先進行一個 cas 的操作,把當前 FutureTask 的 status 欄位從 NEW 修改為 COMPLETING 。

完成了狀態流轉的這一步:

注意這裡,如果 cas 操作失敗了,則不會進行任何操作。

cas 操作失敗了,說明什麼呢?

說明當前的狀態是 CANCELLED 或者 INTERRUPTING 或者INTERRUPTED。

也就是這個任務被取消了或者被中斷了。

那還設定結果乾啥,沒有任何卵用,對不對。

如果 cas 操作成功,接著往下看,可以看到雖然入參不一樣了,但是都賦給了 outcome 變數,這個變數,在上一節的 report 方法出現過,還記得嗎?能不能呼應上?

接下來就是狀態接著往下流轉。

set 方法表示正常結束,狀態流轉到 NORMAL。

setException 方法表示任務出現異常,狀態流轉到 EXCEPTIONAL。

所以經過 FutureTask 的 run 方法後,如果任務沒有被中斷或者取消,則會通過 setException 或者 set 方法完成狀態的流轉和 outcome 引數的設定:

而到底是呼叫 setException 方法還是 set 方法,取決於標號為 ① 的地方是否會丟擲異常。

即取決於任務體是否會丟擲異常。

假設 sayHi 方法是這樣的,會丟擲執行時異常:

而通過 submit 方法提交任務時寫法分別如下:

如果是標號為 ① 的寫法,則會進入 setException 方法。

如果是標號為 ② 的寫法,則會進入 set 方法。

所以,你現在再回去看看這個題目:

當執行方法是 submit 的時候,如果子執行緒丟擲未經捕獲的執行時異常,將會被封裝到 Future 裡面,那麼如果子執行緒捕獲了異常,該異常還會封裝到 Future 裡面嗎?是怎麼實現的呢?

現在是不是很清晰了。

如果子執行緒捕獲了異常,該異常不會被封裝到 Future 裡面。是通過 FutureTask 的 run 方法裡面的 setException 和 set 方法實現的。在這兩個方法裡面完成了 FutureTask 裡面的 outcome 變數的設定,同時完成了從 NEW 到 NORMAL 或者 EXCEPTIONAL 狀態的流轉。

執行緒池拒絕異常

寫文章的時候我突然又想到一個問題。

不論是用 submit 還是 execute 方法往執行緒池裡面提交任務,如果由於執行緒池滿了,導致丟擲拒絕異常呢?

RejectedExecutionException 異常也是一個 RuntimeException:

那麼對於這個異常,如果我們不在子執行緒捕獲,是不是也不會列印呢?

假設你不知道這個問題,你就分析一下,從會和不會中猜一個唄。

我猜是會列印的。

因為假設讓我來提供一個這樣的功能,由於執行緒池飽和了而拒絕了新任務的提交,我肯定得給使用方一個提示。告訴他有的任務由於執行緒池滿了而沒有提交進去。

不然,使用者自己排查到這個問題後,肯定會說一聲:這什麼傻逼玩意,把異常給吞了?

來,搞個 Demo 驗證一下:

我們定義的這個執行緒池最大容量是 7 個任務。

在迴圈體中扔 10 個比較耗時的任務進去。有 3 個任務它處理不了,那麼肯定是會觸發拒絕策略的。

你覺得這個程式執行後會在控制檯列印異常日誌嗎?會列印幾次呢?

看一下執行結果:

丟擲了一次異常,執行完成了 7 個任務。

我們並沒有捕獲異常,列印堆疊資訊的相關程式碼,那麼這個異常是誰列印的?

如果你沒有捕獲異常,JVM 會幫你呼叫這個方法:

而這個方法裡面,會輸出錯誤堆疊:

所以,當我們沒有捕獲異常的時候,會在這裡列印一次堆疊日誌。

而當我們捕獲了異常之後,改成這樣:

再次執行:

10 個任務,三次異常,完成了 7 個任務。

也不會讓 JVM 觸發 dispatchUncaughtException 方法了。

終極答案

上面說這個例子,其實我就是想引出終極答案。

終極答案就是:dispatchUncaughtException 方法。

為什麼這樣說呢?

我們現在把情況分為三種。

第一種:submit 方法提交一個會丟擲執行時異常的任務,捕不捕獲異常都可以。

第二種:execute 方法提交一個會丟擲執行時異常的任務,不捕獲異常。

第三種:submit 或者 execute 提交,讓執行緒池飽和之後丟擲拒絕異常,程式碼沒有捕獲異常。

第一種情況,無論如何都不會觸發 dispatchUncaughtException 方法。因為 submit 方法提交,不論你捕獲與否,原始碼裡面都幫你捕獲了:

第二種情況,如果不捕獲異常,會觸發 dispatchUncaughtException 方法,因為 runWorker 方法的原始碼裡面雖然捕獲了異常,但是又丟擲去了:

而我們自己沒有捕獲,所以會觸發 dispatchUncaughtException 方法。

第三種情況,和第二種其實是一樣的。沒有捕獲,就會觸發。

那麼我現在給你一段這樣的程式碼:

你肯定知道這是會丟擲異常的吧。

就像這樣式兒的:

我們完全沒有列印日誌的程式碼吧?

那你現在知道控制檯這個異常資訊是怎麼來的了不?

是不是平時根本就沒有注意這個點。

最後說一句(求關注)

還記得我之前這篇文章中的一個對話截圖嗎:

寫這篇文章的時候我又問了她近況,她已經不在乎這些技術問題了,因為她從程式媛搖身一變,變成了產品汪。從需求實現方變成了需求輸出方,真是一個華麗的轉身啊:

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在留言區提出來,我對其加以修改。 感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

相關文章