1. 傳統執行緒:如何運作、痛點在哪裡
先回顧一下 Java 中的傳統執行緒(也稱為平台執行緒或 native threads),就是透過 new Thread(...) 建立的那些。
當你呼叫 new Thread(() -> { ... }).start(); 時,JVM 並不只是啟動一段程式碼,它會請作業系統建立一個真正的執行緒。OS 會為它配置獨立的堆疊(通常數個 MB)並保留其他服務性資源。
這樣的執行緒在任務執行期間一直存活,並佔據作業系統的執行緒表。執行緒越多,堆疊耗掉的記憶體越多,OS 的負擔就越高。因此當同時工作的執行緒很多時,應用程式可能開始「吃不消」——系統花太多時間在切換它們。
範例:傳統執行緒
Thread thread = new Thread(() -> {
System.out.println("來自執行緒的問候!");
});
thread.start();
看起來很簡單,對吧?但如果不是建立一兩個,而是比方說一萬個這樣的執行緒——你的程式很快就會開始喘不過氣。不是記憶體用完,就是系統提示已達到執行緒上限。這不是 Java 的錯,而是架構使然:執行緒本來就「沉重而昂貴」。
為什麼會這樣?每個執行緒都會拿到自己的堆疊(通常 1–2 MB),再加上一整套由作業系統提供的輔助結構。而且 OS 也不喜歡被塞進成千上萬的執行緒——它有各種限制,而且往往相當嚴格。
即使記憶體沒用盡,也還有另一個麻煩——情境切換。當執行緒太多時,系統不斷在它們之間跳轉,保存與還原狀態。這些都需要時間並吞噬效能,因此「大量並行」不見得會帶來實際收益。
「一執行緒一請求」的問題
在舊式伺服器應用(例如 Tomcat 或 Jetty)中,常用「thread‑per‑request」模型:每個進來的使用者請求對應一個執行緒。這確實很方便,但如果你有 10_000 位使用者,就需要 10_000 個執行緒!伺服器會越來越吃力,接著你追逐的將是記憶體而非請求處理速度。
結論:
傳統執行緒適合少量並行任務,但無法有效擴展到數萬、數十萬等級。
2. 什麼是虛擬執行緒(Virtual Threads)?
這就輪到今天的主角——虛擬執行緒。它們不只是「另一種執行緒」,而是一個完全不同的架構理念。
虛擬執行緒不是由作業系統管理,而是由 JVM 自己管理。它們完全在 Java 內部實作,可以大量建立(數萬、數十萬)而不會造成記憶體膨脹與效能拖累。
重點摘要:
- Platform Thread(平台執行緒):直接對應 OS 執行緒的傳統執行緒。
- Virtual Thread(虛擬執行緒):由 JVM 管理、而非 OS 管理的輕量執行緒。
它怎麼運作?
虛擬執行緒是「輕量」的執行緒,不在 OS 層而是活在 JVM 內部。它們運行在少量真正的執行緒(稱為 platform threads)之上。可以把 JVM 想像成指揮:手上只有有限的樂手(真正的執行緒),但能把許多樂段(虛擬執行緒)巧妙分配給他們演奏。
架構示意:
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (Operating System)
Carrier Thread 是一般的 OS 執行緒,JVM 會在其上執行許多虛擬執行緒。如果某個虛擬執行緒被阻塞——例如等待磁碟或網路資料——JVM 會把它「凍結」,釋放出 carrier thread 讓其他任務使用。
為什麼這是場革命?
因為你可以繼續書寫直覺、線性的程式碼——不必塞滿 callback、CompletableFuture 以及冗長的 thenApply 鏈——同時仍能把應用擴展到成千上萬個並行操作。
虛擬執行緒只佔用數十 KB(傳統執行緒常是數個 MB),而且建立幾乎是瞬間完成。因此你可以放心地以成千上萬的規模啟動與銷毀它們,無須擔心 OS 會被拖垮。這讓 Java 的並行程式設計終於變得輕鬆而自然。
3. 虛擬執行緒的優勢
可伸縮性
有了虛擬執行緒,你可以大膽地啟動成千上萬個平行任務。比方說為每個網路請求使用一個獨立執行緒——也不必擔心伺服器會「爆掉」。
示範:100 000 個虛擬執行緒
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// 這裡可以是任何邏輯
try {
Thread.sleep(1000); // 模擬工作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("所有執行緒都已啟動!");
這段程式在一般筆電上也能輕鬆跑起來!
試著用傳統執行緒做同樣的事——你很可能會看到 OutOfMemoryError,或讓電腦直接卡成「磚」。
更容易的程式設計
虛擬執行緒讓你能維持熟悉的「阻塞式」寫法,而不會把程式變成一團非同步「麵條」。例如你可以照常使用 Thread.sleep、InputStream.read、Socket.accept——JVM 會確保不會把整個 carrier thread 都卡住。
提高可讀性與可維護性
不必再用複雜的 callback 方案或 CompletableFuture,而是撰寫線性、好理解的程式。這可減少 bug,並讓維護更容易。
不必重新造輪子
過去若要同時處理成千上萬個請求,往往得靠非同步框架、反應式程式庫(Netty、Vert.x、Project Reactor),並採用特別的程式風格。現在即使不用它們——也一樣能獲得良好的可伸縮性。
4. 架構:虛擬執行緒的「底層」如何運作
對映到 carrier threads
JVM 會建立一個小型的真正執行緒池(carrier threads)——通常和 CPU 核心數差不多。所有虛擬執行緒就像搭乘這些 carrier threads 的乘客一樣「跑」在上面。
- 當虛擬執行緒被阻塞(例如等待網路回應)時,JVM 會把它從 carrier thread「卸下」並放入佇列。
- 一旦該執行緒可以繼續,JVM 就把它重新安排到空閒的 carrier thread 上。
比喻:
想像你只有 4 台計程車(carrier threads),但要服務 10 000 位乘客(virtual threads)。當一位乘客抵達下車,計程車立刻去載下一位。沒有人在空等,計程車也不會被「乘客的重量」壓垮。
排程與切換
JVM 會自行決定此刻要執行哪個虛擬執行緒。若執行緒在 I/O 上被阻塞,它不會妨礙其他執行緒繼續工作。
5. 虛擬執行緒的限制與特性
不是所有「虛擬」都那麼美好
不適合長時間的重度計算: 如果你的任務會持續占用 CPU(heavy CPU‑bound),虛擬執行緒不會帶來效能提升。因為 carrier threads 的數量仍受核心數限制。
某些鎖效率不佳: 舊式同步機制(例如在具原生互斥鎖的物件上使用 synchronized)可能讓 JVM 無法「凍結」虛擬執行緒。這種情況下 carrier thread 也會跟著等待,降低可伸縮性。
並非所有程式庫都友善: 如果某個程式庫進行原生呼叫或使用特殊的鎖,虛擬執行緒的行為可能不如預期。
範例:何時不該使用 Virtual Threads
如果你的任務是在無窮迴圈中持續計算,虛擬執行緒完全不會帶來好處。最終仍受限於核心數。
Thread.ofVirtual().start(() -> {
while (true) {
// 一直計算到無窮
}
});
結果:
會有一個 carrier thread 被這個虛擬執行緒占住,其他任務只能排隊等待。
6. 比較:Platform Thread vs Virtual Thread
| 特性 | Platform Thread(傳統) | Virtual Thread(虛擬) |
|---|---|---|
| 管理者 | 作業系統 | JVM |
| 每個執行緒的記憶體 | 數個 MB | 數十 KB |
| 執行緒數量 | 通常 < 10_000 | 數千、數十萬 |
| 建立成本 | 高 | 低 |
| 可伸縮性 | 受限 | 幾乎不受限 |
| 適合用於 | 長時任務、CPU 密集 | 短暫任務、I/O 密集 |
| 執行緒切換 | OS | JVM |
| 相容性 | 100% | 幾乎總是如此,但有一些細節 |
7. 範例:Virtual Threads 前後對比的伺服器寫法
之前(Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
問題:
大約在 5 000 個連線後,伺服器就會開始吃不消。
之後(Virtual Threads,Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
魔法般的效果:
現在可以處理成千上萬個連線——不再需要擔心執行緒上限!
9. 遷移到虛擬執行緒的常見錯誤
錯誤 №1:期待計算型任務獲得加速。 虛擬執行緒無法加速會把 CPU 塞滿的任務。這類任務仍然受限於核心數。
錯誤 №2:沿用舊式阻塞式同步。 如果你使用舊式鎖(例如在可能被原生方式「鎖住」的物件上使用 synchronized),虛擬執行緒可能無法從 carrier thread 上卸載,進而喪失優勢。
錯誤 №3:忽略第三方程式庫的行為。 某些第三方程式庫可能尚未準備好與虛擬執行緒合作(例如使用 JNI 或原生鎖)。
錯誤 №4:期待魔法般的效能成長。 虛擬執行緒不是萬靈丹。它們不會讓所有東西都變快;它們只是讓 I/O‑bound 任務的並行成本變得很低、寫法更自然。
GO TO FULL VERSION