CodeGym /課程 /JAVA 25 SELF /Virtual Threads 的可擴展性與效能

Virtual Threads 的可擴展性與效能

JAVA 25 SELF
等級 57 , 課堂 2
開放

1. 可擴展性

為什麼一般執行緒難以擴展?

每個傳統執行緒(Thread)都是作業系統的實體,具有自己的堆疊(通常 12 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 0002 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 00010 000 較久
虛擬 100 0001 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 可能會把虛擬執行緒「黏住」在載體執行緒上,降低其效益。優先使用 ReentrantLockSemaphorejava.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: 忽視監控。 建立百萬個任務很容易,但若缺乏監控與例外處理,你可能得到的是「漂亮的基準測試」,而非可靠的系統。請使用 JVisualVMJFR

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION