1. 可擴展性
為什麼一般執行緒難以擴展?
每個傳統執行緒(Thread)都是作業系統的實體,具有自己的堆疊(通常 1–2 MB)與狀態結構。嘗試建立例如 10 000 個一般執行緒,往往會導致錯誤 OutOfMemoryError。因此在傳統伺服器中會使用受限的執行緒池。
虛擬執行緒:可擴展性的魔法
虛擬執行緒(Java 21+)是由 JVM 管理的「輕量級」執行緒。它們的堆疊位於堆上,並可動態成長/縮小。當執行緒在 I/O 上被阻塞時,JVM 會「凍結」它,並讓其他任務繼續執行。
在 JVM 內部有一個小型的「載體執行緒」(carrier threads)池——作業系統的平台執行緒,虛擬執行緒會依序在其上執行。這使得能夠建立 100 000+ 個任務而不會對記憶體造成可怕負擔。JVM 會自行規劃要在何時執行哪些虛擬執行緒。
示範: 100_000 個虛擬執行緒對比 1_000 個平台執行緒
範例:建立 1000 個一般執行緒
// 嘗試建立 1000 個一般執行緒
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(t);
t.start();
}
System.out.println("已建立的執行緒數量: " + threads.size());
結果: 在多數系統上大約能建立 1 000–2 000 個執行緒;更大的數量會開始出現記憶體問題與明顯變慢。
範例:建立 100 000 個虛擬執行緒
// 建立 100_000 個虛擬執行緒
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThreads.add(t);
}
System.out.println("已建立的虛擬執行緒數量: " + vThreads.size());
結果: 程式可以輕鬆建立 100 000 個虛擬執行緒而不會當機或有明顯卡頓。所需記憶體大幅減少。
視覺化比較
| 執行緒類型 | 最大執行緒數(約) | 記憶體使用量 | 啟動時間 |
|---|---|---|---|
| 一般(Thread) | 1 000 – 10 000 | 高 | 較久 |
| 虛擬 | 100 000 – 1 000 000+ | 低 | 即時 |
事實: 虛擬執行緒讓你可以撰寫「每個任務一個執行緒」的程式碼,無需複雜的執行緒池,也不必擔心壓垮系統。
2. 效能:虛擬執行緒在哪裡發揮所長
受 I/O 限制的任務(I/O‑bound)
虛擬執行緒非常適合網路請求、檔案 I/O 與資料庫作業。當操作被阻塞時,虛擬執行緒會釋放「載體執行緒」,而 JVM 會去執行其他任務。這在大量同時等待的情況下能提升吞吐量。
範例:模擬 10 000 個同時的 HTTP 請求
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("回應: " + response.statusCode());
} catch (Exception e) {
System.out.println("錯誤: " + e.getMessage());
}
});
threads.add(t);
}
// 等待所有執行緒完成
for (Thread t : threads) {
t.join();
}
結果: 所有 10 000 個請求會並行執行;程式不會當機,而程式碼仍然簡潔。
CPU‑bound:虛擬執行緒不會加速計算
若任務主要負載在 CPU,虛擬執行緒不會帶來速度提升:核心數是固定的。此時應使用大小等於核心數的固定執行緒池,以避免額外的競爭。
// 每個任務計算一個很大的範圍總和
Runnable cpuTask = () -> {
long sum = 0;
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
System.out.println("總和: " + sum);
};
// 啟動 1000 個執行計算的虛擬執行緒
for (int i = 0; i < 1000; i++) {
Thread.ofVirtual().start(cpuTask);
}
結果: 這些執行緒會競逐 CPU,但不會更快——這不是 Virtual Threads 的用武之地。
3. 限制與注意事項
同步與虛擬執行緒的陷阱
- 小心原生鎖。 使用 synchronized 可能會把虛擬執行緒「黏住」在載體執行緒上,降低其效益。優先使用 ReentrantLock、Semaphore 與 java.util.concurrent 中其他針對虛擬執行緒最佳化的原語。
- 舊版函式庫。 部分 JDBC 驅動與原生函式庫尚未針對 Virtual Threads 最佳化。請仔細測試其阻塞操作。
不適合長壽命任務
Virtual Threads 理想於「短暫」的工作單元:處理請求、完成一次操作後結束。上百萬個長時間存活的任務(例如無盡的計算)無濟於事——這類情況請使用平台執行緒。
4. Best practices: 何處使用虛擬執行緒
- I/O‑bound 任務: 網路呼叫、檔案、資料庫——凡是執行緒經常等待的地方。
- Web 伺服器: 每個 HTTP 請求用一個獨立的虛擬執行緒處理。
- 整合測試: 快速模擬成千上萬個客戶端。
- 非同步處理: 以熟悉的「阻塞式」程式碼撰寫——JVM 會做聰明的排程。
不建議使用於:
- 持續重度 CPU 的任務。
- 對低階函式庫相容性要求極高的情境(目前並非全部已適配)。
底層建議使用執行器:Executors.newVirtualThreadPerTaskExecutor()——「每個任務一個虛擬執行緒」,不需固定大小的池。
5. 監控與量測:如何觀察虛擬執行緒的運作
JVisualVM 與 Flight Recorder
JVisualVM 會顯示活動中的執行緒、其狀態與記憶體;自 Java 21 起,虛擬執行緒會被單獨呈現。Java Flight Recorder(JFR)會記錄詳細的「黑盒」執行資料,包括 Virtual Threads 的統計——非常適合用來尋找瓶頸。
如何在程式碼中查看執行緒數量
查看 JVM 中執行緒數量的簡單方式:
System.out.println("執行緒總數: " + Thread.activeCount());
計算其中有多少是虛擬執行緒:
long vCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
System.out.println("虛擬執行緒數量: " + vCount);
6. 使用虛擬執行緒時的常見錯誤
錯誤 №1: 將虛擬執行緒用於重度計算。 為 CPU‑bound 任務啟動數百萬個虛擬執行緒不會讓處理器更快。Virtual Threads 並不是計算的「渦輪」。
錯誤 №2: 盲目沿用舊有模式。 不要建立固定大小的虛擬執行緒池。請使用 Executors.newVirtualThreadPerTaskExecutor(),讓 JVM 自動擴展。
錯誤 №3: 使用未支援的函式庫。 原生鎖與尚未針對 Loom 適配的函式庫可能導致卡頓與效能下滑。請事先檢查相容性。
錯誤 №4: 過早最佳化。 如果你只用到少量執行緒與一般多執行緒,無須急著把所有東西都搬到 Virtual Threads。這項工具適用於大量 I/O 與等待的情境。
錯誤 №5: 忽視監控。 建立百萬個任務很容易,但若缺乏監控與例外處理,你可能得到的是「漂亮的基準測試」,而非可靠的系統。請使用 JVisualVM 與 JFR。
GO TO FULL VERSION