五年前,我寫錯了一道面試題。

why技术發表於2024-04-22

你好呀,我是歪歪。

事情是這樣的,上週有個讀者找我,給我丟擲了這樣的一個問題:

問題中涉及到的文章分別是這兩篇:

  • 《有的執行緒它死了,於是它變成一道面試題》 --- why技術
  • 《執行緒池中執行緒異常後:銷燬還是複用?》 --- 京東技術

我自己寫的這篇文章,雖然是五年前,2019 年的文章:

(臥槽,2019 年已經是五年前了)

但是畢竟是自己一個字一個字敲出來的,大概內容還是記得。

主要就是討論了我在面試的時候遇到的這個問題:

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

當時我的回答是這樣的:

在文章裡面,我把我的回答總結成了三句話:

  • 1.丟擲堆疊異常 ---這句話對了一半!
  • 2.不影響其他執行緒任務 ---這句話全對!
  • 3.這個執行緒會被放回執行緒池---這句話全錯!

然後我的文章就基於上面這三句話展開了。

過程就不再贅述了,這次只討論我五年前的文章中說錯的一個點:這個(異常的)執行緒會被放回執行緒池。

當時我的結論是這句話全錯了,正確的描述應該是:

(當一個執行緒池裡面的執行緒異常後,)執行緒池會把這個執行緒移除掉,並建立一個新的執行緒放到執行緒池中。

對於同樣的問題,京東技術的結論是這樣的:

  • 當執行方式是 execute 時,可以看到堆疊異常的輸出,執行緒池會把這個執行緒移除掉,並建立一個新的執行緒放到執行緒池中。
  • 當執行方式是 submit 時,堆疊異常沒有輸出。但是呼叫 Future.get() 方法時,可以捕獲到異常,不會把這個執行緒移除掉,也不會建立新的執行緒放入到執行緒池中。

歪師傅的結論是一概而論,京東技術則是分情況討論。

首先,京東技術的結論是正確的。

其次,歪師傅當年寫這個文章的時候,就是技不如人,就是寫錯了,就是情況沒有分析完整。

只看了 execute 的情況,導致得出了一個“只對了一半的答案”。

而關於使用 submit 方法時,如果線上程中丟擲了異常,為什麼不建立新的執行緒,而是繼續複用原執行緒的原因,京東技術也從原始碼的角度解析了。

歪師傅這裡也贅述一下。

問題的關鍵就是要抓到關鍵的問題。

那麼在這個問題中,關鍵的問題是什麼?

就是移除執行緒的方法在哪兒。

對應到原始碼其實就是這裡:

java.util.concurrent.ThreadPoolExecutor#processWorkerExit

那麼其實關鍵點就是這個方法在哪兒,在什麼情況下會被呼叫到?

對應的原始碼在這裡:

java.util.concurrent.ThreadPoolExecutor#runWorker

透過原始碼我們可以知道,在丟擲異常的情況下,該方法會被呼叫到。

而 try 部分就只有一行程式碼:

task.run();

那麼能耍花招的地方就只能是 task 這個物件了。

比如這樣的程式碼,當 execute 方法執行的時候,這就是一個原生的 Thread 執行緒:

該方法是否會丟擲異常,取決於你程式碼是否會丟擲異常。

比如這樣去寫,執行緒執行 sayHi 方法的時候就會丟擲異常:

而這樣去寫,則不會丟擲異常:

所以,你再去看京東技術的結論:

execute 提交到執行緒池的方式,如果執行中丟擲異常,並且沒有在執行邏輯中 catch,那麼會丟擲異常,並且移除丟擲異常的執行緒,建立新的執行緒放入到執行緒池中。

特別提到了 catch。

但是 submit 的時候,是怎麼回事呢?

task 從一個普通執行緒變成了 FutureTask 物件:

因為原始碼在這裡玩個了個小花招:

java.util.concurrent.AbstractExecutorService#submit(java.lang.Runnable)

把 task 包裝成了 FutureTask 物件。

而一切的秘密就藏在 FutureTask 物件的 run 方法中:

java.util.concurrent.FutureTask#run

異常之後,會呼叫 setException 方法,僅僅是把異常放在了 outcome 欄位中,然後維護了 FutureTask 的狀態,不會繼續往外丟擲異常。

如果需要獲取異常,則需要呼叫 get 方法。

好,現在我要開始閉環了。

因為 submit 提交的時候會把任務封裝為 FutureTask 物件,該物件重寫了 run 方法,所以當任務異常之後,不會繼續往外丟擲異常。

因為不會繼續往外丟擲異常,所以不會走到 processWorkerExit 方法。

因為不會走到 processWorkerExit 方法,所以不涉及移除執行緒和新增執行緒的邏輯。

所以:

當執行方式是 submit 時,不會把這個執行緒移除掉,也不會建立新的執行緒放入到執行緒池中。

其實整體邏輯還是很清楚的,當年就是分析漏了 submit 的情況,導致最終的結論不對。

五年前我挖了個坑,五年後,我把這個坑填一下。

然後再回答一個京東技術那篇文章下留言區的一個問題:

execute 執行無論是否丟擲異常,finally 塊中程式碼不是都會執行嗎?

也就是這段程式碼:

如果你只看這部分 try 和 finally 程式碼塊,我們學習 Java 的時候,如果老師沒有騙我們的話,那麼不管是正常執行完成 try 裡面的程式碼,還是 try 裡面的程式碼丟擲異常, finally 程式碼塊的程式碼理論上都是會執行的。

是的,這一個知識點沒有任何毛病。

但是,你注意我是怎麼說的“不管是正常執行完成還是丟擲異常”。

丟擲異常我們前面已經分析了,提問者的疑問點在於“正常執行完成”為什麼不會執行 finally 程式碼塊裡面的 processWorkerExit 方法。

我的答案是:會。

但是,try 裡面要正常執行完成,也就是 while 迴圈要正常結束,所以你看看一眼迴圈條件中的這個部分,要返回 null 才滿足條件:

getTask 對應的原始碼是這樣的:

java.util.concurrent.ThreadPoolExecutor#getTask

在我們討論的場景下,執行緒是會阻塞在佇列的 poll 或者 take 方法這裡的。

如果是 take 方法就不說了,不會返回 null,在這裡死等。

如果是 poll 方法返回了 null,則說明該執行緒到了超時時間還未從佇列中獲取到任務。

這個時候該怎麼辦?

翻翻八股文看看,如果執行緒池設定了 allowCoreThreadTimeOut 為 true,針對核心執行緒,在指定時間內未獲取到任務或者非核心執行緒在指定時間內未獲取到任務的時候,執行緒池會怎麼處理?

是不是說的該銷燬了,該從執行緒池中移走了?

所以,才會走到 processWorkerExit 執行 workers.remove(w) 方法。

是不是感覺自己又能行了,知識點又串起來了。

一點思考

當讀者問我“是複用還是移除”這個問題的時候,我當時確實不知道答案。

但是我一點都不慌,因為我知道去哪裡找答案。

如果我真的需要想要知道答案的話,在不借助任何搜尋工具,僅僅給我原始碼的情況下,我應該很快就能得到一個準確的答案。

這一點自信的底氣是因為我確實較為深入的研究過這部分原始碼。

但是當時我沒有去尋找答案,結合我對於執行緒池的理解,我在思考另外一個問題:這重要嗎?

你仔細想一想,如果這個問題丟擲來之後你直接就是一頭霧水,或者說和我一樣知道去哪裡找答案,那麼這個問題的準確回答對你來說真的重要嗎?

不管是那種情況都不重要,一點都不重要。

因為不管是銷燬還是複用,它完全不影響你對於執行緒池的使用。

重要的是,在一頭霧水的情況下,自己去尋找問題的答案的這個過程。

你當然可以拿著關鍵字去網上搜,肯定能搜到答案,這是一個尋找的過程,不過是輕鬆一點,然後遺忘起來快一點。

你也可以帶著問題去翻原始碼,這也是一個尋找的過程,不過是難一點而已,記憶深刻一點。

如果覺得直接啃原始碼啃不動,那就結合網上的資料一起食用,這同樣是一個尋找的過程。

等你真的找到這個問題的標準答案的時候、等你進一步理解執行緒池的時候,你會發現這個問題的答案不重要,但是在尋找的過程中你寫的 Demo、接觸到的原始碼、方法之間的呼叫關係、分支判斷邏輯、查閱到的資料、付出的時間和對應的收穫、甚至是內心中轉瞬即逝的開心...

這些是重要的。

這個題其實是一個陷阱。

就像是我們讀書的時候做的數學題,我們都知道參考答案就在練習冊的最後幾頁,照著參考答案抄就能回答正確。

但是我們都知道比起正確答案來說,更重要的是你知道解題的過程。

最可怕的情況是你抄答案的次數多了,對自己產生了錯誤的認知,讓你在抄答案的過程中還產生了這題很簡單,自己也會做的錯覺。

只有見過了無數千奇百怪的題目,摸熟了無數個解題的套路,當你在這個過程中,在某個瞬間體會到了“萬變不離其宗”的時候,在自信心經歷過建立、崩塌、再建立的過程後,在把參考答案真的只是當做參考的時候,你就可以淡定的說出:哦,這題啊,我沒見過,但是我知道怎麼去做。

就像是五年前我拿到這個題的時候,我經過一番研究,還是答錯了。

五年後,再次遇到這個題的瞬間,我還是不知道答案,但是我的內心一點都不慌。

在學習程式設計的路上,這樣的“陷阱題”真的太多太多了,難的不是回答出你被背下的標準答案,難的是你知道標準答案是怎麼來的。

這就是我從“是複用還是移除”這個問題帶給我的思考。

我覺得我其實是在試圖給你闡述一種學習的方法,因為我也沒有悟透,所以總感覺有點詞不達意,但是我想要表述的都說完了,剩下的,我自己接著悟吧。

合訂本

翻了一下,我過往還是寫了很多執行緒相關的文章的。

都放在這裡,作為一個合訂版吧:

《有的執行緒它死了,於是它變成一道面試題》

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

《如何設定執行緒池引數?美團給出了一個讓面試官虎軀一震的回答。》

《填個坑!再談執行緒池動態調整那點事。》

《每天都在用,但你知道 Tomcat 的執行緒池有多努力嗎?》

《這個佇列的思路真的好,現在它是我簡歷上的亮點了。》

《雖然是我遇到的一個棘手的生產問題,但是我寫出來之後,就是你的了。》

《面試官:你給我說一下執行緒池裡面的幾把鎖。》

《Dubbo 2.7.5線上程模型上的最佳化》

《面試官問我知不知道非同步程式設計的Future。》

《面試官問我知不知道CompletionService?》

《1000 多個併發執行緒,10 臺機器,每臺機器 4 核,設計執行緒池大小。》

《要我說,多執行緒事務它必須就是個偽命題!》

《Doug Lea在J.U.C包裡面寫的BUG又被網友發現了。》

《“藉助同步”這個理念在 FutureTask 裡面的應用。》

《面試官:Java如何繫結執行緒到指定CPU上執行?》

《別問了,我真的不喜歡 @Asyn 這個註解!》

《看完JDK併發包原始碼的這個效能問題,我驚了!》

《什麼是高併發下的請求合併?》

《CompletableFuture 的那點事兒》

《看起來是執行緒池的BUG,但是我認為是原始碼設計不合理。》

《喜提JDK的BUG一枚!多執行緒的情況下請謹慎使用這個類的stream遍歷。》

《聽我一句勸,業務程式碼中,別用多執行緒。》

《面試官:一個 SpringBoot 專案能處理多少請求?(小心有坑)》

《執行緒池引數千萬不要這樣設定》

《刺激,執行緒池的一個BUG直接把CPU幹到100%了。》

《這裡有執行緒池、區域性變數、內部類、靜態巢狀類和一個莫得名堂的引用,哦,還有一個坑!》

《看到一個魔改執行緒池,面試素材加一!》

《面試官一個執行緒池問題把我問懵逼了。》

如果裡面的某一篇曾經幫助過你,安排一個一鍵三連就行了。

相關文章