1. 程式人生 > >如何設定執行緒池引數?美團給出了一個讓面試官虎軀一震的回答。

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

前言:曾經自詡對執行緒池瞭如指掌,不料看了美團的一篇技術文章後才知道原來執行緒池的引數還可以動態調節。

學藝不精,一邊留下了沒有技術的淚水,一邊站在美團這個巨人的肩上寫下此文,補充並記錄了自己的一點看法。

分享給大家,希望能對你有所幫助。

荒腔走板

大家好,我是 why,一個四川好男人。

今天本來應該是武漢馬拉松鳴槍起跑的日子,所以先荒腔走板說幾句馬拉松吧。

上面的圖是我跑 2019 年成都馬拉松的時候拍的,是一對雙胞胎陪著 80 歲的父親跑全程馬拉松。

圖片中的老人叫羅廣德,在他 75 歲之前的人生和其他的老人並無不同。

但是經過他兒子的影響,在 75 歲的時候開始接觸跑步的。一直就沒有停下腳步,世界六大馬拉松賽(紐約、倫敦、柏林、芝加哥、東京、波士頓)他已經完成了五個。

本來打算今年 4 月份站上波士頓馬拉松的賽道上,完成最後的挑戰。

完成之後,他就是世界華人這個年齡段裡第一個完成世界六大馬拉松賽的大滿貫跑者。

但是由於疫情的原因,波士頓馬拉松延期舉行了。但是沒有關係,我相信老爺子的執著,我也相信他會是第一人。

他說:“人生沒有太晚的開始,關鍵是要行動起來。現在的年輕朋友很多都缺乏鍛鍊,作息時間不好,我希望年輕人都行動起來,我 80 歲都能跑步,難道你們不能跑嗎?”

我之前說過,在賽道上你能看到很多有趣的、感動的畫面。我喜歡跑馬拉松,因為跑完之後總是能帶給我爆棚的正能量。

人生需要一場馬拉松,你可以遲到,但是你不能缺席。

好了,說迴文章。

經典面試題

這次的文章還是繞回了我寫的第三篇原創文章《有的執行緒它死了,於是它變成一道面試題》中留下的幾個問題:

哎,兜兜轉轉,走走停停。天道好輪迴,蒼天饒過誰?

在這篇文章中我主要回答上面丟擲的這個問題:你這幾個引數的值怎麼來的呀?

要回答這個問題,我們得先說說這幾個引數是什麼,請看截圖:

其實,官方的註釋寫的都非常明白了。你看文章的時一定要結合英文,因為英文是 Doug Lea(作者)他自己寫的,表達的是作者自己的準確的想法。

不要瞎猜好嗎?

1.corePoolSize:the number of threads to keep in the pool, even if they are idle, unless {@code allowCoreThreadTimeOut} is set

(核心執行緒數大小:不管它們建立以後是不是空閒的。執行緒池需要保持 corePoolSize 數量的執行緒,除非設定了 allowCoreThreadTimeOut。)

2.maximumPoolSize:the maximum number of threads to allow in the pool。

(最大執行緒數:執行緒池中最多允許建立 maximumPoolSize 個執行緒。)

3.keepAliveTime:when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating。

(存活時間:如果經過 keepAliveTime 時間後,超過核心執行緒數的執行緒還沒有接受到新的任務,那就回收。)

4.unit:the time unit for the {@code keepAliveTime} argument

(keepAliveTime 的時間單位。)

5.workQueue:the queue to use for holding tasks before they are executed. This queue will hold only the {@code Runnable} tasks submitted by the {@code execute} method。

(存放待執行任務的佇列:當提交的任務數超過核心執行緒數大小後,再提交的任務就存放在這裡。它僅僅用來存放被 execute 方法提交的 Runnable 任務。所以這裡就不要翻譯為工作隊列了,好嗎?不要自己給自己挖坑。)

6.threadFactory:the factory to use when the executor creates a new thread。

(執行緒工程:用來建立執行緒工廠。比如這裡面可以自定義執行緒名稱,當進行虛擬機器棧分析時,看著名字就知道這個執行緒是哪裡來的,不會懵逼。)

7.handler :the handler to use when execution is blocked because the thread bounds and queue capacities are reached。

(拒絕策略:當佇列裡面放滿了任務、最大執行緒數的執行緒都在工作時,這時繼續提交的任務執行緒池就處理不了,應該執行怎麼樣的拒絕策略。)

7 個引數介紹完了,我希望當面試官問你自定義執行緒池可以指定哪些引數的時候,你能回答的上來。

當然,不能死記硬背,這樣回答起來磕磕絆絆的,像是在背書。也最好別給我回答什麼:我給你舉個例子吧,就是一開始有多少多少工人....

沒必要,真的,直接回答每個引數的名稱和含義就行了,牛逼的話你就給我說英文也行,我也能聽懂。

這玩意大家都懂,又不抽象,你舉那例子幹啥?拖延時間嗎?

面試要求的是儘量精簡、準確的回答問題,不要讓面試官去你冗長的回答中提煉關鍵字。

一是面試官面試體驗不好。面試完了後,常常是面試者在強調自己的面試體驗。朋友,你多慮了,你面試體驗不好,回去一頓吐槽,叫你進入下一輪面試的時候,大部分人還不是腆著個臉就來了。面試官的體驗不好,那你是真的沒有下一輪了。

二是面試官面試都是有一定的時間限制的,有限的面試時間內,前面太囉嗦了,能問你的問題就少了。問的問題少了,面試官寫評分表的時候一想,我靠,還有好多問題沒問呢,也不知道這小子能不能回答上來,算了,就不進入下一輪了吧。

好了好了,一不下心又暴露了幾個面試小技巧,扯遠了,說回來。

上面的 7 個引數中,我們主要需要關心的引數是: corePoolSize、maximumPoolSize、workQueue(佇列長度)。

所以,文字主要討論這個問題:

當我們自定義執行緒池的時候 corePoolSize、maximumPoolSize、workQueue(佇列長度)該如何設定?

你以為我要給你講分 IO 密集型任務或者分 CPU 密集型任務?

不會的,說好的是讓面試官眼前一亮、虎軀一震、直呼牛皮的答案。不騙你。

美團騷操作

怎麼虎軀一震的呢?

因為我看到了美團技術團隊發表的一篇文章:《Java執行緒池實現原理及其在美團業務中的實踐》

第一次看到這篇文章的時候我真是眼前一亮,看到美團的這騷操作,我真是直呼牛皮。

(哎,還是自己見的太少了。)

這篇文章寫的很好,很全面,比如我之前說的執行緒執行流程,它配了一張圖,一圖勝千言:

阻塞佇列成員表,一覽無餘:

前面都是些基礎知識,文中的後半部分才丟擲了一個實際問題:

執行緒池使用面臨的核心的問題在於:執行緒池的引數並不好配置。


一方面執行緒池的執行機制不是很好理解,配置合理需要強依賴開發人員的個人經驗和知識;


另一方面,執行緒池執行的情況和任務型別相關性較大,IO密集型和CPU密集型的任務執行起來的情況差異非常大。


這導致業界並沒有一些成熟的經驗策略幫助開發人員參考。

美團給出的對應的解決方案是什麼呢?

執行緒池引數動態化。

儘管經過謹慎的評估,仍然不能夠保證一次計算出來合適的引數,那麼我們是否可以將修改執行緒池引數的成本降下來,這樣至少可以發生故障的時候可以快速調整從而縮短故障恢復的時間呢?


基於這個思考,我們是否可以將執行緒池的引數從程式碼中遷移到分散式配置中心上,實現執行緒池引數可動態配置和即時生效,執行緒池引數動態化前後的引數修改流程對比如下:

說實話看到這個圖的時候我想起之前也有這樣的想法的。

因為有一次我這邊有個專案裡面的定時任務用到了執行緒池,但是核心執行緒數和佇列長度都設定的比較大,某一次任務觸發後查出了大批資料,通過執行緒池提交任務,每個任務裡面都會呼叫下游服務,導致下游服務長時間的壓力過大,也沒有做限流,所以影響了其對外提供的其他功能。

於是我叫運維幫我在 Apollo(配置中心)調小了核心執行緒數,並且重啟了服務。

那一次我就在想,我們使用的是 Apollo 天然支援動態更新,那我能不能動態的修改執行緒池呢?

因為那個時候不知道一個構建好了的執行緒池,它的核心執行緒數和最大執行緒數是可以動態修改的。

所以最開始的想法是監聽到引數變化後,直接弄一個新的執行緒池把原來的給替換掉。

但這樣的問題是,偷天換日之後,原來的執行緒池裡面的任務我怎麼處理呢?

我不能等原來的執行緒池裡面的任務執行完成後再換,因為這個時候任務一定是源源不斷的過來的。

於是就卡在了這個地方。

說來慚愧,這塊原始碼我看過幾次,但還是差點火候,學藝不精,怨不得別人。

先勸退一波

為了不浪費你的時間,先檢測一下你是否有閱讀本文的基礎知識儲備:

首先,我們先自定義一個執行緒池:

拿著這個執行緒池,當這個執行緒池在正常工作的前提下,我先問你兩個問題:

1.如果這個執行緒池接受到了 30 個比較耗時的任務,這個時候執行緒池的狀態(或者說資料)是怎樣的?

2.在前面 30 個比較耗時的任務還沒執行完成的情況下,再來多少個任務會觸發拒絕策略?

其實這就是在問你執行緒池的執行流程了,簡單的說一下就是:

1.當接收到了 30 個比較耗時的任務時,10 個核心執行緒數都在工作,剩下的 20 個去佇列裡面排隊。這個時候和最大執行緒數是沒有關係的,所以和執行緒存活時間也就沒有關係。

2.其實你知道這個執行緒池最多能接受多少任務,你就知道這個題的答案是什麼了,上面的執行緒池中最多接受 1000(佇列長度) + 30(最大執行緒數) = 1030 個任務。所以當已經接收了30個任務的情況下,如果再來 1000 個比較耗時的任務,這個時候佇列也滿了,最大執行緒數的執行緒也都在工作,這個時候執行緒池滿載了。因此,在前面 30 個比較耗時的任務還沒執行完成的情況下,再來 1001 個任務,第 1001 個任務就會觸發執行緒池的拒絕策略了。

這兩個問題你得會,如果答不上來你也別往下看了,大概率看的一臉懵逼。

我建議你先給本文點個贊,接著去網上搜一下執行緒池執行流程的文章(其實美團的那篇文章也寫了執行流程),寫個 Demo 跑一下,摸清楚了,再來看這篇文章。

巨人肩膀

對於執行緒池引數到底如何設定的問題美團的那篇文章提供了一個很好的思路和解決方案,展現的是一個大而全的東西。

但是,對於實施起來的細節就沒有具體的展示了。

所以文字斗膽,站在巨人的肩膀上對細節處進行一些補充說明。

1.現有的解決方案的痛點。

2.動態更新的工作原理是什麼?

3.動態設定的注意點有哪些?

4.如何動態指定佇列長度?

5.這個過程中涉及到的面試題有哪些?

下面從這五點進行展開說明。

現有的解決方案的痛點。

現在市面上大多數的答案都是先區分執行緒池中的任務是 IO 密集型還是 CPU 密集型。

如果是 CPU 密集型的,可以把核心執行緒數設定為核心數+1。

為什麼要加一呢?

《Java併發程式設計實戰》一書中給出的原因是:即使當計算(CPU)密集型的執行緒偶爾由於頁缺失故障或者其他原因而暫停時,這個“額外”的執行緒也能確保 CPU 的時鐘週期不會被浪費。

看不懂是不是?沒關係我也看不懂。反正把它理解為一個備份的執行緒就行了。

這個地方還有個需要注意的小點就是,如果你的伺服器上部署的不止一個應用,你就得考慮其他的應用的執行緒池配置情況。

經過精密的計算,你咔一下設定為核心數,結果專案部署上去了,發現還有其他的應用在和你搶 CPU,你想想難不難受。

如果是包含 IO 操作的任務呢?這個才是我們關心的東西。

《Java併發程式設計實戰》一書中給出的計算方式是這樣的:

理想很豐滿,現實很骨感。

我之前有個系統就是按照這個公式算出來的引數去配置的。

結果效果並不好,甚至讓下游系統直呼受不了。

這個東西怎麼說呢,還是得記住,面試的時候有用。真實場景中只能得到一個參考值,基於這個參考值,再去進行調整。

我們再看一下美團的那篇文章調研的現有解決方案列表:

第一個就是我們上面說的,和實際業務場景有所偏離。

第二個設定為 2*CPU 核心數,有點像是把任務都當做 IO 密集型去處理了。而且一個專案裡面一般來說不止一個自定義執行緒池吧?比如有專門處理資料上送的執行緒池,有專門處理查詢請求的執行緒池,這樣去做一個簡單的執行緒隔離。但是如果都用這樣的引數配置的話,顯然是不合理的。

第三個不說了,理想狀態。流量是不可能這麼均衡的,就拿美團來說,下午3,4點的流量,能和 12 點左右午飯時的流量比嗎?

基於上面的這些解決方案的痛點,美團給出了動態化配置的解決方案。

動態更新的工作原理是什麼?

先來一個動態更新的程式碼示例:

上面的程式就是自定義了一個核心執行緒數為 2,最大執行緒數為 5,佇列長度為 10 的執行緒池。

然後給它塞 15 個耗時 10 秒的任務,直接讓它 5 個最大執行緒都在工作,佇列長度 10 個都塞滿。

當前的情況下,佇列裡面的 10 個,前 5 個在 10 秒後會被執行,後 5 個在 20 秒後會被執行。

再加上最大執行緒數正在執行的 5 個,15 個任務全部執行完全需要 3 個 10 秒即 30 秒的時間。

這個時候,如果我們把核心執行緒數和最大執行緒數都修改為 10。

那麼 10 個任務會直接被 10 個最大執行緒數接管,10 秒就會被處理完成。

剩下的 5 個任務會在 10 秒後被執行完成。

所以,15 個任務執行完成需要 2 個 10 秒即 20 秒的時間處理完成了。

看一下上面程式的列印日誌:

效果實現了,我先看一下原理是什麼。

先看 setCorePoolSize 方法:

這個方法在美團的文章中也說明了:

在執行期執行緒池使用方呼叫此方法設定corePoolSize之後,執行緒池會直接覆蓋原來的corePoolSize值,並且基於當前值和原始值的比較結果採取不同的處理策略。

對於當前值小於當前工作執行緒數的情況,說明有多餘的worker執行緒,此時會向當前idle的worker執行緒發起中斷請求以實現回收,多餘的worker在下次idel的時候也會被回收;

對於當前值大於原始值且當前佇列中有待執行任務,則執行緒池會建立新的worker執行緒來執行佇列任務,setCorePoolSize具體流程如下:

看了美團的那篇文章後,我又去看了 Spring 的 ThreadPoolTaskExecutor類 (就是對JDK ThreadPoolExecutor 的一層包裝,可以理解為裝飾者模式)的 setCorePoolSize 方法: 註釋上寫的清清楚楚,可以線上程池執行時修改該引數。

而且,你再品一品 JDK 的原始碼,其實原始碼也體現出了有修改的含義的,兩個值去做差值,只是第一次設定的時候原來的值為 0 而已。

哎,當時沒有細細研究,恨自己看原始碼的時候不仔細。

接著看 setMaximumPoolSize 原始碼:

這個地方就很簡單了,邏輯不太複雜。

1.首先是引數合法性校驗。

2.然後用傳遞進來的值,覆蓋原來的值。

3.判斷工作執行緒是否是大於最大執行緒數,如果大於,則對空閒執行緒發起中斷請求。

經過前面兩個方法的分析,我們知道了最大執行緒數和核心執行緒數可以動態調整。

動態設定的注意點有哪些?

調整的時候可能會出現核心執行緒數調整之後無效的情況,比如下面這種:

改變之前的核心執行緒數是 2,最大執行緒數為 5,我們動態修改核心執行緒數為 10。

但是從日誌還是可以看出,修改之後核心執行緒數確實變成了 10,但活躍執行緒數還是為 5。

而且我呼叫了 prestartCoreThread 方法,該方法見名知意,你也知道是啟動所有的核心執行緒數,所有不存線上程沒有建立的問題。

這是為什麼呢?

原始碼之下無祕密,我帶你去看一眼:

java.util.concurrent.ThreadPoolExecutor#getTask

在這個方法中我們可以看到,如果工作執行緒數大於最大執行緒數,則對工作執行緒數量進行減一操作,然後返回 null。

所以,這個地方的實際流程應該是: 建立新的工作執行緒 worker,然後工作執行緒數進行加一操作。 執行建立的工作執行緒 worker,開始獲取任務 task。 工作執行緒數量大於最大執行緒數,對工作執行緒數進行減一操作。 返回 null,即沒有獲取到 task。 清理該任務,流程結束。

這樣一加一減,所以真正在執行任務的工作執行緒數的數量一直沒有發生變化,也就是最大執行緒數。

怎麼解決這個問題呢?

答案已經呼之欲出啦。

設定核心執行緒數的時候,同時設定最大執行緒數即可。其實可以把二者設定為相同的值:

這樣,活動執行緒數就能正常提高了。

有的小夥伴就會問了:如果調整之後把活動執行緒數設定的值太大了,豈不是業務低峰期我們還需要人工把值調的小一點?

不存在的,還記得前面介紹 corePoolSize 引數的含義時的註解嗎:

當 allowCoreThreadTimeOut 引數設定為 true 的時候,核心執行緒在空閒了 keepAliveTime 的時間後也會被回收的,相當於執行緒池自動給你動態修改了。

如何動態指定佇列長度?

前面介紹了最大執行緒數和核心執行緒數的動態設定,但是你發現了嗎,並沒有設定佇列長度的 set 方法啊?

有的小機靈鬼說先獲取 Queue 物件出來再看一下呢?

還是沒有,這可咋整呢?

首先我們看一下為什麼沒有提供佇列長度的 set 方法呢:

因為佇列的 capacity 是被 final 修飾了呀。

但是美團的那篇文章明明說了,他們也支援佇列的動態調整呀:

可是沒有詳細說明,但是彆著急,接著看後面的內容可以發現他們有一個名字為 ResizableCapacityLinkedBlockIngQueue 的佇列:

很明顯,這是一個自定義隊列了。

我們也可以按照這個思路自定義一個佇列,讓其可以對 Capacity 引數進行修改即可。

操作起來也非常方便,把 LinkedBlockingQueue 貼上一份出來,修改個名字,然後把 Capacity 引數的 final 修飾符去掉,並提供其對應的 get/set 方法。

然後在程式裡面把原來的佇列換掉:

執行起來看看效果:

可以看到,佇列大小確實從 10 變成了 100,佇列使用度從 100% 降到了 9%。

我後來去看了美團的那篇文章下面的評論,有個評論是這樣的:

果然不出我所料。

這個過程中涉及到的面試題有哪些?

問題一:執行緒池被建立后里面有執行緒嗎?如果沒有的話,你知道有什麼方法對執行緒池進行預熱嗎?

執行緒池被建立後如果沒有任務過來,裡面是不會有執行緒的。如果需要預熱的話可以呼叫下面的兩個方法:

全部啟動:

僅啟動一個:

問題二:核心執行緒數會被回收嗎?需要什麼設定?

核心執行緒數預設是不會被回收的,如果需要回收核心執行緒數,需要呼叫下面的方法:

allowCoreThreadTimeOut 該值預設為 false。

最後說一句(求關注)

點個贊吧,周更很累的,不要白嫖我,需要一點正反饋。

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,由於本號沒有留言功能,還請你加我微信給我指出來,我對其加以修改。(我每篇技術文章都有這句話,我是認真的說的。)

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是why技術,一個不是大佬,但是喜歡分享,又暖又有料的四川好男人。

歡迎關注公眾號【why技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。