[BEE-11007] 虛擬執行緒與結構化並發
INFO
Java 的 Project Loom 讓簡單的執行緒-每請求(thread-per-request)模型能像響應式程式設計一樣擴展——透過讓執行緒便宜到可以阻塞而不浪費 OS 資源——同時結構化並發(Structured Concurrency)強制執行生命週期規則以防止執行緒洩漏。
背景
2023 年之前,Java 伺服器的根本擴展性問題在於 Java 執行緒與 OS 執行緒之間的 1:1 對應關係。一個 OS 執行緒消耗約 1 MB 的堆疊記憶體,並需要核心執行緒來排程。一個有 10,000 個並發請求的伺服器需要 10,000 個 OS 執行緒——這個限制推動業界轉向響應式程式設計(Spring WebFlux、RxJava、Vert.x),其中小型執行緒池透過回調和 Future 以非阻塞方式處理請求。響應式框架解決了擴展性問題,但代價高昂:堆疊追蹤在回調鏈中變得零散,標準 Java 除錯工具失效,依賴圖中的每個函式庫都必須提供響應式 API。
Project Loom 是消除這個取捨的 OpenJDK 計畫。Ron Pressler(技術負責人,Oracle)在其 InfoQ 播客訪談(2021 年 5 月)中如此說明設計動機:「現代伺服器支援多達一百萬個開放套接字,但 Java 只能維持幾千個平台執行緒。虛擬執行緒透過讓執行緒便宜到可以每個任務分配一個,消除了這個瓶頸。」
虛擬執行緒於 Java 21 正式定案(JEP 444,2023 年 9 月)。它們是普通的 java.lang.Thread 實例,但存活於堆積(heap)而非 OS 堆疊。JVM 將虛擬執行緒掛載(mount)到平台執行緒(「載體執行緒(carrier thread)」)上執行,並在虛擬執行緒阻塞於 I/O 時卸載(unmount)——立即釋放載體執行緒去執行另一個虛擬執行緒。阻塞中的虛擬執行緒堆疊以延續(continuation)的形式保存在堆積記憶體中。沒有 OS 執行緒閒置等待。
結構化並發(自 Java 21 起透過 JEP 453 進入預覽;截至 Java 25 透過 JEP 525 仍持續演進)提供了互補的 API:當你從虛擬執行緒派生多個子任務時,StructuredTaskScope 保證所有子任務在作用域退出前完成或被取消。這消除了一類因失敗的子任務在背景持續執行而導致執行緒洩漏的問題。
虛擬執行緒的運作方式
JVM 維護一個專用的 ForkJoinPool,稱為虛擬執行緒排程器。此池中的平台執行緒稱為載體執行緒(carrier threads)。
預設排程器配置:
jdk.virtualThreadScheduler.parallelism:載體執行緒數 = CPU 核心數jdk.virtualThreadScheduler.maxPoolSize:上限為 256 個載體執行緒- 當載體執行緒被固定(pinned)時,池可暫時擴展至最多 256 個
當虛擬執行緒執行阻塞操作(I/O、Thread.sleep()、Object.wait())時,它從載體執行緒卸載。載體立即歸還給池,並接取另一個可執行的虛擬執行緒。阻塞中的虛擬執行緒堆疊框架被序列化為堆積上的**延續(continuation)**物件——僅幾百位元組,而非平台執行緒堆疊保留的 1 MB。
// 每個提交的任務建立一個虛擬執行緒——推薦的生產模式
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Order> order = executor.submit(() -> db.fetchOrder(id));
Future<Customer> customer = executor.submit(() -> db.fetchCustomer(id));
return new Response(order.get(), customer.get());
}
// 有名稱的虛擬執行緒(對執行緒轉儲有用)
ThreadFactory factory = Thread.ofVirtual().name("req-handler-", 0).factory();固定(Pinning)問題及其解決方案
虛擬執行緒在以下兩種情況下會被固定(pinned)——在阻塞操作期間無法從載體執行緒卸載:
- 虛擬執行緒在
synchronized區塊或方法內部(Java 21–23) - 虛擬執行緒正在執行原生方法或 JNI 呼叫
固定不會造成正確性問題,但會損害擴展性:若所有 256 個載體執行緒都被固定,其他虛擬執行緒將無法繼續執行。
偵測固定(Java 21+):
-Djdk.tracePinnedThreads=full # 固定時輸出完整堆疊追蹤
-Djdk.tracePinnedThreads=short # 僅輸出阻塞框架Java Flight Recorder(JFR)也會發出 jdk.VirtualThreadPinned 事件(預設閾值:20 ms)。
修復 Java 21–23 的固定問題: 將包含 I/O 的 synchronized 區塊替換為 ReentrantLock:
// Java 21-23:synchronized 在 I/O 期間固定載體執行緒
synchronized (lock) {
result = callExternalService(); // 阻塞載體執行緒
}
// 修復:ReentrantLock 允許卸載
lock.lock();
try {
result = callExternalService(); // 虛擬執行緒卸載;載體執行緒自由
} finally {
lock.unlock();
}Java 24+(JEP 491): synchronized 關鍵字被重新實作,使虛擬執行緒可以獨立於載體執行緒獲取、持有和釋放物件監視器。在基準測試中,固定情境下的 CPU 密集場景改善了 70 倍(31.8 秒 → 0.45 秒),Spring Boot I/O 場景改善了 5.3 倍(12.5 秒 → 2.3 秒)。JEP 491 之後,不再需要為了擴展性而遷移至 ReentrantLock。
結構化並發
使用 ExecutorService 進行扇出模式的問題:若你派生兩個任務,其中一個拋出例外,另一個任務會持續執行,直到你明確取消它。忘記取消就是執行緒洩漏。錯誤傳播需要手動處理。執行緒轉儲顯示的是沒有父子結構的平坦清單。
StructuredTaskScope 強制執行生命週期規則:所有派生的子任務必須在作用域關閉前完成(或被取消)。作為 AutoCloseable,它在 try-with-resources 中使用。
ShutdownOnFailure — 所有子任務必須成功;第一個失敗取消其餘:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Order> order = scope.fork(() -> db.fetchOrder(id));
Subtask<Customer> customer = scope.fork(() -> db.fetchCustomer(id));
scope.join(); // 等待所有子任務完成或作用域關閉
scope.throwIfFailed(); // 傳播第一個例外;取消任何仍在執行的子任務
return new Response(order.get(), customer.get());
}
// 作用域關閉:保證兩個子任務都已完成——無執行緒洩漏ShutdownOnSuccess — 返回第一個成功結果;取消其餘:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromPrimary());
scope.fork(() -> fetchFromReplica());
scope.join();
return scope.result(); // 第一個成功結果;失敗者被取消
}結構化並發也使執行緒轉儲可讀:在作用域內派生的執行緒在轉儲中作為作用域擁有者的子節點出現,與邏輯程式結構一致。
狀態: 結構化並發自 Java 21(JEP 453)起一直處於預覽狀態,並透過 Java 25(JEP 525)持續調整 API。語義是穩定的;工廠方法 API 在 JEP 505 中有所變更。尚不適合在穩定函式庫 API 中使用,但對應用程式程式碼是安全的。
Scoped Values
ThreadLocal 的設計面向長命、被池化的平台執行緒。使用虛擬執行緒時——它們從不被池化,每個任務存活一次——ThreadLocal 會造成問題:
- 每個虛擬執行緒分配自己的槽位,毫無快取效益。
- 使用
ThreadLocal快取昂貴物件的函式庫(如SimpleDateFormat、日期格式化器)會為每個虛擬執行緒實例化一個新的快取物件——在沒有任何重用的情況下造成堆積壓力。 ThreadLocal是可變的;任何被調用方都可以覆寫該值。- 清理需要明確的
remove();忘記會導致洩漏。
ScopedValue(Java 24 預覽 JEP 487;在 Java 25 透過 JEP 506 正式定案)是正確的替代品:
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
// 在請求處理器中綁定
ScopedValue.where(CURRENT_USER, authenticatedUser)
.run(() -> handleRequest()); // CURRENT_USER 對所有被調用方可見
// 在呼叫堆疊深處讀取——無需參數傳遞
void handleRequest() {
User u = CURRENT_USER.get();
// ...
}Scoped values 是不可變的(重新綁定會建立新的內部作用域)、run() 區塊退出時自動釋放、並且由 StructuredTaskScope 的子執行緒繼承——子任務自動繼承父作用域的 scoped values。
最佳實踐
MUST NOT(不得)對 CPU 密集型任務使用虛擬執行緒。 虛擬執行緒不搶占 CPU 密集型工作。執行純計算的虛擬執行緒會持有載體執行緒直到阻塞或完成。對 CPU 密集型操作應使用有界平台執行緒池(Executors.newFixedThreadPool(availableProcessors()))。
MUST(必須)使用 Semaphore 限制對容量有限的共用資源的並發存取。 無限制地為每個傳入請求建立虛擬執行緒,將使資料庫和下游服務不堪重負,因為它們仍有有界的連線池和執行緒池。
private final Semaphore dbSemaphore = new Semaphore(100); // 最多 100 個並發 DB 呼叫
void queryDatabase() throws InterruptedException {
dbSemaphore.acquire();
try {
db.query(...);
} finally {
dbSemaphore.release();
}
}MUST(必須)在遷移至虛擬執行緒之前審計依賴項中的 ThreadLocal 濫用。 透過 ThreadLocal 快取每執行緒物件(連線狀態、格式化器、解析器)的函式庫,將為每個虛擬執行緒分配這些物件而毫無重用。請優先使用 ScopedValue 進行請求作用域的上下文傳播。
SHOULD(應該)在 Java 21–23 部署中,將包含 I/O 的 synchronized 替換為 ReentrantLock。 在 Java 24+(JEP 491),此遷移不再需要用於擴展性。
SHOULD(應該)透過生產環境中的 JFR jdk.VirtualThreadPinned 事件監控虛擬執行緒固定。 固定事件激增表明某個函式庫或框架在阻塞 I/O 上持有同步監視器。
SHOULD(應該)對所有扇出模式使用 StructuredTaskScope(並行子請求、冗餘呼叫)。它能防止使用原始 Future 組合時容易引入的執行緒洩漏。
MUST NOT(不得)池化虛擬執行緒。 執行緒池的存在是為了分攤執行緒建立的成本;虛擬執行緒的建立成本極低,池化是不必要且適得其反的(它破壞了結構化並發所需的每任務生命週期模型)。
虛擬執行緒 vs. 響應式與 Go Goroutines
vs. 響應式程式設計(WebFlux、RxJava): 兩者都能在不按比例擴展 OS 執行緒的情況下實現 I/O 並發。響應式需要整個堆疊使用非同步 API,並產生零散的堆疊追蹤。虛擬執行緒使用普通的阻塞 Java 程式碼,並與任何現有的阻塞函式庫(JDBC、檔案 I/O、Thread.sleep())配合使用。對於典型企業規模(10k–50k 並發請求)的 I/O 密集型工作負載,吞吐量相當。響應式在極端串流並發(50 萬以上持久連線)中保有優勢。
vs. Go goroutines: 兩者都是使用 M:N 排程的用戶空間執行緒。Go 的排程器實作了非同步搶占(自 Go 1.14 起)——CPU 密集型 goroutine 最終會被搶占,讓其他 goroutine 執行。Java 虛擬執行緒沒有 CPU 搶占;CPU 密集型虛擬執行緒持有其載體執行緒直到阻塞。Go goroutines 能更優雅地處理 CPU 密集型並發工作負載。對於 I/O 密集型伺服器工作負載,兩種模型在功能上相當。
框架支援
Spring Boot 3.2+(需要 Java 21): 一個屬性即可將所有請求處理切換至虛擬執行緒:
spring.threads.virtual.enabled=true這會將 Tomcat 的執行緒池重新配置為 Executors.newVirtualThreadPerTaskExecutor(),並將 Spring 的非同步任務執行器改為使用虛擬執行緒。
Quarkus: 使用 @RunOnVirtualThread 標注阻塞的端點處理器,在虛擬執行緒上分發方法,而非 Vert.x 事件迴圈。適用於 REST 端點、Kafka 消費者和資料庫操作。
Helidon Níma(Helidon 4): 第一個從頭以虛擬執行緒建置的 Java 微服務框架——底層沒有響應式核心。使用阻塞套接字而非 NIO,每個 HTTP 連線分配一個虛擬執行緒。Helidon 的基準測試顯示效能與非同步 Netty 相當甚至更優。
常見錯誤
在 CPU 密集型負載下對虛擬執行緒進行基準測試。 IBM 的 Open Liberty 團隊發現,在 CPU 密集型工作負載上,吞吐量比調優良好的平台執行緒池低 10–40%,在 2 CPU 機器上僅達基準的 50–55%,原因是 Linux 排程器與 ForkJoinPool 的交互。虛擬執行緒提供的是高並發下的擴展性,而非更快的每請求執行。
不限制下游並行性。 虛擬執行緒使同時發起 50,000 個並發 JDBC 查詢變得輕而易舉。資料庫通常高效處理幾百到幾千個並發查詢。若無 Semaphore 或連線池大小限制,虛擬執行緒將使資料庫不堪重負。
使用 Thread.currentThread() 身份儲存狀態。 虛擬執行緒每個任務新建,從不重用。以 Thread.currentThread() 身份為鍵快取狀態的模式——或使用 ThreadLocal 進行快取而非上下文傳播——在虛擬執行緒下會失效。將快取模式遷移至明確作用域或請求作用域上下文。
將虛擬執行緒視為所有場景的萬能解法。 虛擬執行緒在明確定義的阻塞點(阻塞 I/O、Object.wait())進行協作讓出。它們不是魔法:在緊密 CPU 迴圈中自旋、在網路 I/O 上持有 synchronized(Java 21–23),或呼叫長時間原生方法(JNI)的程式碼不會從虛擬執行緒中獲益。
相關 BEE
- BEE-11001 -- 執行緒 vs 進程 vs 協程:虛擬執行緒建立於 OS 執行緒、綠色執行緒和協程的概念區分之上
- BEE-11004 -- 非同步 I/O 與事件迴圈:響應式程式設計模型,虛擬執行緒為 I/O 密集型工作負載提供了替代方案
- BEE-11005 -- 生產者-消費者與 Worker Pool 模式:何時使用有界平台執行緒池(CPU 密集型)vs. 虛擬執行緒(I/O 密集型)
- BEE-13003 -- 連線池與資源管理:為何即使有了虛擬執行緒,連線池仍然重要
- BEE-13008 -- JVM JIT 編譯與應用程式預熱:理解載體執行緒排程的 JVM 內部原理背景
參考資料
- JEP 444: Virtual Threads — OpenJDK (Java 21)
- JEP 453: Structured Concurrency (First Preview) — OpenJDK (Java 21)
- JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK (Java 24)
- JEP 506: Scoped Values (Final) — OpenJDK (Java 25)
- Virtual Threads — Oracle Java 21 Core Libraries
- Ron Pressler: Java's Project Loom — InfoQ Podcast (May 2021)
- Managing Throughput with Virtual Threads — Billy Korando, inside.java (February 2024)
- Java Virtual Threads: A Case Study — Gary DeVal et al., InfoQ (July 2024)
- When Quarkus Meets Virtual Threads — Clement Escoffier, Quarkus Blog (September 2023)
- All together now: Spring Boot 3.2, Java 21, and Virtual Threads — Spring Blog (September 2023)