PriorityBlockingQueue 永遠不會擋你:四個實測出來的地雷
一個 BlockingQueue 最值錢的功能,是它會拒絕你。
滿了就把生產者擋在門外,讓上游慢下來配合下游。這件事聽起來像限制,其實是整條流水線唯一的自動調速器。
兩天前那篇鎖的選擇結尾勸過一句:與其自己拿 ReentrantLock 加 Condition 湊生產者消費者,先看看 ArrayBlockingQueue 是不是就夠用。
那句話留了個洞。如果我要的不只是緩衝,還想要「重要的先做」呢?名字最接近的答案叫 PriorityBlockingQueue,跟 ArrayBlockingQueue 差三個字。行為差的東西比三個字多很多。
下面的輸出都是我自己在 OpenJDK 25.0.2 上跑出來的,不是抄文件。
它的 Blocking 只在一個方向上成立
BlockingQueue 這個介面有兩個阻塞點:佇列空的時候 take() 會等,佇列滿的時候 put() 會等。前者 PriorityBlockingQueue 老實做到了,後者它直接放棄,因為它沒有「滿」這個狀態。
1 | var q = new PriorityBlockingQueue<Integer>(1); // 初始容量刻意寫 1 |
初始容量 1,塞 50 萬筆,45 毫秒跑完,一次都沒等。remainingCapacity() 回的是 Integer.MAX_VALUE,21 億多,這個數字從第一筆到第五十萬筆都不會變。建構子那個容量參數只是叫它一開始配多大的陣列,不是上限。
最容易被騙的是這一行:
1 | boolean ok = q.offer(999, 3, TimeUnit.SECONDS); // true,實際等 0 ms |
offer(e, timeout, unit) 是 BlockingQueue 為「等不到空位就放棄」設計的方法。在這裡它永遠回 true,永遠不用等,那個 3 秒寫了跟沒寫一樣。API 長得像有煞車,踩下去是空的。
沒有 back-pressure,記憶體就是你唯一的煞車
有界佇列的行為像捷運閘門:裡面塞不下,後面的人就在閘門外排隊,站務員看得到隊伍在變長。無界佇列的行為像把閘門拆掉,所有人都進得去,月台看起來永遠很順,直到月台自己塌下來。
塌下來的方式是 OutOfMemoryError,而且它通常不在你塞資料的那條執行緒上炸。生產速度高過消費速度的那段時間,佇列只是安靜地長大,heap 用量往上爬,GC 開始變頻繁、變久,服務先變慢再變死。等你看到 OOM 的 stack trace,噴出來的往往是某個剛好要配置記憶體的無辜路徑,跟真正塞爆你的那個 producer 沒關係。
這是無界佇列最難查的地方:它把「上游太快」這個問題,轉譯成一個看起來跟上游無關的症狀。有界佇列會讓 producer 自己卡住,你 jstack 一抓就看到誰在等,五分鐘結案。
想要「重要的先做」又不想拆掉煞車,實務上就是自己在上游加一道限流,或者退一步問自己:優先級真的必要嗎?很多場景要的其實是兩條有界佇列,一條給急件一條給普通件,消費端優先撈急件那條。兩條 ArrayBlockingQueue 加一個 if,換回「滿了會擋」這個性質,通常比一顆無界的堆積划算。
你的 maximumPoolSize 會變成裝飾品
這一條是我最想拿出來講的,因為它會靜靜地讓一整套執行緒池設定失效。
ThreadPoolExecutor 開新執行緒的規則有先後順序:執行緒數少於 corePoolSize 就直接開;到了 core 之後改成往佇列塞;只有塞不進佇列,才會繼續開到 maximumPoolSize。無界佇列永遠塞得進去,所以第三步永遠不會發生。
1 | var pool = new ThreadPoolExecutor(2, 10, 0, TimeUnit.SECONDS, |
core 2、max 10,丟 30 個任務進去,largestPoolSize 從頭到尾是 2。那個 10 完全沒用到,RejectedExecutionHandler 也一輩子不會被呼叫,因為沒有東西會被拒絕。同一組參數換成有界佇列,數字立刻不一樣:
1 | var pool2 = new ThreadPoolExecutor(2, 10, 0, TimeUnit.SECONDS, |
同樣 30 個任務,這次執行緒真的長到 10 個。差別不在池,在佇列。你調 maximumPoolSize 沒有效果的時候,先去看佇列是誰,別再往上加數字。
順手講一句:Executors.newFixedThreadPool() 用的是無界 LinkedBlockingQueue,踩的是同一個坑,只是它連 max 都懶得讓你填。
排序這件事,它只做了一半
底層是一顆二元最小堆積,配一把 ReentrantLock。堆積只保證一件事:陣列第 0 格是當下最小的那個。除此之外的位置關係,跟你要的排序沒有關係。
我放入的順序是 5、1、4、2、3:
1 | toString() = [1:a, 2:b, 4:d, 5:e, 3:c] |
4 排在 3 前面。toString()、iterator()、stream()、toArray() 給你的都是堆積的實體陣列布局,看起來很像排好了,其實只有第一格可信。真正有序的只有反覆 poll(),還有 drainTo()(它內部就是一直取堆頂再往下沉,所以是對的)。
日誌印一行 queue= 就用 toString() 的人要小心,那行輸出永遠會讓你對佇列現況產生錯誤的印象。
第二半更陰險。同一個優先級的元素,出列順序不是進去的順序:
1 | put 順序: t1 t2 t3 t4 t5 t6 t7 t8 (優先級全部一樣) |
第一個是 t1,接下來整串倒著跑。這不是隨機,是堆積每次把陣列最後一個元素搬到頂端再往下沉的副產物,比較器說「一樣大」時它不會多做任何事。不隨機,但也絕對不是你要的先進先出。
修法很短,把單調遞增的序號當第二比較鍵:
1 | record Job(int priority, long seq, String payload) implements Comparable<Job> { |
加上去之後同優先級的八筆就乖乖回到 t1 到 t8。這個 tie-breaker 幾乎是所有優先級佇列的標準配件,Comparator 只回 0 的地方,順序就是未定義的。
型別檢查發生在入列那一刻
排序有入場費:沒有比較依據,東西根本進不去。
我原本以為它會等到佇列裡有兩個元素、真的需要比較的時候才抱怨,所以第一筆應該一路綠燈。寫了個計數器實測,直接被打臉:
1 | PriorityBlockingQueue:成功 0 次,第 1 次 put 丟 CCE(佇列當時 size=0) |
第一筆就炸,佇列還是空的也照炸。原因在 siftUpComparable 的第一行:它無條件把傳進來的元素 cast 成 Comparable,跟堆積裡有幾個元素完全無關。非併發版的 PriorityQueue 一樣。而 ArrayBlockingQueue 放三個同樣的物件連眉頭都不皺,因為它不排序,不需要那個 cast。
這一半反而最無害:任何一個塞得進一筆資料的單元測試都會抓到它。要嘛元素實作 Comparable,要嘛建構時傳 Comparator,你不可能帶著這個錯上線。
同一個入場費,換個方式交就過不了關,這才是真的會咬人的地方:
1 | submit(): 成功 0 次,第 1 次丟 CCE(queue 當時 size=0) |
同一個 core=1/max=1 的池,同一個實作了 Comparable 的任務類別。用 execute() 連丟五個都乖乖排隊,換成 submit() 第一次就炸。差別在佇列裡實際躺的是什麼型別,我把它印出來對照:
1 | execute() 進佇列的型別 = ProbeC$PTask |
這條呼叫鏈把原因寫得很清楚。submit() 會先把你的任務包成 FutureTask,再交給 execute(),所以進佇列的是那層包裝,不是你的類別。你寫的 compareTo() 藏在包裝底下,堆積的 siftUpComparable 看不到。
要同時拿到 Future 和優先級,得自己做一個實作 Comparable 的 FutureTask 子類,並覆寫 newTaskFor() 讓池改用它。
順帶對照一下 DelayQueue:它也是優先佇列,但取出條件是「延遲到期」,適合做排程。單純想要「重要的先做」才是 PriorityBlockingQueue 的場子。兩個都無界。
選佇列,是在選滿的時候誰痛
四個地雷其實是同一件事的四種長相。PriorityBlockingQueue 為了讓插入永遠成功、為了讓堆積維持得便宜,交出了「拒絕」這個能力。back-pressure、執行緒池的彈性、穩定的排序都是跟著那個能力一起走的。而它換回來的排序也有代價:型別檢查被押到入列那一刻,包一層 FutureTask 就穿不過去。
所以挑佇列的時候,我會先回答一個跟資料結構無關的問題:這條路塞住的時候,我希望誰先痛?
希望 producer 先痛,就選有界,讓它卡在 put() 上,jstack 一抓就知道是誰。希望誰都不痛,那就是選了讓 heap 最後痛一次,而它痛起來的時候不會告訴你原因。優先級只是排隊規則,煞車是另一回事,這兩件事你得分開買。
真的需要無界又要排序,至少把三件事寫進 code review 的檢查點。上游有沒有限流、比較器有沒有 tie-breaker、監控有沒有把 queue.size() 拉出來畫成一條線。前兩件是十行內能修完的事,第三件會在某個半夜救你一次。



































