要把一個 traceId 從 Controller 帶到最底層的 DAO,中間不想改十七個方法簽名,你會怎麼做?

十年來標準答案只有一個:塞進 ThreadLocalset() 完就走,離開前記得 remove()。這招我自己寫過、review 過、也修過別人忘記 remove 的那種 bug。它能用,只是那個 finally 區塊像是隨身攜帶的道歉信。

Java 25 把 ScopedValue 正式轉正了(JEP 506,五輪 preview 之後定案)。網路上多數介紹會說它「離開作用域自動解除,不用 remove」。這句沒錯,但把重點放錯地方了。我用 JDK 25.0.2 把兩邊行為跑了一遍,跑完覺得真正的差別是另一件事:**ScopedValue 沒有 set()**。

下面每個有輸出區塊的段落都是實跑結果(java Probe.java,JBR 25.0.2),沒有輸出的地方是機制說明,我會講明哪些我沒測。

舊做法:那個 finally 到底在防什麼

那個 finally 到底在防什麼?開一個大小為 1 的執行緒池,第一個任務 set 完就結束,第二個任務什麼都不做,直接讀:

1
2
3
[A] ThreadLocal + 單執行緒池,第二個任務進來時讀到什麼
任務1 執行緒=pool-1-thread-1 設定後讀到=alice
任務2 執行緒=pool-1-thread-1 沒設定就讀到=alice(null 代表乾淨,有值代表殘留)

第二個任務讀到了 alice。它從來沒設定過任何東西。

這就是 ThreadLocal 的物理現實:值掛在執行緒上,不是掛在任務上。執行緒池的整個賣點就是執行緒會被重複使用,所以上一個任務留下的東西,下一個任務原封不動接手。

放大一點看更清楚。4 條執行緒的池子跑 8 個任務,每個任務進來先讀一次再寫自己的值,數有幾個任務開頭就讀到前人的殘留:

1
2
[I] 4 執行緒池跑 8 個任務,數有幾個任務讀到別人的殘留
8 個任務中,開頭就讀到前人殘留的有 4 個

前 4 個任務踩到乾淨的執行緒,後 4 個全部接到前人的值。數字剛好對上執行緒數,因為每條執行緒都被用了兩輪。

這種 bug 的可怕之處在於它不會炸。你的程式碼會拿到一個看起來合理的 traceId、一個看起來合理的使用者 ID,然後把 A 使用者的操作記在 B 使用者頭上。測試環境跑一輪通常抓不到,因為任務太少、執行緒還沒開始重用。

新做法:值掛在那對大括號上

同一個場景換 ScopedValue

1
2
3
4
[B] ScopedValue + 單執行緒池,同樣兩個任務
任務1 執行緒=pool-2-thread-1 作用域內讀到=alice
任務2 執行緒=pool-2-thread-1 isBound()=false
任務2 讀取丟 NoSuchElementException,訊息=ScopedValue not bound

第二個任務在同一條執行緒上(pool-2-thread-1),isBound() 回 false,硬要 get() 就丟 NoSuchElementException

寫法是這樣:

1
2
3
4
5
6
7
static final ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "alice").run(() -> {
// 這對大括號裡面 USER.get() 拿得到 alice
doSomething();
});
// 出了這行,綁定就沒了

run() 一結束綁定就消失,不需要 finally,也沒有「忘記 remove」這個選項可以忘。同一條執行緒在作用域外面讀也是一樣的結果:

1
2
3
4
[C] 作用域結束後在同一條執行緒讀
scope 內 get()=bob isBound=true
scope 外 isBound=false
scope 外 orElse("匿名")=匿名

想像成函式的區域變數。你不會擔心一個 for 迴圈裡宣告的 int i 會洩漏到迴圈外面,因為大括號結束它就不存在了。ScopedValue 把同一套規則搬到了跨層傳遞的情境上。

真正被拿掉的東西

到這裡的故事都還只是「自動清理」。但把 ScopedValue 的公開方法全部列出來,會看到更狠的設計:

1
2
[F] ScopedValue 的公開方法清單
[get, hashCode, isBound, newInstance, orElse, orElseThrow, where]

沒有 set。沒有 remove

這才是它跟 ThreadLocal 的分水嶺。ThreadLocal 的問題從來不只是「你可能忘記清」,而是任何拿到那個變數的程式碼,都能在任何時刻把它改掉。一個請求跑過六層,中間第三層某個 util 順手 set 了一次,你在第六層讀到的東西就跟第一層放進去的不一樣了。而且從程式碼上看不出來是誰改的,你只能一層一層 grep。

ScopedValue 把這件事在型別層面關掉:值只能在 where(...).run(...) 的入口綁一次,作用域裡面就是唯讀的。你要改,唯一的辦法是再開一層新的作用域,而那一層有明確的括號範圍:

1
2
3
4
[E] 巢狀綁定
進外層 = 外層
進內層 = 內層
離開內層後 = 外層

內層綁定只在內層那對括號內有效,出來之後外層的值原封不動。這是它叫「structured」的原因,值的生命週期跟呼叫堆疊的形狀對齊,不會有一個活得比呼叫它的人還久的狀態。

順帶一個小地雷,JEP 506 定案時唯一的行為變更就在這裡:

1
2
[D] orElse(null) 在這個版本的行為
丟了 java.lang.NullPointerException:null

orElse(null) 現在不接受了。以前 preview 版本可以拿它當「沒綁定就給我 null」,Java 25 起要改用 isBound() 判斷。

虛擬執行緒才是這一切的動機

ThreadLocal 為什麼要在 2026 年被重新設計?因為虛擬執行緒把它的成本結構整個掀了。

一條平台執行緒對應一份 ThreadLocalMap,過去執行緒總數是幾百條,這筆開銷沒人在意。虛擬執行緒讓你一次開一百萬條,每條都掛一份 map,每份 map 裡都放著同一個 traceId 的複本。而且虛擬執行緒的生命週期短、數量大,remove() 漏掉一個就是一筆記憶體洩漏乘以一百萬。

ScopedValue 的值是不可變的,所以它可以只存一份、由所有繼承這個作用域的執行緒共享,不需要每條執行緒複製。這段是 JEP 506 的機制說明,我沒有實測記憶體占用,別把它當我量出來的數字。

我沒有換掉全部的 ThreadLocal,原因有三個

這裡要說清楚,免得你以為可以全域搜尋替換。

第一,子執行緒不會自動看到你的綁定。這點跟很多文章給人的印象不同,實測長這樣:

1
2
3
4
5
[G] 手動 new Thread() 的子執行緒讀父作用域
子執行緒 isBound=false

[H] ThreadLocal vs InheritableThreadLocal 的子執行緒
子執行緒 plain=null inheritable=i

InheritableThreadLocal 在手動開的子執行緒裡拿到值,ScopedValue 拿不到。它的繼承只在結構化併發(StructuredTaskScope)底下成立,而那個 API 在 JDK 25 還是 preview 狀態,要 --enable-preview 才能編。這部分我沒測,只是讀了 JEP。所以如果你的程式碼在靠 InheritableThreadLocal 把 context 傳給自己 new Thread() 出來的東西,現在還不能換。

第二,你需要中途改值的場合它幫不了你。作用域裡累加一個計數器、收集這次請求碰過幾張表,這種本質上要可變的狀態,ScopedValue 直接把路封掉。硬要做只能綁一個可變物件進去(例如 AtomicInteger),但那就繞掉了它整個設計意圖,不如老實用 ThreadLocal

第三,框架還沒跟上。Log4j2 的 ThreadContext、SLF4J 的 MDC、Spring 的 RequestContextHolder、Hibernate 的 session 綁定,底層都還是 ThreadLocal。你自己的程式碼可以換,但只要 log 的 traceId 還是靠 MDC 帶,你就得同時維護兩套 context 傳遞機制,那通常比不換更糟。

務實的切法是新寫的、跑在虛擬執行緒上的、context 從入口綁到底都不用改的那條路,用 ScopedValue。既有的、跟框架黏在一起的,維持原狀並且把 finally { remove(); } 寫好。判準是「這個值在整段呼叫過程中會不會被改」,會改的留給 ThreadLocal,不會改的交給 ScopedValue

帶走的那個問題

ThreadLocal 給你的是一個變數,你要自己負責它的生老病死。ScopedValue 給你的是一段生命週期,變數只是搭便車。

所以那句「不用寫 remove 了」其實還低估了它。被拿掉的不是一行清理程式碼,是「這個值現在到底是誰放的」這個問題本身,那個問題以前只能靠 grep 全庫和運氣回答。

如果你手上有一份 ThreadLocal 的用法清單,可以拿去照一遍:其中有多少是從頭到尾只讀不寫的?那些就是白繳了十年利息的部分。至於還在用 ThreadLocal 的地方要注意的坑,我之前寫過一篇專門講它怎麼用歪,跟這篇正好是一體兩面。

參考來源