你好呀,我是歪歪。
前幾天和一個大佬聊天的時候他說自己最近在做執行緒池的監控,剛剛把動態調整的功能開發完成。
想起我之前寫過這方面的文章,就找出來看了一下:《如何設定執行緒池引數?美團給出了一個讓面試官虎軀一震的回答。》
然後給我指出了一個問題,我仔細思考了一下,好像確實是留了一個坑。
為了更好的描述這個坑,我先給大家回顧一下執行緒池動態調整的幾個關鍵點。
首先,為什麼需要對執行緒池的引數進行動態調整呢?
因為隨著業務的發展,有可能出現一個執行緒池開始夠用,但是漸漸的被塞滿的情況。
這樣就會導致後續提交過來的任務被拒絕。
沒有一勞永逸的配置方案,相關的引數應該是隨著系統的浮動而浮動的。
所以,我們可以對執行緒池進行多維度的監控,比如其中的一個維度就是佇列使用度的監控。
當佇列使用度超過 80% 的時候就傳送預警簡訊,提醒相應的負責人提高警惕,可以到對應的管理後臺頁面進行執行緒池引數的調整,防止出現任務被拒絕的情況。
以後有人問你執行緒池的各個引數怎麼配置的時候,你先把分為 IO 密集型和 CPU 密集型的這個八股文答案背完之後。
加上一個:但是,除了這些方案外,我在實際解決問題的時候用的是另外一套方案”。
然後把上面的話複述一遍。
那麼執行緒池可以修改的引數有哪些呢?
正常來說是可以調整核心執行緒數和最大執行緒數的。
執行緒池也直接提供了其對應的 set 方法:
但是其實還有一個關鍵引數也是需要調整的,那就是佇列的長度。
哦,對了,說明一下,本文預設使用的佇列是 LinkedBlockingQueue
。
其容量是 final 修飾的,也就是說指定之後就不能修改:
所以佇列的長度調整起來稍微要動點腦筋。
至於怎麼繞過 final 這個限制,等下就說,先先給大家上個程式碼。
我一般是不會貼大段的程式碼的,但是這次為什麼貼了呢?
因為我發現我之前的那篇文章就沒有貼,之前寫的程式碼也早就不知道去哪裡了。
所以,我又苦哈哈的敲了一遍...
import cn.hutool.core.thread.NamedThreadFactory;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadChangeDemo {
public static void main(String[] args) {
dynamicModifyExecutor();
}
private static ThreadPoolExecutor buildThreadPoolExecutor() {
return new ThreadPoolExecutor(2,
5,
60,
TimeUnit.SECONDS,
new ResizeableCapacityLinkedBlockingQueue<>(10),
new NamedThreadFactory("why技術", false));
}
private static void dynamicModifyExecutor() {
ThreadPoolExecutor executor = buildThreadPoolExecutor();
for (int i = 0; i < 15; i++) {
executor.execute(() -> {
threadPoolStatus(executor,"建立任務");
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
threadPoolStatus(executor,"改變之前");
executor.setCorePoolSize(10);
executor.setMaximumPoolSize(10);
ResizeableCapacityLinkedBlockingQueue<Runnable> queue = (ResizeableCapacityLinkedBlockingQueue)executor.getQueue();
queue.setCapacity(100);
threadPoolStatus(executor,"改變之後");
}
/**
* 列印執行緒池狀態
*
* @param executor
* @param name
*/
private static void threadPoolStatus(ThreadPoolExecutor executor, String name) {
BlockingQueue<Runnable> queue = executor.getQueue();
System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
"核心執行緒數:" + executor.getCorePoolSize() +
" 活動執行緒數:" + executor.getActiveCount() +
" 最大執行緒數:" + executor.getMaximumPoolSize() +
" 執行緒池活躍度:" +
divide(executor.getActiveCount(), executor.getMaximumPoolSize()) +
" 任務完成數:" + executor.getCompletedTaskCount() +
" 佇列大小:" + (queue.size() + queue.remainingCapacity()) +
" 當前排隊執行緒數:" + queue.size() +
" 佇列剩餘大小:" + queue.remainingCapacity() +
" 佇列使用度:" + divide(queue.size(), queue.size() + queue.remainingCapacity()));
}
private static String divide(int num1, int num2) {
return String.format("%1.2f%%", Double.parseDouble(num1 + "") / Double.parseDouble(num2 + "") * 100);
}
}
當你把這個程式碼粘過去之後,你會發現你沒有 NamedThreadFactory
這個類。
沒有關係,我用的是 hutool 工具包裡面的,你要是沒有,可以自定義一個,也可以在建構函式裡面不傳,這不是重點,問題不大。
問題大的是 ResizeableCapacityLinkedBlockingQueue
這個玩意。
它是怎麼來的呢?
在之前的文章裡面提到過:
就是把 LinkedBlockingQueue 貼上一份出來,修改個名字,然後把 Capacity 引數的 final 修飾符去掉,並提供其對應的 get/set 方法。
感覺非常的簡單,就能實現 capacity 引數的動態變更。
但是,我當時寫的時候就感覺是有坑的。
畢竟這麼簡單的話,為什麼官方要把它給設計為 final 呢?
坑在哪裡?
關於 LinkedBlockingQueue
的工作原理就不在這裡說了,都是屬於必背八股文的內容。
主要說一下前面提到的場景中,如果我直接把 final 修飾符去掉,並提供其對應的 get/set 方法,這樣的做法坑在哪裡。
先說一下,如果沒有特殊說明,本文中的原始碼都是 JDK 8 版本。
我們看一下這個 put 方法:
主要看這個被框起來的部分。
while 條件裡面的 capacity 我們知道代表的是當前容量。
那麼 count.get 是個什麼玩意呢?
就是當前佇列裡面有多少個元素。
count.get == capacity 就是說佇列已經滿了,然後執行 notFull.await()
把當前的這個 put 操作掛起來。
來個簡單的例子驗證一下:
申請一個長度為 5 的佇列,然後在迴圈裡面呼叫 put 方法,當佇列滿了之後,程式就阻塞住了。
通過 dump 當前執行緒可以知道主執行緒確實是阻塞在了我們前面分析的地方:
所以,你想想。如果我把佇列的 capacity 修改為了另外的值,這地方會感知到嗎?
它感知不到啊,它在等著別人喚醒呢。
現在我們把佇列換成我修改後的佇列驗證一下。
下面驗證程式的思路就是在一個子執行緒中執行佇列的 put 操作,直到容量滿了,被阻塞。
然後主執行緒把容量修改為 100。
上面的程式其實我想要達到的效果是當容量擴大之後,子執行緒不應該繼續阻塞。
但是經過前面的分析,我們知道這裡並不會去喚醒子執行緒。
所以,輸出結果是這樣的:
子執行緒還是阻塞著,所以並沒有達到預期。
所以這個時候我們應該怎麼辦呢?
當然是去主動喚醒一下啦。
也就是修改一下 setCapacity 的邏輯:
public void setCapacity(int capacity) {
final int oldCapacity = this.capacity;
this.capacity = capacity;
final int size = count.get();
if (capacity > size && size >= oldCapacity) {
signalNotFull();
}
}
核心邏輯就是發現如果容量擴大了,那麼就呼叫一下 signalNotFull
方法:
喚醒一下被 park 起來的執行緒。
如果看到這裡你覺得你有點懵,不知道 LinkedBlockingQueue 的這幾個玩意是幹啥的:
趕緊去花一小時時間補充一下 LinkedBlockingQueue 相關的知識點。這樣玩意,面試也經常考的。
好了,我們說回來。
修改完我們自定義的 setCapacity 方法後,再次執行程式,就出現了我們預期的輸出:
除了改 setCapacity 方法之外,我在寫文章的時候不經意間還觸發了另外一個答案:
在呼叫完 setCapacity 方法之後,再次呼叫 put 方法,也能得到預期的輸出:
我們觀察 put 方法就能發現其實道理是一樣的:
當呼叫完 setCapacity 方法之後,再次呼叫 put 方法,由於不滿足標號為 ① 的程式碼的條件,所以就不會被阻塞。
於是可以順利走到標號為 ② 的地方喚醒被阻塞的執行緒。
所以也就變相的達到了改變佇列長度,喚醒被阻塞的任務目的。
而究根結底,就是需要執行一次喚醒的操作。
那麼那一種優雅一點呢?
那肯定是第一種把邏輯封裝在 setCapacity 方法裡面操作起來更加優雅。
第二種方式,大多適用於那種“你也不知道為什麼,反正這樣寫程式就是正常了”的情況。
現在我們知道線上程池裡面動態調整佇列長度的坑是什麼了。
那就是佇列滿了之後,呼叫 put 方法的執行緒就會被阻塞住,即使此時另外的執行緒呼叫了 setCapacity 方法,改變了佇列長度,如果沒有執行緒再次觸發 put 操作,被阻塞的執行緒也不會被喚醒。
是不是?
了不瞭解?
對不對?
這是不對的,朋友們。
看到前面內容,頻頻點頭的朋友,要注意了。
這地方要開始轉彎了。
開始轉彎
執行緒池裡面往佇列裡面新增物件的時候,用的是 offer 命令,並沒有用 put 命令:
我們看看 offer 命令在幹啥事兒:
佇列滿了之後,直接返回 false,不會出現阻塞的情況。
也就是說,執行緒池中根本就不會出現我前面說的需要喚醒的情況,因為根本就沒有阻塞中的執行緒。
在和大佬交流的過程中,他提到了一個 VariableLinkedBlockingQueue
的東西。
這個類位於 MQ 包裡面,我前面提到的 setCapacity 方法的修改方式就是在它這裡學來的:
同時,專案裡面也用到了它的 put 方法:
所以,它是有可能出現我們前面分析的情況,有需要被喚醒的執行緒。
但是,你想想,執行緒池裡面並沒有使用 put 方法,是不是就剛好避免這樣的情況?
是的,確實是。
但是,不夠嚴謹,如果知道有問題了的話,為什麼要留個坑在這裡呢?
你學 MQ 的 VariableLinkedBlockingQueue 考慮的周全一點,就算 put 方法阻塞的時候也能用,它不香嗎?
寫到這裡其實好像除了讓你熟悉一下 LinkedBlockingQueue 外,似乎是一個沒啥卵用的知識點,
但是,我能讓這個沒有卵用的知識點起到大作用。
因為這其實是一個小細節。
假設我出去面試,在面試的時候提到動態調整方法的時候,在不經意間拿捏一下這個小細節,即使我沒有真的落地過動態調整,但是我提到這樣的一個小細節,就顯得很真實。
面試官一聽:很不錯,有整體,有區域性,應該是假不了。
在 VariableLinkedBlockingQueue 裡面還有幾處細節,拿 put 方法來說:
判斷條件從 count.get() >= capacity
變成了 count.get() = capacity
,目的是為了支援 capacity 由大變小的場景。
這樣的地方還有好幾處,就不一一列舉了。
魔鬼,都在細節裡面。
同學們得好好的拿捏一下。
JDK bug
其實原計劃寫到前面,就打算收尾了,因為我本來就只是想補充一下我之前沒有注意到的細節。
但是,我手賤,跑到 JDK bug 列表裡面去搜尋了一下 LinkedBlockingQueue,想看看還有沒有什麼其他的收穫。
我是萬萬沒想到,確實是有一點意外收穫的。
首先是這一個 bug ,它是在 2019-12-29 被提出來的:
https://bugs.openjdk.java.net...
看標題的意思也是想要給 LinkedBlockingQueue 賦能,可以讓它的容量進行修改。
加上他下面的場景描述,應該也想要和執行緒池配合,找到佇列的抓手,下鑽到底層邏輯,聯動監控系統,拉通配置頁面,打出一套動態適應的組合拳。
但是官方並沒有採納這個建議。
回覆裡面說寫 concurrent 包的這些哥們對於在併發類裡面加東西是非常謹慎的。他們覺得給 ThreadPoolExecutor 提供可動態修改的特性會帶來或者已經帶來眾多的 bug 了。
我理解就是簡單一句話:建議還是不錯的,但是我不敢動。併發這塊,牽一髮動全身,不知道會出些什麼么蛾子。
所以要實現這個功能,還是得自己想辦法。
這裡也就解釋了為什麼用 final 去修飾了佇列的容量,畢竟把功能縮減一下,出現 bug 的機率也少了很多。
第二個 bug 就有意思了,和我們動態調整執行緒池的需求非常匹配:
https://bugs.openjdk.java.net...
這是一個 2020 年 3 月份提出的 bug,描述的是說在更新執行緒池的核心執行緒數的時候,會丟擲一個拒絕異常。
在 bug 描述的那部分他貼了很多程式碼,但是他給的程式碼寫的很複雜,不太好理解。
好在 Martin 大佬寫了一個簡化版,一目瞭然,就好理解的多:
這段程式碼是幹了個啥事兒呢,簡單給大家彙報一下。
首先 main 方法裡面有個迴圈,迴圈裡面是呼叫了 test 方法,當 test 方法丟擲異常的時候迴圈結束。
然後 test 方法裡面是每次都搞一個新的執行緒池,接著往執行緒池裡面提交佇列長度加最大執行緒數個任務,最後關閉這個執行緒池。
同時還有另外一個執行緒把執行緒池的核心執行緒數從 1 修改為 5。
你可以開啟前面提到的 bug 連結,把這段程式碼貼出來跑一下,非常的匪夷所思。
Martin 大佬他也認為這是一個 BUG.
說實在的,我跑了一下案例,我覺得這應該算是一個 bug,但是經過 Doug Lea 老爺子的親自認證,他並不覺得這是一個 Bug。
主要是這個 bug 確實也有點超出我的認知,而且在連結中並沒有明確的說具體原因是什麼,導致我定位的時間非常的長,甚至一度想要放棄。
但是最終定位到問題之後也是長嘆一口:害,就這?沒啥意思。
先看一下問題的表現是怎麼樣的:
上面的程式執行起來後,會丟擲 RejectedExecutionException,也就是執行緒池拒絕執行該任務。
但是我們前面分析了,for 迴圈的次數是執行緒池剛好能容納的任務數:
按理來說不應該有問題啊?
這也就是提問的哥們納悶的地方:
他說:我很費解啊,我提交的任務數量根本就不會超過 queueCapacity+maxThreads,為什麼執行緒池還丟擲了一個 RejectedExecutionException?而且這個問題非常的難以除錯,因為在任務中新增任何形式的延遲,這個問題都不會復現。
他的言外之意就是:這個問題非常的莫名其妙,但是我可以穩定復現,只是每次復現出現問題的時機都非常的隨機,我搞不定了,我覺得是一個 bug,你們幫忙看看吧。
我先不說我定位到的 Bug 的主要原因是啥吧。
先看看老爺子是怎麼說的:
老爺子的觀點簡單來說就是四個字:
老爺子說他沒有說服自己上面的這段程式應該被正常執行成功。
意思就是他覺得丟擲異常也是正常的事情。但是他沒有說為什麼。
一天之後,他又補了一句話:
我先給大家翻譯一下:
他說當執行緒池的 submit 方法和 setCorePoolSize 或者 prestartAllCoreThreads 同時存在,且在不同的執行緒中執行的時候,它們之間會有競爭的關係。
在新執行緒處於預啟動但還沒完全就緒接受佇列中的任務的時候,會有一個短暫的視窗。在這個視窗中佇列還是處於滿的狀態。
解決方案其實也很簡單,比如可以在 setCorePoolSize 方法中把預啟動執行緒的邏輯拿掉,但是如果是用 prestartAllCoreThreads 方法,那麼還是會出現前面的問題。
但是,不管是什麼情況吧,我還是不確定這是一個需要被修復的問題。
怎麼樣,老爺子的話看起來是不是很懵?
是的,這段話我最開始的時候讀了 10 遍,都是懵的,但是當我理解到這個問題出現的原因之後,我還是不得不感嘆一句:
還是老爺子總結到位,沒有一句廢話。
到底啥原因?
首先我們看一下示例程式碼裡面操作執行緒池的這兩個地方:
修改核心執行緒數的是一個執行緒,即 CompletableFuture 的預設執行緒池 ForkJoinPool 中的一個執行緒。
往執行緒池裡面提交任務是另外一個執行緒,即主執行緒。
老爺子的第一句話,說的就是這回事:
racing,就是開車,就是開快車,就是與...比賽的意思。
這是一個多執行緒的場景,主執行緒和 ForkJoinPool 中的執行緒正在 race,即可能出現誰先誰後的問題。
接著我們看看 setCorePoolSize 方法幹了啥事:
標號為 ① 的地方是計算新設定的核心執行緒數與原核心執行緒數之間的差值。
得出的差值,在標號為 ② 的地方進行使用。
也就是取差值和當前佇列中正在排隊的任務數中小的那一個。
比如當前的核心執行緒數配置就是 2,這個時候我要把它修改為 5。佇列裡面有 10 個任務在排隊。
那麼差值就是 5-2=3,即標號為 ① 處的 delta=3。
workQueue.size 就是正在排隊的那 10 個任務。
也就是 Math.min(3,10),所以標號為 ② 處的 k=3。
含義為需要新增 3 個核心執行緒數,去幫忙把排隊的任務給處理一下。
但是,你想新增 3 個就一定是對的嗎?
會不會在新增的過程中,佇列中的任務已經被處理完了,有可能根本就不需要 3 個這麼多了?
所以,迴圈終止的條件除了老老實實的迴圈 k 次外,還有什麼?
就是佇列為空的時候:
同時,你去看程式碼上面的那一大段註釋,你就知道,其實它描述的和我是一回事。
好,我們接著看 addWorker 裡面,我想要讓你看到地方:
在這個方法裡面經過一系列判斷後,會走入到 new Worker() 的邏輯,即工作執行緒。
然後把這個執行緒加入到 workers 裡面。
workers 就是一個存放工作執行緒的 HashSet 集合:
你看我框起來的這兩局程式碼,從 workers.add(w)
到 t.start()
。
從加入到集合到真正的啟動,中間還有一些邏輯。
執行中間的邏輯的這一小段時間,就是老爺子說的 “window”。
there's a window while new threads are in the process of being prestarted but not yet taking tasks。
就是在新執行緒處於預啟動,但尚未接受任務時,會有一個視窗。
這個視窗會發生啥事兒呢?
就是下面這句話:
the queue may remain (transiently) full。
佇列有可能還是滿的,但是隻是暫時的。
接下來我們連起來看:
所以怎麼理解上面被劃線的這句話呢?
帶入一個實際的場景,也就是前面的示例程式碼,只是調整一下引數:
這個執行緒池核心執行緒數是 1,最大執行緒數是 2,佇列長度是 5,最多能容納的任務數是 7。
另外有一個執行緒在執行把核心執行緒池從 1 修改為 2 的操作。
假設我們記執行緒池 submit 提交了 6 個任務,正在提交第 7 個任務的時間點為 T1。
為什麼是要強調這個時間點呢?
因為當提交第 7 個任務的時候,就需要去啟用非核心執行緒數了。
具體的原始碼在這裡:
java.util.concurrent.ThreadPoolExecutor#execute
也就是說此時佇列滿了, workQueue.offer(command)
返回的是 fasle。因此要走到 addWorker(command, false)
方法中去了。
程式碼走到 1378 行這個時間點,是 T1。
如果 1378 行的 addWorker 方法返回 false,說明新增工作執行緒失敗,丟擲拒絕異常。
前面示例程式丟擲拒絕異常就是因為這裡返回了 fasle。
那麼問題就變成了:為什麼 1378 行中的 addWorker 執行後返回了 false 呢?
因為當前不滿足這個條件了 wc >= (core ? corePoolSize : maximumPoolSize)
:
wc 就是當前執行緒池,正在工作的執行緒數。
把我們前面的條件帶進去,就是這樣的 wc >=(false?2:2)
。
即 wc=2。
為什麼會等於 2,不應該是 1 嗎?
多的哪一個是哪裡來的呢?
真相只有一個:恰好此時 setCorePoolSize 方法中的 addWorker 也執行到了 workers.add(w)
,導致 wc 從 1 變成了 2。
撞車了,所以丟擲拒絕異常。
那麼為什麼大多數情況下不會丟擲異常呢?
因為從 workers.add(w)
到 t.start()
這個時間視窗,非常的短暫。
大多數情況下,setCorePoolSize 方法中的 addWorker 執行了後,就會理解從佇列裡面拿一個任務出來執行。
而這個情況下,另外的任務通過執行緒池提交進來後,發現佇列還有位子,就放到佇列裡面去了,根本不會去執行 addWorker 方法。
道理,就是這樣一個道理。
這個多執行緒問題確實是比較難復現,我是怎麼定位到的呢?
加日誌。
原始碼裡面怎麼加日誌呢?
我不僅搞了一個自定義佇列,還把執行緒池的原始碼粘出來了一份,這樣就可以加日誌了:
另外,其實我這個定位方案也是很不嚴謹的。
除錯多執行緒的時候,最好是不要使用 System.out.println,有坑!
場景
我們再回頭看看老爺子給出的方案:
其實它給了兩個。
第一個是拿掉 setCorePoolSize 方法中的 addworker 的邏輯。
第二個是說原程式中,即提問者給的程式中,使用的是 prestartAllCoreThreads 方法,這個裡面必須要呼叫 addWorker 方法,所以還是有一定的機率出現前面的問題。
但是,老爺子不明白為什麼會這樣寫?
我想也許他是沒有想到什麼合適的場景?
其實前面提到的這個 Bug,其實在動態調整的這個場景下,還是有可能會出現的。
雖然,出現的概率非常低,條件也非常苛刻。
但是,還是有機率出現的。
萬一出現了,當同事都在摳腦殼的時候,你就說:這個嘛,我見過,是個 Bug。不一定每次都出現的。
這又是一個你可以拿捏的小細節。
但是,如果你在面試的時候遇到這個問題了,這屬於一個傻逼問題。
毫無意義。
屬於,面試官不知道在哪看到了一個感覺很厲害的觀點,一定要展現出自己很厲害的樣子。
但是他不知道的是,這個題:
最後說一句
好了,看到了這裡了,安排一個點贊吧。寫文章很累的,需要一點正反饋。
給各位讀者朋友們磕一個了:
本文已收錄自個人部落格,歡迎大家來玩:
https://www.whywhy.vip/