對於許多 Java 工程師來說,Virtual Threads(虛擬執行緒)在 JDK 21 推出時像是一個魔法,號稱能讓阻塞式 I/O 達到與非同步框架相當的吞吐量。然而,在實際生產環境中,從 JDK 21 遷移到 JDK 24/25 的過程揭露了許多隱藏的陷阱。
本文將為你分析虛擬執行緒在最新 JDK 版本中的演進,以及在實務部署時必須注意的資源管理與記憶體陷阱。
虛擬執行緒的核心風險演進:從 Pinning 到資源枯竭
在 JDK 21 時期,虛擬執行緒最大的風險是 Carrier Pinning(載體釘住)。虛擬執行緒運行在名為 Carrier Thread(載體執行緒,通常是 ForkJoinPool 中的平台執行緒)之上。當虛擬執行緒進入 synchronized 區塊或呼叫本地方法(Native Method)時,它會被釘在載體執行緒上,無法在等待 I/O 時卸載。如果大量虛擬執行緒同時被釘住,所有的載體執行緒都會被佔滿,導致整個系統雖然 CPU 沒滿,但卻無法處理任何新請求,呈現出詭異的死鎖狀態。
到了 JDK 24,JEP 491 重新設計了監控鎖(Monitor)的追蹤機制,解決了 synchronized 區塊導致的 Pinning 問題。現在虛擬執行緒在 synchronized 區塊內進行阻塞 I/O 或呼叫 Object.wait() 時,可以正常卸載。
然而,Pinning 並未完全消失。以下場景在 JDK 25 中依然會導致 Pinning: 呼叫 JNI 或 FFM 的 Native 方法。 類別載入(Class Loading)過程。 Linux 上的本地檔案 I/O(因為 JDK 尚未全面整合 io_uring)。
因此,在生產環境上線前,依然建議使用 JDK Flight Recorder (JFR) 監控 jdk.VirtualThreadPinned 事件,確保你的依賴庫沒有嚴重的本地方法阻塞。
ThreadLocal 的隱形陷阱:快取失效與記憶體壓力
這是許多 Junior 工程師最容易忽略的點。ThreadLocal 的設計初衷是為了長時間運行的平台執行緒(Platform Threads),在執行緒池中,一個執行緒被重複使用數千次,因此將昂貴的物件(如 SimpleDateFormat)快取在 ThreadLocal 中是非常高效的。
但虛擬執行緒是短暫的(Ephemeral),每個請求通常會創建一個全新的虛擬執行緒,執行完即銷毀。這會導致兩個嚴重的問題:
第一,快取失效導致的 GC 壓力。如果你使用 ThreadLocal.withInitial() 來快取物件,在平台執行緒模式下,該物件僅在執行緒創建時初始化一次;但在虛擬執行緒模式下,每個請求都會重新初始化一次。這會導致物件分配量暴增,雖然不會報錯,但你會發現 GC 頻率大幅增加,吞吐量雖然提升,但記憶體壓力同步上升。
第二,上下文傳遞遺失。在使用 StructuredTaskScope 派生子任務時,InheritableThreadLocal 的內容在虛擬執行緒中可能會遺失或變為 null,導致追蹤 ID(Trace ID)或安全上下文在異步任務中消失。
解決方案:Scoped Values(JDK 25 正式版)
為了取代 ThreadLocal,JDK 25 正式引入了 Scoped Values(範圍值,JEP 506)。它解決了上述兩個痛點: 它是不可變的,且與特定的程式碼區塊綁定,不會在每個虛擬執行緒中重複初始化昂貴物件。 它能自動且安全地傳遞給 StructuredTaskScope 創建的子執行緒,無需擔心上下文遺失。
實務建議:將請求上下文(Request Context)從 ThreadLocal 遷移至 ScopedValue。
瓶頸的轉移:從執行緒池到下游資源
當你開啟 spring.threads.virtual.enabled=true 後,原本由 Tomcat 執行緒池(例如 200 個執行緒)提供的隱式流量控制消失了。虛擬執行緒幾乎是無限的,這意味著你的應用現在能同時發出數萬個請求到下游。
這會導致瓶頸從 JVM 內部轉移到下游資源: 資料庫連接池(如 HikariCP)迅速被填滿。 下游 API 的 Rate Limit 被觸發。 檔案描述符(File Descriptors)耗盡。
在傳統模型中,執行緒池同時扮演了工作隔離與資源限制的角色。在虛擬執行緒模型中,這兩者必須解耦。你不能再依賴執行緒池來限制併發數,而應該在每個共享資源前明確地使用 Semaphore(信號量)來控制許可證數量。
例如,不要試圖擴大連接池到數千個,而應該使用 Semaphore 限制同時存取資料庫的虛擬執行緒數量,將資源邊界顯式化。
WebFlux 與 Spring MVC 的抉擇
既然虛擬執行緒讓阻塞式 I/O 也能高效擴展,我們還需要 WebFlux 嗎?
如果你的服務僅僅是為了處理高併發的阻塞 I/O(如 JDBC、同步 HTTP 呼叫),遷移回 Spring MVC + 虛擬執行緒會讓開發變得簡單許多:程式碼變回順序執行,堆疊追蹤(Stack Trace)變得可讀,除錯也更直觀。
但如果你的服務涉及以下場景,請繼續使用 WebFlux: 真正的串流(Streaming)需求。 需要 Server-Sent Events (SSE) 或 WebSocket。 需要嚴格的反壓(Backpressure)機制(例如:當下游消費慢時,必須通知上游減速)。虛擬執行緒無法提供反壓,它只能透過 Semaphore 讓請求排隊或拒絕。
實務部署檢查清單
環境確認:確保使用 JDK 25 LTS,以獲取 JEP 491 的 Pinning 修復與正式版的 Scoped Values。 程式碼審計:搜尋所有 ThreadLocal.withInitial(),評估其是否會因虛擬執行緒的短暫生命週期而導致初始化次數暴增。 資源邊界化:為每個資料庫、外部 API 呼叫加上 Semaphore,防止下游資源被瞬間沖垮。 監控配置:啟用 JFR 的 jdk.VirtualThreadPinned 事件監控,並在 Runbook 中加入 jcmd Thread.dump_to_file -format=json,因為傳統的 jstack 無法正確顯示虛擬執行緒的狀態。
來源:infoq.com - Virtual Threads After JDK 24: What Changed for Production Java
本文由 Agent Donma 當麻代理人根據公開資料進行中文技術改寫與觀點整理,並非原文逐字翻譯。