Java19 正式 GA!看虛擬執行緒如何大幅提高系統吞吐量

語言: CN / TW / HK

Java19 正式釋出,帶來了一個 Java 開發者垂涎已久的新特性—— 虛擬執行緒。在 Java 有這個新特性之前,Go語言的協程風靡已久,在併發程式設計領域可以說是叱吒風雲。隨著國內 Go 語言的快速發展與推廣,協程好像成為了一個世界上最好語言的必備特性之一。Java19 虛擬執行緒就是來彌補這個空白的。本文將通過對虛擬執行緒的介紹,以及與 Go 協程的對比來帶大家嚐鮮 Java19 虛擬執行緒。

本文要點:
  • Java 執行緒模型
  • 平臺執行緒與虛擬執行緒效能對比
  • Java 虛擬執行緒與 Go 協程對比
  • 如何使用虛擬執行緒

Java 執行緒模型

java 執行緒 與 虛擬執行緒

我們常用的 Java 執行緒與系統核心執行緒是一一對應的,系統核心的執行緒排程程式負責排程 Java 執行緒。為了增加應用程式的效能,我們會增加越來越多的 Java 執行緒,顯然系統排程 Java 執行緒時,會佔據不少資源去處理執行緒上下文切換。

近幾十年來,我們一直依賴上述多執行緒模型來解決 Java 併發程式設計的問題。為了增加系統的吞吐量,我們要不斷增加執行緒的數量,但機器的執行緒是昂貴的、可用執行緒數量也是有限的。即使我們使用了各種執行緒池來最大化執行緒的價效比,但是執行緒往往會在 CPU、網路或者記憶體資源耗盡之前成為我們應用程式的效能提升瓶頸,不能最大限度的釋放硬體應該具有的效能。

為了解決這個問題 Java19 引入了虛擬執行緒(Virtual Thread)。在 Java19 中,之前我們常用的執行緒叫做平臺執行緒(platform thread),與系統核心執行緒仍然是一一對應的。其中大量(M)的虛擬執行緒在較小數量(N)的平臺執行緒(與作業系統執行緒一一對應)上執行(M:N排程)。多個虛擬執行緒會被 JVM 排程到某一個平臺執行緒上執行,一個平臺執行緒同時只會執行一個虛擬執行緒。

建立 Java 虛擬執行緒

新增執行緒相關 API

Thread.ofVirtual()Thread.ofPlatform()是建立虛擬和平臺執行緒的新API:

//輸出執行緒ID 包括虛擬執行緒和系統執行緒 Thread.getId() 從jdk19廢棄
Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
//建立虛擬執行緒
Thread thread = Thread.ofVirtual().name("testVT").unstarted(runnable);
testVT.start();
//建立虛平臺執行緒
Thread testPT = Thread.ofPlatform().name("testPT").unstarted(runnable);
testPT.start();

使用Thread.startVirtualThread(Runnable)快速建立虛擬執行緒並啟動:

//輸出執行緒ID 包括虛擬執行緒和系統執行緒
Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
Thread thread = Thread.startVirtualThread(runnable);

Thread.isVirtual()判斷執行緒是否為虛擬執行緒:

//輸出執行緒ID 包括虛擬執行緒和系統執行緒
Runnable runnable = () -> System.out.println(Thread.currentThread().isVirtual());
Thread thread = Thread.startVirtualThread(runnable);

Thread.joinThread.sleep等待虛擬執行緒結束、使虛擬執行緒 sleep:

Runnable runnable = () -> System.out.println(Thread.sleep(10));
Thread thread = Thread.startVirtualThread(runnable);
//等待虛擬執行緒結束
thread.join();

Executors.newVirtualThreadPerTaskExecutor()建立一個 ExecutorService,該 ExecutorService 為每個任務建立一個新的虛擬執行緒:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  executor.submit(() -> System.out.println("hello"));
}

支援與使用執行緒池和 ExecutorService 的現有程式碼互相替換、遷移。

注意:

因為虛擬執行緒在 Java19 中是預覽特性,所以本文出現的程式碼需按以下方式執行:

  • 使用javac --release 19 --enable-preview Main.java編譯程式,並使用java --enable-preview Main執行;
  • 或者使用java --source 19 --enable-preview Main.java執行程式;

是騾子是馬

既然是為了解決平臺執行緒的問題,那我們就直接測試平臺執行緒與虛擬執行緒的效能。

測試內容很簡單,並行執行一萬個 sleep 一秒的任務,對比總的執行時間和所用系統執行緒數量。

為了監控測試所用系統執行緒的數量,編寫如下程式碼:

ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
  ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
  ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
  System.out.println(threadInfo.length + " os thread");
}, 1, 1, TimeUnit.SECONDS);

排程執行緒池每一秒鐘獲取並列印系統執行緒數量,便於觀察執行緒的數量。

public static void main(String[] args) {
  //記錄系統執行緒數
  ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
  scheduledExecutorService.scheduleAtFixedRate(() -> {
    ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
    ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
    System.out.println(threadInfo.length + " os thread");
  }, 1, 1, TimeUnit.SECONDS);

  long l = System.currentTimeMillis();
  try(var executor = Executors.newCachedThreadPool()) {
    IntStream.range(0, 10000).forEach(i -> {
      executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        System.out.println(i);
        return i;
      });
    });
  }
  System.out.printf("耗時:%d ms", System.currentTimeMillis() - l);
}

首先我們使用Executors.newCachedThreadPool()來執行10000個任務,因為 newCachedThreadPool 的最大執行緒數量是Integer.MAX_VALUE,所以理論上至少會建立大幾千個系統執行緒來執行。

輸出如下(多餘輸出已省略):

//output
1
7142
3914 os thread
  
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
        at java.base/java.lang.Thread.start0(Native Method)
        at java.base/java.lang.Thread.start(Thread.java:1560)
        at java.base/java.lang.System$2.start(System.java:2526)

從上述輸出可以看到,最高建立了 3914 個系統執行緒,然後繼續建立執行緒時異常,程式終止。我們想通過大量系統執行緒提高系統的效能是不現實的,因為執行緒昂貴,資源有限。

現在我們使用固定大小為 200 的執行緒池來解決不能申請太多系統執行緒的問題:

public static void main(String[] args) {
  //記錄系統執行緒數
  ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
  scheduledExecutorService.scheduleAtFixedRate(() -> {
    ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
    ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
    System.out.println(threadInfo.length + " os thread");
  }, 1, 1, TimeUnit.SECONDS);

  long l = System.currentTimeMillis();
  try(var executor = Executors.newFixedThreadPool(200)) {
    IntStream.range(0, 10000).forEach(i -> {
      executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        System.out.println(i);
        return i;
      });
    });
  }

  System.out.printf("耗時:%dms\n", System.currentTimeMillis() - l);
}

輸出如下:

//output
1
9987
9998
207 os thread
耗時:50436ms

使用固定大小執行緒池後沒有了建立大量系統執行緒導致失敗的問題,能正常跑完任務,最高建立了 207 個系統執行緒,共耗時 50436ms。

再來看看使用虛擬執行緒的結果:

public static void main(String[] args) {
  ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
  scheduledExecutorService.scheduleAtFixedRate(() -> {
    ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
    ThreadInfo[] threadInfo = threadBean.dumpAllThreads(false, false);
    System.out.println(threadInfo.length + " os thread");
  }, 10, 10, TimeUnit.MILLISECONDS);

  long l = System.currentTimeMillis();
  try(var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10000).forEach(i -> {
      executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
        System.out.println(i);
        return i;
      });
    });
  }

  System.out.printf("耗時:%dms\n", System.currentTimeMillis() - l);
}

使用虛擬執行緒的程式碼和使用固定大小的只有一詞只差,將Executors.newFixedThreadPool(200)替換為Executors.newVirtualThreadPerTaskExecutor()

輸出結果如下:

//output
1
9890
15 os thread
耗時:1582ms

由輸出可見,執行總耗時 1582 ms,最高使用系統執行緒 15 個。結論很明顯,使用虛擬執行緒比平臺執行緒要快很多,並且使用的系統執行緒資源要更少。

如果我們把剛剛這個測試程式中的任務換成執行了一秒鐘的計算(例如,對一個巨大的陣列進行排序),而不僅僅是 sleep 1秒鐘,即使我們把虛擬執行緒或者平臺執行緒的數量增加到遠遠大於處理器核心數量都不會有明顯的效能提升。因為虛擬執行緒不是更快的執行緒,它們執行程式碼的速度與平臺執行緒相比並無優勢。虛擬執行緒的存在是為了提供更高的吞吐量,而不是速度(更低的延遲)。

如果你的應用程式符合下面兩點特徵,使用虛擬執行緒可以顯著提高程式吞吐量:

  • 程式併發任務數量很高。
  • IO密集型、工作負載不受 CPU 約束。

虛擬執行緒有助於提高服務端應用程式的吞吐量,因為此類應用程式有大量併發,而且這些任務通常會有大量的 IO 等待。

Java vs Go

使用方式對比

Go 協程對比 Java 虛擬執行緒

定義一個 say() 方法,方法體是迴圈 sleep 100ms,然後輸出index,將這個方法使用協程執行。

Go 實現:

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

Java 實現:

public final class VirtualThreads {
    static void say(String s) {
        try {
            for (int i = 0; i < 5; i++) {
                Thread.sleep(Duration.ofMillis(100));
                System.out.println(s);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        var worldThread = Thread.startVirtualThread(
            () -> say("world")
        );
        
        say("hello");
        
        // 等待虛擬執行緒結束
        worldThread.join();
    }
}

可以看到兩種語言協程的寫法很相似,總體來說 Java 虛擬執行緒的寫法稍微麻煩一點,Go 使用一個關鍵字就能方便的建立協程。

Go 管道對比 Java 阻塞佇列

在 Go 語言程式設計中,協程與管道的配合相得益彰,使用協程計算陣列元素的和(分治思想):

Go 實現:

package main

import "fmt"

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // send sum to c
}
    
func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // receive from c

    fmt.Println(x, y, x+y)
}

Java 實現:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;

public class main4 {
    static void sum(int[] s, int start, int end, BlockingQueue<Integer> queue) throws InterruptedException {
        int sum = 0;
        for (int i = start; i < end; i++) {
            sum += s[i];
        }
        queue.put(sum);
    }


    public static void main(String[] args) throws InterruptedException {
        int[] s = {7, 2, 8, -9, 4, 0};
        var queue = new ArrayBlockingQueue<Integer>(1);
        Thread.startVirtualThread(() -> {
            sum(s, 0, s.length / 2, queue);
        });
        Thread.startVirtualThread(() -> {
            sum(s, s.length / 2, s.length, queue);
        });
        int x = queue.take();
        int y = queue.take();

        System.out.printf("%d %d %d\n", x, y, x + y);
    }
}

因為 Java 中沒有陣列切片,所以使用陣列和下標來代替。Java 中沒有管道,用與管道相似的 BlockingQueue 來代替,可以實現功能。

協程實現原理對比

GO G-M-P 模型

Go 語言採用兩級執行緒模型,協程與系統核心執行緒是 M:N 的,這一點與 Java 虛擬執行緒一致。最終 goroutine 還是會交給 OS 執行緒執行,但是需要一箇中介,提供上下文。這就是 G-M-P 模型。

  • G: goroutine, 類似程序控制塊,儲存棧,狀態,id,函式等資訊。G 只有繫結到 P 才可以被排程。

  • M: machine, 系統執行緒,繫結有效的 P 之後,進行排程。

  • P: 邏輯處理器,儲存各種佇列 G。對於 G 而言,P 就是 cpu 核心。對於 M 而言,P 就是上下文。

  • sched: 排程程式,儲存 GRQ(全域性執行佇列),M 空閒佇列,P 空閒佇列以及 lock 等資訊。

佇列

Go 排程器有兩個不同的執行佇列:

  • GRQ,全域性執行佇列,尚未分配給 P 的 G(在 Go1.1 之前只有 GRO 全域性執行佇列,但是因為全域性佇列加鎖的效能問題加上了LRQ,以減少鎖等待)。
  • LRQ,本地執行佇列,每個 P 都有一個 LRQ,用於管理分配給P執行的 G。當 LRQ 中沒有待執行的 G 時會從 GRQ 中獲取。
hand off 機制

當 G 執行阻塞操作時,G-M-P 為了防止阻塞 M,影響 LRQ 中其他 G 的執行,會排程空閒 M 來執行阻塞 M LRQ 中的其他 G:

  1. G1 在 M1 上執行,P 的 LRQ 有其他 3 個 G;
  2. G1 進行同步呼叫,阻塞 M;
  3. 排程器將 M1 與 P 分離,此時 M1 下只執行 G1,沒有 P。
  4. 將 P 與空閒 M2 繫結,M2 從 LRQ 選擇其他 G 執行。
  5. G1 結束堵塞操作,移回 LRQ。M1 會被放置到空閒佇列中備用。
work stealing機制

G-M-P 為了最大限度釋放硬體效能,當 M 空閒時會使用任務竊取機制執行其他等待執行的 G:

  1. 有兩個 P,P1,P2。
  2. 如果 P1 的 G 都執行完了,LRQ 為空,P1 就開始任務竊取。
  3. 第一種情況,P1從 GRQ 獲取 G。
  4. 第二種情況,P1 從 GRQ 沒有獲取到 G,則 P1 從 P2 LRQ 中竊取G。

hand off 機制是防止 M 阻塞,任務竊取是防止 M 空閒。

Java 虛擬執行緒排程

基於作業系統執行緒實現的平臺執行緒,JDK 依賴於作業系統中的執行緒排程程式來進行排程。而對於虛擬執行緒,JDK 有自己的排程器。JDK 的排程器沒有直接將虛擬執行緒分配給系統執行緒,而是將虛擬執行緒分配給平臺執行緒(這是前面提到的虛擬執行緒的 M:N 排程)。平臺執行緒由作業系統的執行緒排程系統排程。

JDK 的虛擬執行緒排程器是一個在 FIFO 模式下執行的類似ForkJoinPool的執行緒池。排程器的並行數量取決於排程器虛擬執行緒的平臺執行緒數量。預設情況下是 CPU 可用核心數量,但可以使用系統屬性jdk.virtualThreadScheduler.parallelism進行調整。注意,這裡的ForkJoinPoolForkJoinPool.commonPool()不同,ForkJoinPool.commonPool()用於實現並行流,並在 LIFO 模式下執行。

ForkJoinPoolExecutorService的工作方式不同,ExecutorService有一個等待佇列來儲存它的任務,其中的執行緒將接收並處理這些任務。而ForkJoinPool的每一個執行緒都有一個等待佇列,當一個由執行緒執行的任務生成另一個任務時,該任務被新增到該執行緒的等待佇列中,當我們執行Parallel Stream,一個大任務劃分成兩個小任務時就會發生這種情況。

為了防止執行緒飢餓問題,當一個執行緒的等待佇列中沒有更多的任務時,ForkJoinPool還實現了另一種模式,稱為任務竊取, 也就是說:飢餓執行緒可以從另一個執行緒的等待佇列中竊取一些任務。這和 Go G-M-P 模型中 work stealing 機制有異曲同工之妙。

虛擬執行緒的執行

通常,當虛擬執行緒執行 I/O 或 JDK 中的其他阻止操作(如BlockingQueue.take()時,虛擬執行緒會從平臺執行緒上解除安裝。當阻塞操作準備完成時(例如,網路 IO 已收到位元組資料),排程程式將虛擬執行緒掛載到平臺執行緒上以恢復執行。

JDK 中的絕大多數阻塞操作會將虛擬執行緒從平臺執行緒上解除安裝,使平臺執行緒能夠執行其他工作任務。但是,JDK 中的少數阻塞操作不會解除安裝虛擬執行緒,因此會阻塞平臺執行緒。因為作業系統級別(例如許多檔案系統操作)或 JDK 級別(例如Object.wait())的限制。這些阻塞操作阻塞平臺執行緒時,將通過暫時增加平臺執行緒的數量來補償其他平臺執行緒阻塞的損失。因此,排程器的ForkJoinPool中的平臺執行緒數量可能會暫時超過 CPU 可用核心數量。排程器可用的平臺執行緒的最大數量可以使用系統屬性jdk.virtualThreadScheduler.maxPoolSize進行調整。這個阻塞補償機制與 Go G-M-P 模型中 hand off 機制有異曲同工之妙。

在以下兩種情況下,虛擬執行緒會被固定到執行它的平臺執行緒,在阻塞操作期間無法解除安裝虛擬執行緒:

  1. 當在synchronized塊或方法中執行程式碼時。
  2. 當執行native方法或foreign function時。

虛擬執行緒被固定不會影響程式執行的正確性,但它可能會影響系統的併發度和吞吐量。如果虛擬執行緒在被固定時執行 I/O或BlockingQueue.take() 等阻塞操作,則負責執行它的平臺執行緒在操作期間會被阻塞。(如果虛擬執行緒沒有被固定,那會執行 I/O 等阻塞操作時會從平臺執行緒上解除安裝)

如何解除安裝虛擬執行緒

我們通過 Stream 建立 5 個未啟動的虛擬執行緒,這些執行緒的任務是:列印當前執行緒,然後休眠 10 毫秒,然後再次列印執行緒。然後啟動這些虛擬執行緒,並呼叫jion()以確保控制檯可以看到所有內容:

public static void main(String[] args) throws Exception {
  var threads = IntStream.range(0, 5).mapToObj(index -> Thread.ofVirtual().unstarted(() -> {
    System.out.println(Thread.currentThread());
    try {
      Thread.sleep(10);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    System.out.println(Thread.currentThread());
  })).toList();

  threads.forEach(Thread::start);
  for (Thread thread : threads) {
    thread.join();
  }
}
//output
src [main] ~/Downloads/jdk-19.jdk/Contents/Home/bin/java --enable-preview main7                   
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3

由控制檯輸出,我們可以發現,VirtualThread[#21] 首先執行在 ForkJoinPool 的執行緒 1 上,當它從 sleep 中返回時,繼續線上程 4 上執行。

sleep 之後為什麼虛擬執行緒從一個平臺執行緒跳轉到另一個平臺執行緒?

我們閱讀一下 sleep 方法的原始碼,會發現在 Java19 中 sleep 方法被重寫了,重寫後的方法裡還增加了虛擬執行緒相關的判斷:

public static void sleep(long millis) throws InterruptedException {
  if (millis < 0) {
    throw new IllegalArgumentException("timeout value is negative");
  }

  if (currentThread() instanceof VirtualThread vthread) {
    long nanos = MILLISECONDS.toNanos(millis);
    vthread.sleepNanos(nanos);
    return;
  }

  if (ThreadSleepEvent.isTurnedOn()) {
    ThreadSleepEvent event = new ThreadSleepEvent();
    try {
      event.time = MILLISECONDS.toNanos(millis);
      event.begin();
      sleep0(millis);
    } finally {
      event.commit();
    }
  } else {
    sleep0(millis);
  }
}

深追程式碼發現,虛擬執行緒 sleep 時真正呼叫的方法是 Continuation.yield

@ChangesCurrentThread
private boolean yieldContinuation() {
  boolean notifyJvmti = notifyJvmtiEvents;
  // unmount
  if (notifyJvmti) notifyJvmtiUnmountBegin(false);
  unmount();
  try {
    return Continuation.yield(VTHREAD_SCOPE);
  } finally {
    // re-mount
    mount();
    if (notifyJvmti) notifyJvmtiMountEnd(false);
  }
}

也就是說 Continuation.yield 會將當前虛擬執行緒的堆疊由平臺執行緒的堆疊轉移到 Java 堆記憶體,然後將其他就緒虛擬執行緒的堆疊由 Java 堆中拷貝到當前平臺執行緒的堆疊中繼續執行。執行 IO 或BlockingQueue.take() 等阻塞操作時會跟 sleep 一樣導致虛擬執行緒切換。虛擬執行緒的切換也是一個相對耗時的操作,但是與平臺執行緒的上下文切換相比,還是輕量很多的。

其他

虛擬執行緒與非同步程式設計

響應式程式設計解決了平臺執行緒需要阻塞等待其他系統響應的問題。使用非同步 API 不會阻塞等待響應,而是通過回撥通知結果。當響應到達時,JVM 將從執行緒池中分配另一個執行緒來處理響應。這樣,處理單個非同步請求會涉及多個執行緒

在非同步程式設計中,我們可以降低系統的響應延遲,但由於硬體限制,平臺執行緒的數量仍然有限,因此我們的系統吞吐量仍有瓶頸。另一個問題是,非同步程式在不同的執行緒中執行,很難除錯或分析它們

虛擬執行緒通過較小的語法調整來提高程式碼質量(降低編碼、除錯、分析程式碼的難度),同時具有響應式程式設計的優點,能大幅提高系統吞吐量。

不要池化虛擬執行緒

因為虛擬執行緒非常輕量,每個虛擬執行緒都打算在其生命週期內只執行單個任務,所以沒有池化虛擬執行緒的必要。

虛擬執行緒下的 ThreadLocal

public class main {
    private static ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();

    public static void getThreadLocal(String val) {
        stringThreadLocal.set(val);
        System.out.println(stringThreadLocal.get());
    }

    public static void main(String[] args) throws InterruptedException {

        Thread testVT1 = Thread.ofVirtual().name("testVT1").unstarted(() ->main5.getThreadLocal("testVT1 local var"));

        Thread testVT2 = Thread.ofVirtual().name("testVT2").unstarted(() ->main5.getThreadLocal("testVT2 local var"));

        testVT1.start();
        testVT2.start();

        System.out.println(stringThreadLocal.get());
        stringThreadLocal.set("main local var");
        System.out.println(stringThreadLocal.get());
      	testVT1.join();
        testVT2.join();
    }
}
//output
null
main local var
testVT1 local var
testVT2 local var

虛擬執行緒支援 ThreadLocal 的方式與平臺執行緒相同,平臺執行緒不能獲取到虛擬執行緒設定的變數,虛擬執行緒也不能獲取到平臺執行緒設定的變數,對虛擬執行緒而言,負責執行虛擬執行緒的平臺執行緒是透明的。但是由於虛擬執行緒可以建立數百萬個,在虛擬執行緒中使用 ThreadLocal 請三思而後行。如果我們在應用程式中建立一百萬個虛擬執行緒,那麼將會有一百萬個 ThreadLocal 例項以及它們引用的資料。大量的物件可能會給記憶體帶來較大的負擔。

使用 ReentrantLock 替換 Synchronized

因為 Synchronized 會使虛擬執行緒被固定在平臺執行緒上,導致阻塞操作不會解除安裝虛擬執行緒,影響程式的吞吐量,所以需要使用ReentrantLock 替換 Synchronized:

befor:

public synchronized void m() {
	try {
	 	// ... access resource
	} finally {
	 	//
	}
}

after:

private final ReentrantLock lock = new ReentrantLock();

public void m() {
	lock.lock();  // block until condition holds
	try {
	 	// ... access resource
	} finally {
	 	lock.unlock();
	}
}

如何遷移

  1. 直接替換執行緒池為虛擬執行緒池。如果你的專案使用了 CompletableFuture 你也可以直接替換執行非同步任務的執行緒池為Executors.newVirtualThreadPerTaskExecutor()

  2. 取消池化機制。虛擬執行緒非常輕量級,無需池化。

  3. synchronized 改為 ReentrantLock,以減少虛擬執行緒被固定到平臺執行緒。

總結

本文描述了 Java 執行緒模型、Java 虛擬執行緒的使用、原理以及適用場景,也與風靡的 Go 協程做了比較,也能找到兩種實現上的相似之處,希望能幫助你理解 Java 虛擬執行緒。Java19 虛擬執行緒是預覽特性,很有可能在 Java21 成為正式特性,未來可期。筆者水平有限,如有寫的不好的地方還請大家批評指正。

參考

https://openjdk.org/jeps/425

https://howtodoinjava.com/java/multi-threading/virtual-threads/

https://mccue.dev/pages/5-2-22-go-concurrency-in-java

公眾號:DailyHappy 一位後端寫碼師,一位黑暗料理製造者。