Java 的 Thread 跟作業系統的執行緒是一一對應的,每建立一個 Java thread,就對應一個完整的作業系統原生 thread,stack 預設分配 512KB 到 1MB。Tomcat 預設 200 條 thread、手動調到 500 條差不多就是實務上限,因為每個 OS thread 的記憶體開銷跟 context switch 成本都不能省。

偏偏後端服務最常見的工作模式是 IO 密集:一個 HTTP request 進來,查 DB 等幾十毫秒、呼叫第三方 API 又等幾十毫秒,真正在 CPU 上跑的時間可能不到 1 毫秒。Thread 在等 IO 的期間什麼都不能做,但 OS thread 的資源照樣佔著。想要更多並行處理就要加開 thread,加到 OS 資源不夠就會成為效能瓶頸。

JDK 21(2023 年 9 月)正式推出 Virtual Threads(JEP 444),把這個問題的解法搬進了標準 JDK。Virtual thread 是 JVM 自己排程的輕量執行緒,不用跟 OS thread 一一綁定,等 IO 的時候 JVM 把它掛起、把 OS thread 空出來跑別的 virtual thread,IO 完成再排回去繼續。

Platform Thread 與 Virtual Thread

  • Platform Thread:就是我們一直在用的 Java thread,跟 OS thread 一一對應,由 OS 排程。記憶體開銷大、建立成本高,數量受限於 OS 資源
  • Virtual Thread:JVM 管理的輕量執行緒,多條 virtual thread 共用少數 OS thread。Stack 動態分配在 heap,初始只佔幾百 bytes
  • Carrier Thread:用來實際執行 virtual thread 的 platform thread,JVM 從一個 ForkJoinPool 裡取出,預設數量等於 CPU 核心數

Virtual thread 被排到一條 carrier thread 上開始執行,這個動作叫 mount(掛載)。遇到 blocking IO 時,JVM 把 virtual thread 的 stack frames 移回 heap、釋放 carrier thread,這叫 unmount(卸載)。被釋放的 carrier 馬上可以去跑別的 virtual thread。等 IO 完成,JVM 排程器再把這條 virtual thread 排到下一個空閒的 carrier 繼續跑。

因為 unmount 的成本遠低於 OS 的 context switch,一台機器同時掛著幾十萬條 virtual thread 也不是問題。

Virtual Thread 的 mount 與 unmount 機制
VT2 在 IO wait 時 unmount 釋放 Carrier,VT4 隨即 mount 上去

版本歷程

  • JDK 19(2022 年 9 月):JEP 425,第一次 Preview,需加 --enable-preview
  • JDK 20(2023 年 3 月):JEP 436,第二次 Preview,API 沒有變動
  • JDK 21(2023 年 9 月):JEP 444,正式 GA。JDK 21 是 LTS,也是實務上開始導入的起點

API 用法

直接建立一條 virtual thread:

// 建立 virtual thread 並啟動
Thread vt = Thread.ofVirtual().name("my-vt").start(() -> {
    System.out.println(Thread.currentThread());
});
vt.join();

透過 ExecutorService 批次送任務更實用,每個 submit 的任務會各自建立一條 virtual thread:

// 支援 try-with-resources,離開時等所有任務完成
try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        es.submit(() -> callExternalApi());
    }
}

newVirtualThreadPerTaskExecutor() 不是 thread pool。沒有 pool size 上限,每個任務各自建一條 virtual thread,跑完就結束。不用像以前那樣煩惱 pool 要設多大,JVM 自己管並發度。

適合與不適合的場景

適合 IO-bound:DB 查詢、HTTP 呼叫、檔案讀寫、訊息佇列。這些任務大部分時間在等,virtual thread 等待時 unmount 讓 carrier 去做別的事,吞吐量因此上升。

不適合 CPU-bound:影像處理、加密運算、大量計算。這類任務整段執行期間都佔著 carrier thread,不會觸發 unmount。Carrier 數量等於 CPU 核心數,多開 virtual thread 只是排隊等著用同一批核心,跟 platform thread 沒有差別,反而多一層 JVM 排程的開銷。CPU-bound 繼續用固定大小的 thread pool 比較合適。

Platform Thread 與 Virtual Thread 的 OS Thread 佔用對比
Platform Thread 在 IO wait 期間仍佔住 OS Thread,Virtual Thread 則釋放 Carrier 給其他任務使用

Pinning 問題

有一種情況 virtual thread 會「釘住(pin)」carrier thread,沒辦法 unmount:在 synchronized 區塊裡做 blocking 操作。

原因是 JDK 21–23 的 monitor(synchronized 背後的鎖機制)是以 carrier thread 的 identity 來追蹤誰持有鎖。Virtual thread 進了 synchronized 就跟 carrier 綁死,blocking 時 carrier 被卡住、其他 virtual thread 拿不到 carrier 來跑。

常見的 workaround 是改用 ReentrantLockReentrantLock 的 blocking 由 JVM 管理,不受 monitor 機制影響,可以正常 unmount:

// synchronized — JDK 21-23 會 pin carrier
synchronized (this) {
    result = callDatabase();  // carrier 被卡住
}

// ReentrantLock — 可以正常 unmount
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    result = callDatabase();
} finally {
    lock.unlock();
}

不過 JDK 24(2025 年 3 月)的 JEP 491 把 monitor 子系統重寫了,改用 virtual thread identity 追蹤鎖的擁有者。synchronized 區塊裡的 blocking 操作現在可以正常 unmount,大部分的 synchronized pinning 在 JDK 24 後不再是問題。殘留的例外是 JNI native method 呼叫,JVM 沒辦法跨 native frame 管理 thread 狀態,這裡仍會 pin。

想知道程式裡有沒有遇到 pinning,啟動時加 -Djdk.tracePinnedThreads=full,JVM 會在發生 pinning 時印出 stack trace。JDK 24 之後也可以用 JFR event jdk.VirtualThreadPinned 做監控。

常見陷阱

ThreadLocal 的記憶體壓力

ThreadLocal 在 virtual thread 上可以正常用,語意跟 platform thread 一樣。但 virtual thread 的量級動不動就幾十萬,每條都持有 ThreadLocal 資料的話,heap 壓力會很明顯。

如果只是傳 request ID、trace ID 這類 context,可以改用 ScopedValue(JDK 25 正式化)。ScopedValue 是唯讀的,生命週期綁在一個明確的 scope 內,比 ThreadLocal 更適合 virtual thread 大量使用的場景。

外部資源並發控制

Virtual thread 讓我們可以輕鬆開幾萬個並發,但下游資源有上限,DB 連線數、第三方 API rate limit 都是固定的。不加控制的話,很容易超過下游的承受能力。

// 用 Semaphore 控制對外部服務的並發數
var permits = new Semaphore(50);

try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var item : items) {
        es.submit(() -> {
            permits.acquire();
            try {
                callExternalService(item);
            } finally {
                permits.release();
            }
        });
    }
}

如果已經在用 connection pool(像 HikariCP),pool 本身就在控制並發了,不需要再疊一個 Semaphore。這時要調的是 pool 的最大連線數,而不是另外包一層限流。

Spring Boot 整合

Spring Boot 3.2 開始支援 Virtual Threads,搭配 Java 21 以上,一行設定就能啟用:

spring.threads.virtual.enabled=true

啟用之後 Tomcat 跟 Jetty 的請求處理執行緒都會切換成 virtual thread,@Async 用的 applicationTaskExecutor 也會自動改成 SimpleAsyncTaskExecutor 搭配 virtual thread。之前為了提高並發量而調大的 server.tomcat.threads.max 在 virtual thread 模式下意義不大,JVM 會自己管理。

Spring Boot 3.1 以下沒有這個設定,要手動建 TomcatProtocolHandlerCustomizer bean 指定 virtual thread executor,不如直接升 3.2。

小結

Virtual Threads 不會讓程式跑更快,它解決的是「同樣的機器資源能同時服務多少正在等 IO 的請求」。對 IO 密集的後端服務來說,這正是瓶頸所在——thread pool 調到幾百條就接近上限,virtual thread 讓這個數字可以到幾萬甚至更高。

導入門檻不高:JDK 21 + Spring Boot 3.2,改一行設定。需要留意的就是 synchronized pinning(JDK 24 已大幅改善)和下游資源的並發控制。CPU-bound 任務不適合,繼續用 thread pool。


Sponsored Links

發佈留言