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

你好呀,我是歪歪。

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

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

我自己寫的這篇文章,雖然是五年前,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%了。》

《這裏有線程池、局部變量、內部類、靜態嵌套類和一個莫得名堂的引用,哦,還有一個坑!》

《看到一個魔改線程池,面試素材加一!》

《面試官一個線程池問題把我問懵逼了。》

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章