線程池參數千萬不要這樣設置,坑得我整篇文章都寫錯了,要注意!

你好呀,我是歪歪。

先給大家道個歉:

上週不是發佈了這篇文章嘛:《三個爛慫八股文,變成兩個場景題,打得我一臉懵逼。》

其中第一個關於線程池的場景,經過讀者提醒可能有問題,我又一次用盡渾身解數分析了一波,發現之前確實分析的不對。

這個案例真的是再一次深入的刷新了我對於線程池運行過程的認知。

而由於我之前寫過太多關於線程池的文章,對於線程池的運行過程太過於熟悉,基本熟悉到了源碼信手拈來的地步。

所以我再次分析的時候,一度曾懷疑這個問題現象可能是 JDK 的 BUG,在 JDK BUG 庫裏面翻了一圈也沒有發現有人提到過這個問題,我甚至想要發起這個問題。

最後陰差陽錯的,還是定位到了問題的原因是線程池使用方面的問題,而問題的原因,最終說起來,極其簡單,一點就透。

這一篇文章,歪師傅再次帶大家盤一下這個問題。

問題再現

先給大家上代碼:

這個問題最開始是一個讀者提出來,發給我的一個 Demo,這個代碼已經是我精簡過的了。

這個代碼運行起來會觸發線程池的拒絕策略:

重點看一下我們的線程池定義:

private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));

該線程池核心大小數和最大線程數都是 64,隊列長度爲 32,也就是說這個線程池同時能容納的任務數是 64+32=96。

但是從代碼可以看出,由於有 countDownLatch 的存在,可以確認 for 循環一次一定只會放 34 個任務進來。

JDK 線程池的運行原理,大家應該都是背的滾瓜爛熟了:先啓用核心線程,然後任務進隊列,如果隊列滿了,再啓用最大線程數。最大線程數也滿了,就觸發拒絕策略。

那麼按照我個人的理解,因爲我們的核心線程數就是 64 個,已經完全大於 34 個任務了,所以線程池完全可以喫下這 34 個任務。

完全沒有理由觸發拒絕策略啊?

所以,我在之前的文章中給出的結論是:

線程池裏面的任務執行完成了,核心線程就一定會釋放出來等着接受下一波循環的任務,但是不會立馬釋放出來。從釋放到就緒之間,有一個時間差的存在,導致線程池核心線程數不夠用,從而導致觸發拒絕策略。

老實說,這個結論從純理論的角度來說,是真的有可能的。所以我才寫了一篇文章去論證它。

而且我還通過重寫線程池的 afterExecute 方法,延長了“核心線程收尾的時間”來確保問題復現。

也確實復現了。

但是很遺憾,這個結論在這個案例中是錯誤的。

之前的文章說了:

“線程池兩個工作”和“主線程繼續往線程池裏面扔任務的動作”之間,沒有先後邏輯控制。

我的驗證方式是通過延長了“核心線程收尾的時間”來確保問題復現。

但是這裏有兩個條件,所以其實還有一個驗證方式:讓“主線程繼續往線程池裏面扔任務的動作”足夠的慢,讓線程池有足夠的事件去收尾,這樣問題就一定不會出現。

然而我忽略了這個驗證方式,一心只是想着復現問題。

所以,當讀者給我這樣的一個代碼片段的時候,我直接就是一整個愣住了:

他在主線程中睡了 2s,目的是爲了讓“主線程繼續往線程池裏面扔任務的動作”足夠的慢:

如果按照我之前的推測,那麼線程池是完全足夠時間讓線程就緒的。

我自己也進行了驗證,而且我甚至把時間拉長到 10s,這樣也確實是會觸發拒絕策略:

看到這個運行結果的時候,我本能上是抗拒的,因爲這一行代碼的加入,運行結果和我預測的完全相反,相當於直接推翻了我前面的結論。

但是歪師傅寫文章這麼多年了,還是見過一些大場面的。

於是迅速開始思考原因。

最開始我懷疑這裏面的 sleep 動作有問題,於是我直接改成了這樣,相當於模擬線程空跑一趟,什麼動作都沒有做:

但是還是會拋出異常。

然後我又開始懷疑 CountDownLatch,於是我直接去掉了相關的代碼,整個代碼變成了這樣:

public class MyTest {
    private static final ThreadPoolExecutor threadPoolExecutor =
            new ThreadPoolExecutor(64, 64,
                    0, TimeUnit.MINUTES,
                    new ArrayBlockingQueue<>(32));
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            Thread.sleep(100);
            for (int j = 0; j < 34; j++) {
                threadPoolExecutor.execute(() -> {
                    int a = 0;
                });
            }
            System.out.println("===============>  詳情任務 - 任務處理完成");
        }
        System.out.println("都執行完成了");
    }
}

這個代碼可以說已經非常簡單了,除了線程池之外,沒有其他的任何干擾項了。

但是,你直接粘過去跑,你會發現,還是會拋出異常:

核心線程數64,隊列長度 32,每次往線程池裏面扔 34 個任務,對應的任務完全沒有任何耗時操作。

這樣居然會觸發線程池的拒絕策略?

又想起了幾年前寫文章時由於 idea “bug”遇到的詭異問題,甚至懷疑起了是“質子作祟”。

不知道你看到這裏的時候有沒有看出什麼破綻,或者說新的思路。

反正我對着這份代碼盯了一整天,調試了無數次,線程池的問題是真的難以調試,而且是在線程數比較多,沒有排查思路的情況下,所以基本上沒有什麼進展。

峯迴路轉

事情的轉機出現在我實在沒有思路,然後開始重新覆盤整個問題的時候。

再次翻看和提出這個問題的讀者的聊天記錄,這句話引起了我的注意:

解決問題的辦法就是提高隊列的容量。

我也不知道爲什麼,反正也沒有思路,逮着個方向就順便看看吧。

於是我直接把隊列的長度從 32 提升到了 320:

程序立馬就正常了:

32 不行,320 就行。

那麼會不會存在一個臨界值 x,當隊列的長度小於 x 的時候,就會出問題,大於等於 x 的時候就一切正常呢?

按照這個思路,我用二分法,很快就定位到了這個 x= 34。

等於 34 啊,朋友,當時我都快興奮的跳起來了。

34 和我們 for 循環一次往線程池裏面扔的任務數是一樣的,這裏面一定是有內在聯繫的,雖然我現在還不知道是什麼,但是至少也有一條線索了。

然後我又在隊列的長度爲 33 和 34 之間反覆運行了很多次,確認在我的機器上運行, 33 的時候問題會必現,34 的時候程序就能正常完成。

基於這個現象,我得出了一個結論:隊列長度小於 for 循環中一次放進來的任務數的時候,就會觸發這個現象。

於是我一步步的多次調整參數,最終把參數修改爲了這樣:

線程池核心線程數還是 64,但是把隊列長度修改爲一,for 循環一次放兩個任務進來。目的是最小程度的減少干擾項,然後神奇的事情就出現。

我現在把這個線程池定義單獨拎出來:

來,你說,站在你的認知裏面,隔 100ms 往這個線程池中扔兩個任務進來。

會觸發線程池的拒絕策略嗎?

至少在我的認知裏面是不可能的。

但是,它真的觸發了:

而當我把核心線程數設置爲 63,最大線程數保持爲 64。或者核心線程數保持爲 64,最大線程數修改爲 65 時,其他代碼都不動,程序均能正常運行。

匪夷所思,太匪夷所思了。

看到這個現象的時候,我直接開始懷疑是 JDK 的 BUG,當核心線程數和最大線程數一致的時候可能會觸發,於是我用各種姿勢搜了一圈,然而並沒有什麼收穫。

同時我發現,當我保持核心線程數和最大線程數個數一致時,不管這個“個數”是 1 還是 100,都會觸發拒絕策略。

雖然不知道原因,但是經過我對各種參數進行的調整,目前我有兩個線索,只有當這兩個線索同時滿足的時候,就會觸發拒絕策略:

  1. 隊列長度小於 for 循環中一次放進來的任務數。
  2. 核心線程數和最大線程數個數一致。

雖然還是不知道具體的原因,但是我可以基於上面這兩個線索,把參數的值取小一點,把 Demo 再簡化一下,變成這樣:

核心線程數等於最大線程數,都是 2,隊列長度爲 1,按理說這個隊列最大可以容納 3 個任務運行,但是一次性扔 2 個任務進去,會觸發拒絕策略。

爲什麼?

我不知道,但是現在我有一個問題必現的 Demo,而且線程池裏面的線程並不多,調試起來會輕鬆很多。

調試一波

首先我還是懷疑線程池裏面的線程在下一次任務到來之前,沒有進入到就緒狀態。

也就是對應到 getTask 的這個部分:

java.util.concurrent.ThreadPoolExecutor#getTask

如果線程能運行到標號爲 ③ 的地方,那麼說明一定是就緒了,可以從隊列中獲取任務。

標號爲 ① 的地方又是一個死循環的寫法。會不會是在標號爲 ② 的這一坨代碼裏面,有什麼問題呢?

怎麼驗證呢?多線程場景下用 debug 還是很難定位到問題的。

我們可以用一種古老但有效的方法來進行驗證:打足夠多的日誌。

只要我在標號爲 ② 的地方,加入足夠多的日誌,就能幫助我分析代碼到底是怎麼運行的。

那麼問題就來了:這個是 JDK 的源碼,我怎麼去加日誌呢?

在我之前的這篇文章中提到過:《這篇文章關於一個源碼調試方法,短小精悍,簡單粗暴,但足夠好用。》

把源碼拷貝一份出來,原模原樣的放一份到自己的項目中即可。

就像是這樣:

爲了區分,我把類粘過來之後,僅僅是修改了一個名字。但是你會發現有些報錯的地方.

比如這裏有個類型不匹配:

一看,是執行拒絕策略的方法。

不影響我們主要流程,直接參考默認的拒絕策略,拋出異常就行了:

然後就是這些拒絕策略也在報錯,直接全部刪除就完事了:

最後,你把程序裏面的線程池換成你自己的,搞定:

現在,你就可以在 MyThreadPoolExecutor 隨便加代碼了:

通過控制檯可以看到這個地方並沒有在循環中多次循環,兩個線程直接都運行到了“開始從隊列中獲取任務”的地方:

也就是都運行到了這個方法:

java.util.concurrent.ArrayBlockingQueue#take

這個方法很關鍵,指出我前一篇文章有問題的讀者,也提到了這個方法:

我也想在這個 take 方法裏面加點日誌觀察一下,同理我也把代碼原模原樣的粘一份出來,作爲我的 MyArrayBlockingQueue,並替換線程池裏面的隊列:

因爲可以確定線程是直接運行到 take 方法了,所以爲了減少日誌輸出干擾,之前加的輸出語句全部清除。

然後在 take 裏面加這樣的輸出語句:

take 是消費者,對應的生產者在這個地方:

com.example.tomcatdemo.MyThreadPoolExecutor#execute

同理,我們在生產者這裏加幾行輸出:

最終程序運行起來可以看到這樣的日誌輸出:

線程池裏面兩個線程在等着隊列裏面來任務。

然後主線程在往隊列裏面提交任務。

相當於兩個消費者,一個生產者。生產者生產一個,消費者立馬就消費了。

這樣就不會有任何毛病。

但是,還能看到這樣的日誌輸出:

雖然兩個消費者都就緒了,但是主線程往隊列裏面放了任務之後,任務並沒有被及時消費,導致主線程放下一個任務的時候,隊列滿了。

對於線程池來說,隊列滿了意味着需要使用最大線程數了。

而在我們的案例裏面,最大線程數等於核心線程數。所以沒有線程拿來新增了,addWorker(command, false) 方法就會返回 false,所以觸發了拒絕策略:

好,現在我再拿着 Demo 給你捋一下啊:

首先線程池的運行邏輯是:先啓用核心線程,然後任務進隊列,如果隊列滿了,再啓用最大線程數。最大線程數也滿了,就觸發拒絕策略。

所以,當外層的第一次 for 循環的時候,提交的兩個任務會直接啓用最大線程數,和隊列沒有任何關係。

第二次 for 循環開始之後,提交的任務是先進隊列,然後線程從隊列裏面取數據消費。

如果隊列的長度只有 1,但是 for 循環一次要提交兩個任務的時候,能否放成功,取決於核心線程從隊列中拿(take)任務的動作,和主線程往隊列裏面放(offer)任務的動作,這兩個動作之間的先後順序。

如果核心線程先從隊列中拿到任務,那麼隊列又有空間了,主線程可以繼續往隊列裏面放任務,程序一切正常。

如果主線程往隊列裏面放任務的動作很快,放完第一個後,還沒被消費,立馬就開始放第二個,那麼隊列滿了,即使我們知道,核心線程其實是在空閒狀態,但是按照線程池的邏輯,會去開啓最大線程數,發現最大線程數也沒有了,所以觸發了拒絕策略。

這個時候,你再回去看我們的“兩個線索”的時候,你就明白過來是怎麼回事了:

  1. 隊列長度小於 for 循環中一次放進來的任務數。
  2. 核心線程數和最大線程數個數一致。

背後的邏輯,就這麼簡單,可以說是一點就透。

你看到這裏,可能只花了五分鐘時間。

但是當我定位到這個原因的時候,距離讀者提出問題,已經過去了差不多三天時間,這期間,我走了很多彎路。

你看到的,是衆多彎路中,唯一正確的一條路線。

而這一切的原因都在於我先入爲主的認爲,核心線程數大於提交的任務數,所以任務一定能找到對應的線程來進行處理,疏忽了任務是要先進隊列的。

驗證一波

我們還是簡單驗證一把。

在我們的場景下,隊列長度爲 1,每次放兩個任務進來。

既然現在的核心問題在於 offer 和 take 這兩個動作的先後順序上。

如果核心線程的 take 動作,先於主線程第二次 offer 的動作,那麼隊列有空間,就不會觸發拒絕策略。

爲了驗證這一點,我們需要在 offer 裏面加點睡眠時間,拖慢它的處理速度:

也就是這樣,在 offer 方法裏面,往隊列裏面放任務的時候,睡一下:

按照我們前面的推理,這樣理論上可以達到主線程 offer 一個進去,核心線程就 take 一個出去的效果,程序一定就會正常運行結束。

對不對?

對個頭,不對啊!

你運行起來還是會拋出異常:

爲什麼,是我們又分析錯了嗎?

分析沒錯,只是臨門一腳的時候,睡的地方不對。

你來看看這是一個什麼寶貝:

offer 和 take 方法都要拿到鎖之後才能進行入隊、出隊的動作。

所以睡一秒的動作,應該發在釋放鎖之後,否則主線程抱着着鎖睡,核心線程只有乾着急了:

這樣,程序一定能正常運行結束。

同時,吸取了前一篇文章的教訓,另外一個方向我也需要驗證一下:

在 take 釋放鎖之後也睡一秒,模擬 take 操作慢,offer 塞滿隊列的情況。

這個情況,按照我們前面的分析,一定就會拋出異常:

至此,問題得到解決。

通過這次問題排除,也讓我對於線程池參數的設置有了新的認知。

儘量不要把線程池的核心線程數和最大線程數設置的一樣,把阻塞隊列的長度設置得大一些,至少保證阻塞隊列本身的長度大於一次提交進來的任務數,而不要做出線程數加上隊列長度才勉強容納單批次任務數,這麼極端的長度參數。

另外,我也突然想到了線程池的 newFixedThreadPool 方法,不就是核心線程數等於最大線程數嗎,它怎麼沒有問題呢?

看一下源碼:

人家的隊列用的是無參的 LinkedBlockingQueue,隊列長度是 Integer.MAX_VALUE,當然不會有問題了。

另外,線程池裏面還有這樣的一個方法 newCachedThreadPool:

把核心線程數設置爲 0,最大線程數放的無線大,超過 60s 空閒則回收線程,通過這個方式防止線程膨脹。

但是我的關注點其實在於它的隊列,用的是 SynchronousQueue。

這個隊列很有意思,它的工作過程是放一個進去之後,必須要拿走,才能放下一個。你可以理解它是一個通道,不存儲任何元素,只是負責傳遞數據,它的隊列長度是 0。

所以回到我們的場景中,如果我們的隊列用的是它:

也不會觸發到拒絕策略,程序也能正常運行結束。

現在我們知道的問題的原因,站在純技術的角度,我們有非常多的方法來規避這個問題。但是具體怎麼使用,還是得結合業務場景來看。

回顧

左邊是最開始的代碼,右邊是最後定位問題的代碼:

從左邊到右邊,我寫了兩篇文章,付出了很多的時間,經過了無數次的調試,一直在思維定時裏面沒有走出來,所以走了很多的彎路。

其實回顧整個問題的原因,一句話就能說清楚:

一次性提交的任務數量大於隊列長度就有可能會觸發。因爲線程池核心線程都啓動之後,任務提交都是先進隊列。當你把最大線程數設置等於核心線程數時,根本就沒有最大線程數可以用,所以會觸發拒絕策略。當你把最大線程數設置大於核心線程數時,在最大線程數用完了的情況下,會觸發拒絕策略。

但是,朋友,其實原因一點都不重要,當然定位到原因的時候我其實挺開心的。

我開心並不是因爲找到了問題的原因,而是我覺得我在這個過程中付出的時間和無數次的調試,包括在這個過程中走過的所有彎路都是有意義的。

我寫這篇文章是因爲有讀者讀了我前一篇文章,發現有問題,告訴了我,讓我有機會知道自己分析的有問題。

我寫下這篇文章來記錄找到問題的過程並分享出去,告訴大家我前一篇文章寫的不對。

找問題的過程、方式和思考比最終的結論重要的多。這是一個相互學習,共同進步的過程,這比找到問題的原因,讓我覺得更加有意義。

解決問題不厲害,因爲當一個問題提出來的時候,它就已經被解決了。厲害的是帶着懷疑的態度去看文章,結合自己的思考,然後提出問題。

帶着質疑的眼光看代碼,帶着求真的態度去探索,與君共勉之。

好啦,本文的技術部分就到這裏了。

下面這個環節叫做[荒腔走板],技術文章後面我偶爾會記錄、分享點生活相關的事情,和技術毫無關係。我知道看起來很突兀,但是我喜歡,因爲這是一個普通博主的生活氣息。

荒腔走板

成都有個地方叫做崇州,崇州有個景點叫做街子古鎮,街子古鎮在山腳,山上 5km 遠的地方有一個禪院,叫做嚴光禪院。

讀大學的時候我騎自行車去過一次,印象比較深刻,因爲盤山路,上山的路很陡,騎車很費勁,有些髮卡彎,得站起來騎。

街子古鎮人山人海,嚴光禪院香火不旺。

當年好不容易騎上去,就隨便再佛祖面前許了個願:希望 Max 同學能順利考上研究生。

後來我給她說起這個事情的時候,她問:那你後來去還願了沒?

我說坡太陡了,難得騎,就沒有再去過了。

這個週末和 Max 同學以練車的名義跑了一趟,許願的人帶着當年被許願的人一起來一趟,就當是還願了。

去的路上還特意拐到西財,吃了 Max 同學極力推薦的特色萬州烤魚,她說只是在讀書的時候喫到過這個味道。

我當時不以爲然,不就是萬州烤魚嗎,到處都有啊?吃了第一口之後才發現,確實是只有在溫江才能喫到的改良版的味道,好喫。

喫飽之後慢悠悠的往目的地開,山上溫度還是很低的,山上的雪還沒完全化掉。遊客也非常得少,站在山路上停下,沒有一點雜音,只能聽到蟲鳴鳥叫,還有雪化之後,從屋檐滴到水池裏面的聲音,唯一的不是大自然的聲音,只有偶然冒出的一聲僧人擊鉢的空靈而悠遠的聲音。

很多人都說買車之後生活半徑會擴大無數倍,提升生活質量,當時我不以爲意,現在看來,確實是至理名言。

久在樊籠裏,復得返自然。

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