CompletableFuture 的 thenApply 跑在哪個執行緒?看你跟它誰先跑到
一個 CompletableFuture 完成的那一瞬間,接在它後面的那段 lambda,到底是誰把它跑掉的?
大部分人心裡有個預設答案:跑在執行緒池裡。畢竟整條鏈子都是非同步的,callback 當然也是非同步跑的。
這個答案有一半的時間是對的。另一半的時間它跑在 main。
先把問題縮到最小
不要一開始就搬 Spring 或 WebFlux 出來。寫一支只有 System.out.println 的探針,把每一段的 Thread.currentThread().getName() 印出來就好。
這裡有個容易踩的陷阱:探針的訊息不要寫死。如果你在 log 裡寫「第二次會跑在 pool」,那句話印出來的是你的假設,不是程式的行為。一律印實際取到的值。
1 | static void log(String tag) { |
第一組實驗只問一件事:掛 callback 的時候,上游完成了沒有。
1 | // 情境 1:上游還在 sleep,callback 是掛在一個還沒完成的 future 上 |
OpenJDK 25 上跑出來:
1 | A. main -> main |
同一行 thenApply,兩種歸屬。
為什麼會這樣
把 thenApply 想成在門口貼一張便條紙。
你走到門口,如果屋裡的人還在忙,你就把便條貼上去然後走人。等他忙完,開門看到便條,這件事就順手由他做掉。所以 callback 跑在完成上游的那條執行緒上。
但如果你走到門口時他已經忙完了,門開著,沒人接便條。這時你不會把紙貼在空門上等下一個人,你就自己動手做了。callback 於是跑在你這條執行緒,也就是呼叫 thenApply 的那一條。
isDone() 為 true 的那個瞬間決定了一切。而「上游完成」與「你掛上 callback」這兩件事,本來就是兩條執行緒在賽跑。
這就是為什麼它會變成 heisenbug。你在本機測,資料少、上游一下就完成,callback 全部跑在 main。上了線流量進來,上游變慢,callback 全部搬到 pool 裡去。程式碼一個字都沒改。
executor 只綁「有 Async 的那幾段」
知道歸屬是競態決定的之後,下一個問題自然浮出來:那我指定 executor 有沒有用。
第二支探針把一條四段的鏈子,每段各印一次:
1 | CompletableFuture |
pool 是我自己建的 newFixedThreadPool(2),執行緒名字都以 biz-pool- 開頭。實際輸出:
1 | 1) supplyAsync(pool) -> thread=biz-pool-25 |
第 3 段是這篇最值得記住的一行。
我在第 1 段明明白白指定了 pool,第 3 段也明明白白寫了 Async。結果它跑在 ForkJoinPool.commonPool。executor 不會沿著鏈子傳下去,每一個 *Async 方法都是獨立決定的:你給它 executor 它就用,你沒給它就用 commonPool。
commonPool 的預設大小是「可用處理器數減一」。我這台機器 8 核,ForkJoinPool.getCommonPoolParallelism() 印出來是 7。這個池是整個 JVM 共用的,parallelStream() 也在裡面跑。所以一段做 IO 的 thenApplyAsync 忘了帶 executor,它會去卡住整個 JVM 共用的那七條執行緒。
traceId 會在第二段就斷掉
上面那支探針其實還藏了一半沒給你看。我在 main 塞了一個 ThreadLocal,模擬 MDC 裡的 traceId:
1 | static final ThreadLocal<String> TRACE = new ThreadLocal<>(); |
把 trace 值一起印出來,同一次執行:
1 | A. main 起點 -> thread=main trace=REQ-8891 |
ThreadLocal 綁在執行緒上,執行緒一換值就沒了,這件事本身不意外。真正難搞的是第 2 段:它有值。
如果 traceId 斷掉是穩定斷的,你第一次跑就會發現,加個 TaskDecorator 或用 MDC.getCopyOfContextMap() 手動搬過去就解決了。但這裡是同一行 code 有時候有值、有時候沒有,取決於上游完成得多快。日誌看起來就是:大部分請求 trace 串得好好的,尖峰時段少數請求中間斷一截。
我之前追一個 log 對不起來的問題繞了很久,就是被這個規律騙了。traceId 有斷有不斷,第一直覺是去懷疑寫入端有沒有漏設,而不是去懷疑「這行 code 換執行緒了」。
異常不會走中間那幾站
歸屬拆完,換異常。這條路徑同樣違反直覺。
第三支探針讓上游直接丟 IllegalStateException,後面接兩個 thenApply,每個都會印一行「我有跑到」:
1 | static CompletableFuture<String> boom() { |
輸出裡沒有任何一行「有跑到」:
1 | --- 1. 中間的 thenApply 會不會被執行 --- |
上游一爆,後面所有 thenApply 直接跳過,異常沿著鏈子往下滑到第一個願意接的人手上。這跟 try 區塊裡丟例外之後下面的程式碼不會執行是同一個道理,只是這裡的「下面」散在好幾個 lambda 裡,看起來比較不像同一段程式。
接下來這條最容易寫錯:
1 | --- 2. join vs get 包裝的例外型別 --- |
exceptionally 拿到的是包過一層的 CompletionException,不是你丟的那個。所以這種寫法會永遠走不進去:
1 | .exceptionally(t -> { |
要判斷原始型別得先剝一層 t.getCause()。而且不能無腦剝。上游如果是同步就完成的路徑,有時候不會包,getCause() 會給你 null。安全的寫法是 Throwable real = (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t;。
三個收尾方法的分工也不同。exceptionally 和 handle 會把異常吃掉,鏈子恢復成正常值繼續往下走;whenComplete 只是讓你偷看一眼,看完異常照樣往下丟。想記 log 又不想改變鏈子行為,用 whenComplete;想給降級值,用另外兩個。
沒人 join 的異常,是真的沒有聲音
最後一格實驗。建一條會爆的鏈子,然後什麼都不做:
1 | boom().thenApply(v -> v + "-never"); |
等一秒,stderr 一片乾淨,沒有 stack trace,沒有 warning。
這跟裸 Thread 不一樣。裸執行緒裡沒接的例外會走到預設的 handler 印出來,至少你在日誌裡看得到屍體。CompletableFuture 把異常存進自己的欄位,等有人來拿;沒人來拿,它就一直躺在那裡直到被 GC 掉。
ExecutorService.submit() 也有這個特性(Future 不 get() 就看不到異常),這是同一種設計取捨的兩個版本。所以任何一條鏈子的尾巴都應該掛一個東西,不接值也要接異常,至少 whenComplete 記一筆 log。
拆完之後真正的用法
把上面五格倒過來看,CompletableFuture 的使用規則其實很短。
只要那段 callback 裡有任何 IO、任何鎖、任何耗時運算,就用 thenApplyAsync 並且自己帶 executor。不是為了效能,是為了讓歸屬變成確定的——確定之後,效能和 traceId 才有得談。
鏈子的每一段都要當作「可能換執行緒」來寫。任何靠 ThreadLocal 傳遞的東西(MDC、Spring 的 SecurityContextHolder、交易狀態)都不會自動跟著走,要嘛在進鏈子前把值撈成區域變數,要嘛用會複製 context 的 decorator。
然後,尾巴一定要有人接。
這個「誰先跑到誰就負責」的模式不只出現在 Java。JavaScript 的 Promise 用微任務佇列把它繞掉了,代價是所有 callback 都晚一拍執行;Kotlin 的 coroutine 用 dispatcher 把歸屬變成顯式參數。三種語言,三種取捨。但它們在解同一道題:一個非同步結果完成的時候,接下來的工作該記在誰的帳上。
我這三支探針是在 OpenJDK 25 上跑的,行為從 Java 8 引進這個類別以來沒變過,但我沒有在每個版本上都實測。有一件事我到現在還沒完全弄清楚:在真實服務的高併發下,第 2 段那種「有時候跑在呼叫端」的比例到底跟上游延遲呈什麼關係,我只知道它會變,還沒量出曲線。
想自己驗的話,把上面的 log() 貼進任何一支 main,改幾個 sleep 的毫秒數,你會看到歸屬跟著換。這比讀十篇文章有用。
延伸閱讀:PriorityBlockingQueue 永遠不會擋你,四個實測出來的地雷 — 那篇講的是無界佇列怎麼讓
maximumPoolSize變成裝飾品,跟這篇的 commonPool 是同一類問題的兩個入口。




































