1. 為什麼 ThreadLocal 逐漸失去時效
ThreadLocal 到底是用來做什麼的?
在傳統的多執行緒環境中,執行緒通常存活時間較長(例如在伺服器上),有時需要為每個執行緒保存其專屬的資料——這些資料不應與其他執行緒互相干擾。比如,使用者名稱、請求 ID,或暫存的緩衝區。
為了達成這點,Java 提供了 ThreadLocal<T>——相當於執行緒的「個人空間」,可以存放不影響其他執行緒的資料:
ThreadLocal<String> user = new ThreadLocal<>();
user.set("Alice"); // 該值只會儲存在此執行緒中
String name = user.get(); // 在這裡會回傳 "Alice",在其他執行緒中則為 null
為什麼 ThreadLocal 與虛擬執行緒不合
虛擬執行緒的生命週期與傳統「重量級」執行緒截然不同。它們會以成千上萬的數量快速建立與消亡——有時甚至在不到毫秒的時間內。而 ThreadLocal 會把資料綁在特定的執行緒上,彷彿該執行緒會永遠存在。
當虛擬執行緒結束時,它在 ThreadLocal 中的資料可能仍懸掛在記憶體中——即使執行緒早已終止。這會導致洩漏,因為 JVM 並不總是知道這些值已經不再需要。
如果執行緒被重複利用(例如使用執行緒池),還可能出現更糟的情況:先前的「他人」上下文會意外地流到新的請求中。想像一下,用戶 Petya 取得了用戶 Vasya 的資料——問題與漏洞接踵而至。
ThreadLocal 很適合用在執行緒不多且存活很久的情境。但對於虛擬執行緒而言——就像試著把東西存放在每秒都會消失的衣櫃裡。
2. Scoped Values:傳遞上下文的新方式
Scoped Values 是 Java 21 引入的新工具,用更優雅的方式解決了 ThreadLocal 的老問題。它不是像 ThreadLocal 那樣把資料存進執行緒裡,而是把資料「附著」在執行範圍(scope)上——也就是特定的一段程式碼。值只會在該段程式執行期間存在,結束後會自動消失,不會在記憶體中留下痕跡。
import java.lang.ScopedValue;
ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println("Hello, " + USER.get()); // 輸出:Hello, Alice
});
當程式碼離開 run 區塊後,該值就不再可用——嘗試存取會拋出例外。無需手動清理任何東西。
Scoped Values 不會污染記憶體、不會在執行緒間混淆上下文,還允許建立巢狀區域,內層值可以暫時覆蓋外層值。這是傳遞上下文時乾淨、可預測且安全的方式,特別適合虛擬執行緒的世界。
3. Scoped Values 的使用範例
範例 1:傳遞使用者上下文
假設我們有一個伺服器會處理不同使用者的請求。對於每個請求,我們都希望知道是誰發起的。
import java.lang.ScopedValue;
public class ServerExample {
static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
processRequest("Alice");
processRequest("Bob");
}
static void processRequest(String userName) {
ScopedValue.where(USER, userName).run(() -> {
handleBusinessLogic();
});
}
static void handleBusinessLogic() {
System.out.println("為使用者處理:" + USER.get());
}
}
會發生什麼事:
- 每個請求都會建立自己的 scope,在其中 USER 分別等於「Alice」或「Bob」。
- 在 handleBusinessLogic() 內,我們總能取得正確的使用者名稱。
- 一旦請求處理結束,該值就會消失。
範例 2:帶有上下文的日誌紀錄
假設我們想在日誌中自動帶入請求識別碼:
import java.lang.ScopedValue;
public class LoggingExample {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
String reqId = "REQ-" + i;
ScopedValue.where(REQUEST_ID, reqId).run(() -> {
log("開始處理");
doWork();
log("結束處理");
});
}
}
static void log(String message) {
System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
}
static void doWork() {
log("處理中...");
}
}
結果(範例):
[REQ-1] 開始處理
[REQ-1] 處理中...
[REQ-1] 結束處理
[REQ-2] 開始處理
[REQ-2] 處理中...
[REQ-2] 結束處理
[REQ-3] 開始處理
[REQ-3] 處理中...
[REQ-3] 結束處理
每個 scope 都保存自己的請求識別碼,執行緒之間不會混淆。
4. Scoped Values 與虛擬執行緒:天作之合
為什麼 Scoped Values 在虛擬執行緒中特別有用
虛擬執行緒存活時間很短——它們會以成千上萬的數量被建立與銷毀,有時只需一瞬間。因此,舊的 ThreadLocal 做法(把資料「綁死」在執行緒上)在這裡並不適用:執行緒消失得太快,而上下文可能意外洩漏或被混用。
ScopedValue 則把資料綁定在任務本身——也就是其執行範圍。這表示上下文(例如使用者名稱或請求 ID)會跟著程式碼走,而不是跟著執行緒走。當任務結束時,值會自動消失。對虛擬執行緒來說,這是安全、乾淨且沒有驚喜的理想解決方案。
範例:使用虛擬執行緒的大量任務處理
import java.lang.ScopedValue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadScopedValueDemo {
static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10_000; i++) {
int taskId = i;
executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
processTask();
}));
}
executor.shutdown();
}
static void processTask() {
// 每個任務都有自己的 TASK_ID
System.out.println("正在處理任務 #" + TASK_ID.get());
}
}
重點:
- 每個任務都會建立屬於它的 TASK_ID scope。
- 即使任務並行執行,值也不會在執行緒間混淆。
- 不會有記憶體洩漏:scope 會隨任務一起結束。
5. 比較:ThreadLocal vs ScopedValue
| 比較項 | ThreadLocal | ScopedValue |
|---|---|---|
| 綁定方式 | 綁在執行緒 | 綁在程式區域(scope) |
| 生命週期 | 執行緒存活期間 | scope 執行期間 |
| 安全性 | 有洩漏與混淆風險 | 無洩漏、無混淆 |
| 虛擬執行緒 | 效率差且風險高 | 非常適合 |
| 使用方式 | |
|
| 巢狀性 | 不支援覆寫 | 可在內層覆蓋外層值 |
6. 巢狀區域(scopes):覆蓋值
ScopedValue<String> INFO = ScopedValue.newInstance();
ScopedValue.where(INFO, "外層").run(() -> {
System.out.println(INFO.get()); // "外層"
ScopedValue.where(INFO, "內層").run(() -> {
System.out.println(INFO.get()); // "內層"
});
System.out.println(INFO.get()); // "外層"
});
結果:
外層
內層
外層
這很方便,例如在同一個任務內需要暫時覆寫上下文值時。
Scoped Values:常見使用情境
- 傳遞使用者或請求的識別碼:用於記錄日誌或檢查權限。
- 日誌紀錄:自動把上下文帶入日誌。
- 追蹤:用於偵錯與效能剖析。
- 交易參數:例如隔離等級或運作模式。
- 任何只在單一任務(或其子任務)範圍內可見的「上下文」。
7. 其他新機制:Structured Concurrency
Structured Concurrency 是一種做法:將相關的任務(例如同一操作的子流程)視為一個整體來管理——如果母任務結束或失敗,所有子任務會自動被取消。這能降低「被遺忘」或「懸掛」執行緒的風險。
範例(非常簡化):
try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result1 = scope.fork(() -> fetchData1());
Future<String> result2 = scope.fork(() -> fetchData2());
scope.join(); // 等待兩者完成
scope.throwIfFailed(); // 若任一失敗 — 拋出例外
String combined = result1.resultNow() + result2.resultNow();
System.out.println(combined);
}
優點:
- 更乾淨地管理任務生命週期。
- 沒有「懸掛」的子流程。
- 錯誤處理更容易。
Structured Concurrency 目前仍在預覽階段,但已在積極演進中。
8. 實務建議與限制
何時使用 Scoped Values?
- 需要在任務之間傳遞上下文時,特別是在使用虛擬執行緒時。
- 如果你先前使用 ThreadLocal——不妨考慮改用 ScopedValue。
何時仍需要 ThreadLocal?
- 少數情況下,執行緒存活時間非常長,且上下文需要在整個生命週期內保持「常駐」(例如面對舊系統時)。
限制
- Scoped Values 在建立 scope 後不可變——它們是唯讀的。
- Scoped Values 不能在 scope 外使用:在區域外嘗試取得值會拋出例外。
- 不要用 Scoped Values 存放大型物件——區域應該是輕量且快速的。
9. 使用 Scoped Values 的常見錯誤
錯誤 #1:嘗試在 scope 之外取得值。 如果在 ScopedValue.where(...) 區塊之外呼叫 USER.get(),你會得到 NoSuchElementException。請確認取用只發生在區域內。
錯誤 #2:嘗試在 scope 內修改值。 Scoped Values 並不是可變容器。若需要暫時「覆蓋」某個值,請建立巢狀的 scope。
錯誤 #3:同時使用 ThreadLocal 與 ScopedValue。 除非萬不得已,不要混用這兩種機制——這可能導致上下文混亂與錯誤。
錯誤 #4:忘記把邏輯包進 run() 區塊。 如果你只寫了 ScopedValue.where(USER, "Alice") 而沒有 .run(() -> { ... }),是不會建立任何 scope 的!
錯誤 #5:試圖用 Scoped Value 保存長期的全域資訊。 此類目的更適合使用一般變數,或在合理情況下使用 ThreadLocal。
GO TO FULL VERSION