一文揭開JDK21虛擬執行緒的神秘面紗

小江的学习日记發表於2024-07-21

虛擬執行緒快速體驗

環境:JDK21 + IDEA

public static void main(String[] args) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        IntStream.range(0, 10_000).forEach(i -> {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));
                return i;
            });
        });
    } 
}

執行上面的程式碼看下執行時間,再試下 Executors.newFixedThreadPool(20) 和 Executors.newCachedThreadPool()

不出意外的話,會發現Executors.newVirtualThreadPerTaskExecutor()執行速度最快,Executors.newCachedThreadPool()執行時系統最卡頓,Executors.newFixedThreadPool(20) 最慢。

Executors.newCachedThreadPool()卡頓是因為一個任務建立一個Platform執行緒,佔用了太多系統資源。

Executors.newFixedThreadPool(20)執行慢是因為只有20個併發去執行1萬個任務

Executors.newVirtualThreadPerTaskExecutor()類似Executors.newCachedThreadPool(),但是建立的是虛擬執行緒,所以在獲得高併發的同時也沒有佔用太多系統資源。

為什麼引入虛擬執行緒

首先,我們來看看現在的Java執行緒是怎樣的。

java.lang.Thread 這個類我相信大家都不陌生,代表Java中的最小併發單元,即一個執行緒。它是Java對底層的作業系統執行緒(OS Thread)的封裝,為了區別於OS執行緒,我們稱之為平臺執行緒(Platform Thread)。當我們初始化一個Thread例項時,其實就是建立了一個Platform執行緒並將之與一個OS執行緒繫結(1:1)。

這種方式存在以下問題:

  1. OS執行緒是有限的,Platform執行緒的建立數量受限制於OS執行緒
  2. 因為繫結系統資源,因此執行緒的建立/銷燬的代價都是昂貴的

這兩個問題並非無解,比如,問題1的本質是垂直擴充套件到頂了,完全可以用水平擴充套件的方式解決,一臺機器的OS執行緒不能滿足需求,再增加一臺便是;問題2可以透過池化技術來解決,既然執行緒的建立和銷燬代價比較昂貴,那便將建立好的執行緒收集起來,推遲銷燬的時機,儘量複用它。

JDK21則是在語言層面上的提供了一個替代方案,也就是本文要介紹的虛擬執行緒(virtual thread),熟悉linux的同學肯定知道系統執行緒和使用者執行緒的區別,虛擬執行緒就像是JDK實現的“使用者執行緒”,下面來重點介紹。

什麼是虛擬執行緒

虛擬執行緒,可以看作是對Platform執行緒的輕量級封裝,Platform執行緒和OS執行緒的關係是1:1,虛擬執行緒和Platform執行緒的關係則是M:N,且一般M要遠遠大於N。

可以直接看下虛擬執行緒的建構函式原始碼加深理解,座標java.lang.VirtualThread#

虛擬執行緒例項化


final class VirtualThread extends BaseVirtualThread {
    VirtualThread(Executor scheduler, String name, int characteristics, Runnable task) {
        super(name, characteristics, /*bound*/ false);
        Objects.requireNonNull(task);

        // choose scheduler if not specified
        if (scheduler == null) {
            Thread parent = Thread.currentThread();
            if (parent instanceof VirtualThread vparent) {
                scheduler = vparent.scheduler;
            } else {
                scheduler = DEFAULT_SCHEDULER;
            }
        }

        this.scheduler = scheduler;
        this.cont = new VThreadContinuation(this, task);
        this.runContinuation = this::runContinuation;
    }
}

private static ForkJoinPool createDefaultScheduler() {
        ForkJoinWorkerThreadFactory factory = pool -> {
            PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);
            return AccessController.doPrivileged(pa);
        };
        PrivilegedAction<ForkJoinPool> pa = () -> {
            int parallelism, maxPoolSize, minRunnable;
            String parallelismValue = System.getProperty("jdk.virtualThreadScheduler.parallelism");
            String maxPoolSizeValue = System.getProperty("jdk.virtualThreadScheduler.maxPoolSize");
            String minRunnableValue = System.getProperty("jdk.virtualThreadScheduler.minRunnable");
            if (parallelismValue != null) {
                parallelism = Integer.parseInt(parallelismValue);
            } else {
                parallelism = Runtime.getRuntime().availableProcessors();
            }
            if (maxPoolSizeValue != null) {
                maxPoolSize = Integer.parseInt(maxPoolSizeValue);
                parallelism = Integer.min(parallelism, maxPoolSize);
            } else {
                maxPoolSize = Integer.max(parallelism, 256);
            }
            if (minRunnableValue != null) {
                minRunnable = Integer.parseInt(minRunnableValue);
            } else {
                minRunnable = Integer.max(parallelism / 2, 1);
            }
            Thread.UncaughtExceptionHandler handler = (t, e) -> { };
            boolean asyncMode = true; // FIFO
            return new ForkJoinPool(parallelism, factory, handler, asyncMode,
                         0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
        };
        return AccessController.doPrivileged(pa);
    }

可以看到,建立虛擬執行緒的時候,使用了一個預設的排程器(ForkJoinPool),也就是Platform的執行緒池,可以看到池子的幾個配置引數。

  1. 最大Platform執行緒數:預設為系統核心數,最大為256,可以透過jdk.virtualThreadScheduler.maxPoolSize設定

這個時候,愛思考的同學可能就要問了,既然預設的最大Platform執行緒數為系統核心數,豈不是大大限制了併發能力?是不是要主動設定一個較大值?

答案是不需要,因為JDK線上程池的基礎上實現了排程的功能。當虛擬執行緒啟動時,排程器會將虛擬執行緒mount到Platform執行緒,此時該Platform執行緒被稱為這個虛擬執行緒的carrier;當執行緒執行遇到IO操作需要等待時,排程器又會將虛擬現場unmount,把Platform執行緒釋放出來給其他虛擬執行緒使用,不佔用CPU時間。因此,對於非CPU密集的應用,很少的Platform執行緒就能支援大量的虛擬執行緒來執行任務。事實上,對於CPU密集的應用,虛擬執行緒並不會帶來多大的提升。虛擬執行緒真正的應用場景是生存週期短、呼叫棧淺的任務,如一次http請求、一次JDBC查詢。

需要明確的是,作業系統真正能同時運算的執行緒數也就只有邏輯CPU數,多出來的執行緒只能等待系統的排程獲得CPU時間。

虛擬執行緒狀態

stateDiagram-v2 NEW --> STARTED STARTED --> TERMINATED STARTED --> RUNNING RUNNING --> TERMINATED RUNNING --> PARKING PARKING --> PARKED PARKING --> PINNED PARKED --> UNPARKED PINNED --> RUNNING UNPARKED --> RUNNING

可以看出,虛擬執行緒相較原先的執行緒狀態,多了Parked、Unparked、Pinned等狀態

  • Parked:就是前面說的mount

  • Unparked:就是前面說的unmount

  • Pinned:虛擬執行緒阻塞時,正常會unmount,但是在一些特殊場景下,不能unmount,此時就會進入Pinned狀態:

    1. 阻塞操作在 synchronized 程式碼塊中(後續JDK可能最佳化這一點限制)
    2. 執行 native 方法時

    Pinned狀態佔用了Platform執行緒,無疑會影響效能,官方建議對於經常執行的 synchronized 程式碼塊,最好使用java.util.concurrent.locks.ReentrantLock 替代。如果不清楚自己程式碼裡哪些地方使用到了 synchronized 程式碼塊,在切換使用虛擬執行緒時,可以新增JVM引數jdk.tracePinnedThreads幫助排查。

總結

虛擬執行緒特別適用如下場景:有大量的併發任務需要執行,且任務是非CPU密集的。

虛擬執行緒使用上和普通的執行緒沒有太大區別,甚至因為內建了排程邏輯和執行緒池,可以讓開發人員不用再考慮執行緒池的大小、拒絕策略等,尤其給框架開發者提供了新的最佳化思路。

對於已經使用了reactive技術的如webFlux框架,沒必要再切換到虛擬執行緒,兩者效能相當。

對於web容器如tomcat來說,本身已經使用reactor、nio等技術最佳化吞吐量,在小的併發數場景下,沒必要切換虛擬執行緒,提升不大。

相關文章