Java19 正式 GA僻他!看虛擬線程如何大幅提高系統(tǒng)吞吐量

今天Java19 正式發(fā)布,帶來了一個(gè) Java 開發(fā)者垂涎已久的新特性—— 虛擬線程环揽。在 Java 有這個(gè)新特性之前略荡,Go語言的協(xié)程風(fēng)靡已久,在并發(fā)編程領(lǐng)域可以說是叱咤風(fēng)云歉胶。隨著國內(nèi) Go 語言的快速發(fā)展與推廣汛兜,協(xié)程好像成為了一個(gè)世界上最好語言的必備特性之一。Java19 虛擬線程就是來彌補(bǔ)這個(gè)空白的通今。本文將通過對虛擬線程的介紹粥谬,以及與 Go 協(xié)程的對比來帶大家嘗鮮 Java19 虛擬線程。

本文要點(diǎn):
  • Java 線程模型
  • 平臺線程與虛擬線程性能對比
  • Java 虛擬線程與 Go 協(xié)程對比
  • 如何使用虛擬線程

Java 線程模型

java 線程 與 虛擬線程

我們常用的 Java 線程與系統(tǒng)內(nèi)核線程是一一對應(yīng)的衡创,系統(tǒng)內(nèi)核的線程調(diào)度程序負(fù)責(zé)調(diào)度 Java 線程帝嗡。為了增加應(yīng)用程序的性能,我們會增加越來越多的 Java 線程璃氢,顯然系統(tǒng)調(diào)度 Java 線程時(shí)哟玷,會占據(jù)不少資源去處理線程上下文切換。

image-20220920172646775.png

近幾十年來一也,我們一直依賴上述多線程模型來解決 Java 并發(fā)編程的問題巢寡。為了增加系統(tǒng)的吞吐量,我們要不斷增加線程的數(shù)量椰苟,但機(jī)器的線程是昂貴的抑月、可用線程數(shù)量也是有限的。即使我們使用了各種線程池來最大化線程的性價(jià)比舆蝴,但是線程往往會在 CPU谦絮、網(wǎng)絡(luò)或者內(nèi)存資源耗盡之前成為我們應(yīng)用程序的性能提升瓶頸题诵,不能最大限度的釋放硬件應(yīng)該具有的性能。

image-20220920173340443.png

為了解決這個(gè)問題 Java19 引入了虛擬線程(Virtual Thread)层皱。在 Java19 中性锭,之前我們常用的線程叫做平臺線程(platform thread),與系統(tǒng)內(nèi)核線程仍然是一一對應(yīng)的叫胖。其中大量(M)的虛擬線程在較小數(shù)量(N)的平臺線程(與操作系統(tǒng)線程一一對應(yīng))上運(yùn)行(M:N調(diào)度)草冈。多個(gè)虛擬線程會被 JVM 調(diào)度到某一個(gè)平臺線程上執(zhí)行,一個(gè)平臺線程同時(shí)只會執(zhí)行一個(gè)虛擬線程瓮增。

創(chuàng)建 Java 虛擬線程

新增線程相關(guān) API

Thread.ofVirtual()Thread.ofPlatform()是創(chuàng)建虛擬和平臺線程的新API:

//輸出線程ID 包括虛擬線程和系統(tǒng)線程 Thread.getId() 從jdk19廢棄
Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
//創(chuàng)建虛擬線程
Thread thread = Thread.ofVirtual().name("testVT").unstarted(runnable);
testVT.start();
//創(chuàng)建虛平臺線程
Thread testPT = Thread.ofPlatform().name("testPT").unstarted(runnable);
testPT.start();

使用Thread.startVirtualThread(Runnable)快速創(chuàng)建虛擬線程并啟動:

//輸出線程ID 包括虛擬線程和系統(tǒng)線程
Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
Thread thread = Thread.startVirtualThread(runnable);

Thread.isVirtual()判斷線程是否為虛擬線程:

//輸出線程ID 包括虛擬線程和系統(tǒng)線程
Runnable runnable = () -> System.out.println(Thread.currentThread().isVirtual());
Thread thread = Thread.startVirtualThread(runnable);

Thread.joinThread.sleep等待虛擬線程結(jié)束怎棱、使虛擬線程 sleep:

Runnable runnable = () -> System.out.println(Thread.sleep(10));
Thread thread = Thread.startVirtualThread(runnable);
//等待虛擬線程結(jié)束
thread.join();

Executors.newVirtualThreadPerTaskExecutor()創(chuàng)建一個(gè) ExecutorService,該 ExecutorService 為每個(gè)任務(wù)創(chuàng)建一個(gè)新的虛擬線程:

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

支持與使用線程池和 ExecutorService 的現(xiàn)有代碼互相替換绷跑、遷移拳恋。

注意:

因?yàn)樘摂M線程在 Java19 中是預(yù)覽特性,所以本文出現(xiàn)的代碼需按以下方式運(yùn)行:

  • 使用javac --release 19 --enable-preview Main.java編譯程序砸捏,并使用java --enable-preview Main運(yùn)行诅岩;
  • 或者使用java --source 19 --enable-preview Main.java運(yùn)行程序;

是騾子是馬

既然是為了解決平臺線程的問題带膜,那我們就直接測試平臺線程與虛擬線程的性能吩谦。

測試內(nèi)容很簡單,并行執(zhí)行一萬個(gè) sleep 一秒的任務(wù)膝藕,對比總的執(zhí)行時(shí)間和所用系統(tǒng)線程數(shù)量式廷。

為了監(jiān)控測試所用系統(tǒng)線程的數(shù)量,編寫如下代碼:

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);

調(diào)度線程池每一秒鐘獲取并打印系統(tǒng)線程數(shù)量芭挽,便于觀察線程的數(shù)量滑废。

public static void main(String[] args) {
  //記錄系統(tǒng)線程數(shù)
  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("耗時(shí):%d ms", System.currentTimeMillis() - l);
}

首先我們使用Executors.newCachedThreadPool()來執(zhí)行10000個(gè)任務(wù),因?yàn)?newCachedThreadPool 的最大線程數(shù)量是Integer.MAX_VALUE袜爪,所以理論上至少會創(chuàng)建大幾千個(gè)系統(tǒng)線程來執(zhí)行蠕趁。

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

//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)

從上述輸出可以看到,最高創(chuàng)建了 3914 個(gè)系統(tǒng)線程辛馆,然后繼續(xù)創(chuàng)建線程時(shí)異常俺陋,程序終止。我們想通過大量系統(tǒng)線程提高系統(tǒng)的性能是不現(xiàn)實(shí)的昙篙,因?yàn)榫€程昂貴腊状,資源有限。

現(xiàn)在我們使用固定大小為 200 的線程池來解決不能申請?zhí)嘞到y(tǒng)線程的問題:

public static void main(String[] args) {
  //記錄系統(tǒng)線程數(shù)
  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("耗時(shí):%dms\n", System.currentTimeMillis() - l);
}

輸出如下:

//output
1
9987
9998
207 os thread
耗時(shí):50436ms

使用固定大小線程池后沒有了創(chuàng)建大量系統(tǒng)線程導(dǎo)致失敗的問題苔可,能正常跑完任務(wù)缴挖,最高創(chuàng)建了 207 個(gè)系統(tǒng)線程,共耗時(shí) 50436ms焚辅。

再來看看使用虛擬線程的結(jié)果:

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("耗時(shí):%dms\n", System.currentTimeMillis() - l);
}

使用虛擬線程的代碼和使用固定大小的只有一詞只差映屋,將Executors.newFixedThreadPool(200)替換為Executors.newVirtualThreadPerTaskExecutor()苟鸯。

輸出結(jié)果如下:

//output
1
9890
15 os thread
耗時(shí):1582ms

由輸出可見,執(zhí)行總耗時(shí) 1582 ms棚点,最高使用系統(tǒng)線程 15 個(gè)倔毙。結(jié)論很明顯,使用虛擬線程比平臺線程要快很多乙濒,并且使用的系統(tǒng)線程資源要更少。

如果我們把剛剛這個(gè)測試程序中的任務(wù)換成執(zhí)行了一秒鐘的計(jì)算(例如卵蛉,對一個(gè)巨大的數(shù)組進(jìn)行排序)颁股,而不僅僅是 sleep 1秒鐘,即使我們把虛擬線程或者平臺線程的數(shù)量增加到遠(yuǎn)遠(yuǎn)大于處理器內(nèi)核數(shù)量都不會有明顯的性能提升傻丝。因?yàn)樘摂M線程不是更快的線程甘有,它們運(yùn)行代碼的速度與平臺線程相比并無優(yōu)勢。虛擬線程的存在是為了提供更高的吞吐量葡缰,而不是速度(更低的延遲)亏掀。

如果你的應(yīng)用程序符合下面兩點(diǎn)特征,使用虛擬線程可以顯著提高程序吞吐量:

  • 程序并發(fā)任務(wù)數(shù)量很高泛释。
  • IO密集型滤愕、工作負(fù)載不受 CPU 約束。

虛擬線程有助于提高服務(wù)端應(yīng)用程序的吞吐量怜校,因?yàn)榇祟悜?yīng)用程序有大量并發(fā)间影,而且這些任務(wù)通常會有大量的 IO 等待。

Java vs Go

使用方式對比

Go 協(xié)程對比 Java 虛擬線程

定義一個(gè) say() 方法茄茁,方法體是循環(huán) sleep 100ms魂贬,然后輸出index,將這個(gè)方法使用協(xié)程執(zhí)行裙顽。

Go 實(shí)現(xiàn):

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 實(shí)現(xiàn):

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");
        
        // 等待虛擬線程結(jié)束
        worldThread.join();
    }
}

可以看到兩種語言協(xié)程的寫法很相似付燥,總體來說 Java 虛擬線程的寫法稍微麻煩一點(diǎn),Go 使用一個(gè)關(guān)鍵字就能方便的創(chuàng)建協(xié)程愈犹。

Go 管道對比 Java 阻塞隊(duì)列

在 Go 語言編程中键科,協(xié)程與管道的配合相得益彰,使用協(xié)程計(jì)算數(shù)組元素的和(分治思想):

Go 實(shí)現(xiàn):

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 實(shí)現(xiàn):

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);
    }
}

因?yàn)?Java 中沒有數(shù)組切片漩怎,所以使用數(shù)組和下標(biāo)來代替萝嘁。Java 中沒有管道,用與管道相似的 BlockingQueue 來代替扬卷,可以實(shí)現(xiàn)功能牙言。

協(xié)程實(shí)現(xiàn)原理對比

GO G-M-P 模型

Go 語言采用兩級線程模型,協(xié)程與系統(tǒng)內(nèi)核線程是 M:N 的怪得,這一點(diǎn)與 Java 虛擬線程一致咱枉。最終 goroutine 還是會交給 OS 線程執(zhí)行卑硫,但是需要一個(gè)中介,提供上下文蚕断。這就是 G-M-P 模型欢伏。

  • G: goroutine, 類似進(jìn)程控制塊,保存棧亿乳,狀態(tài)硝拧,id,函數(shù)等信息葛假。G 只有綁定到 P 才可以被調(diào)度障陶。

  • M: machine, 系統(tǒng)線程,綁定有效的 P 之后聊训,進(jìn)行調(diào)度抱究。

  • P: 邏輯處理器,保存各種隊(duì)列 G带斑。對于 G 而言鼓寺,P 就是 cpu 核心。對于 M 而言勋磕,P 就是上下文妈候。

  • sched: 調(diào)度程序,保存 GRQ(全局運(yùn)行隊(duì)列)挂滓,M 空閑隊(duì)列州丹,P 空閑隊(duì)列以及 lock 等信息。

image-20220920115604057.png
隊(duì)列

Go 調(diào)度器有兩個(gè)不同的運(yùn)行隊(duì)列:

  • GRQ杂彭,全局運(yùn)行隊(duì)列墓毒,尚未分配給 P 的 G(在 Go1.1 之前只有 GRO 全局運(yùn)行隊(duì)列,但是因?yàn)槿株?duì)列加鎖的性能問題加上了LRQ亲怠,以減少鎖等待)所计。
  • LRQ,本地運(yùn)行隊(duì)列团秽,每個(gè) P 都有一個(gè) LRQ主胧,用于管理分配給P執(zhí)行的 G。當(dāng) LRQ 中沒有待執(zhí)行的 G 時(shí)會從 GRQ 中獲取习勤。
hand off 機(jī)制

當(dāng) G 執(zhí)行阻塞操作時(shí)踪栋,G-M-P 為了防止阻塞 M,影響 LRQ 中其他 G 的執(zhí)行图毕,會調(diào)度空閑 M 來執(zhí)行阻塞 M LRQ 中的其他 G:

  1. G1 在 M1 上運(yùn)行夷都,P 的 LRQ 有其他 3 個(gè) G;
  2. G1 進(jìn)行同步調(diào)用予颤,阻塞 M囤官;
  3. 調(diào)度器將 M1 與 P 分離冬阳,此時(shí) M1 下只運(yùn)行 G1,沒有 P党饮。
  4. 將 P 與空閑 M2 綁定肝陪,M2 從 LRQ 選擇其他 G 運(yùn)行。
  5. G1 結(jié)束堵塞操作刑顺,移回 LRQ氯窍。M1 會被放置到空閑隊(duì)列中備用。
work stealing機(jī)制

G-M-P 為了最大限度釋放硬件性能蹲堂,當(dāng) M 空閑時(shí)會使用任務(wù)竊取機(jī)制執(zhí)行其他等待執(zhí)行的 G:

  1. 有兩個(gè) P狼讨,P1,P2贯城。
  2. 如果 P1 的 G 都執(zhí)行完了,LRQ 為空霹娄,P1 就開始任務(wù)竊取能犯。
  3. 第一種情況,P1從 GRQ 獲取 G犬耻。
  4. 第二種情況踩晶,P1 從 GRQ 沒有獲取到 G,則 P1 從 P2 LRQ 中竊取G枕磁。

hand off 機(jī)制是防止 M 阻塞渡蜻,任務(wù)竊取是防止 M 空閑。

Java 虛擬線程調(diào)度

基于操作系統(tǒng)線程實(shí)現(xiàn)的平臺線程计济,JDK 依賴于操作系統(tǒng)中的線程調(diào)度程序來進(jìn)行調(diào)度茸苇。而對于虛擬線程,JDK 有自己的調(diào)度器沦寂。JDK 的調(diào)度器沒有直接將虛擬線程分配給系統(tǒng)線程学密,而是將虛擬線程分配給平臺線程(這是前面提到的虛擬線程的 M:N 調(diào)度)。平臺線程由操作系統(tǒng)的線程調(diào)度系統(tǒng)調(diào)度传藏。

JDK 的虛擬線程調(diào)度器是一個(gè)在 FIFO 模式下運(yùn)行的類似ForkJoinPool的線程池腻暮。調(diào)度器的并行數(shù)量取決于調(diào)度器虛擬線程的平臺線程數(shù)量。默認(rèn)情況下是 CPU 可用核心數(shù)量毯侦,但可以使用系統(tǒng)屬性jdk.virtualThreadScheduler.parallelism進(jìn)行調(diào)整哭靖。注意,這里的ForkJoinPoolForkJoinPool.commonPool()不同侈离,ForkJoinPool.commonPool()用于實(shí)現(xiàn)并行流试幽,并在 LIFO 模式下運(yùn)行。

ForkJoinPoolExecutorService的工作方式不同卦碾,ExecutorService有一個(gè)等待隊(duì)列來存儲它的任務(wù)抡草,其中的線程將接收并處理這些任務(wù)饰及。而ForkJoinPool的每一個(gè)線程都有一個(gè)等待隊(duì)列,當(dāng)一個(gè)由線程運(yùn)行的任務(wù)生成另一個(gè)任務(wù)時(shí)康震,該任務(wù)被添加到該線程的等待隊(duì)列中燎含,當(dāng)我們運(yùn)行Parallel Stream,一個(gè)大任務(wù)劃分成兩個(gè)小任務(wù)時(shí)就會發(fā)生這種情況腿短。

為了防止線程饑餓問題屏箍,當(dāng)一個(gè)線程的等待隊(duì)列中沒有更多的任務(wù)時(shí),ForkJoinPool還實(shí)現(xiàn)了另一種模式橘忱,稱為任務(wù)竊取赴魁, 也就是說:饑餓線程可以從另一個(gè)線程的等待隊(duì)列中竊取一些任務(wù)。這和 Go G-M-P 模型中 work stealing 機(jī)制有異曲同工之妙钝诚。

image-20220921113049916.png
虛擬線程的執(zhí)行

通常颖御,當(dāng)虛擬線程執(zhí)行 I/O 或 JDK 中的其他阻止操作(如BlockingQueue.take()時(shí),虛擬線程會從平臺線程上卸載凝颇。當(dāng)阻塞操作準(zhǔn)備完成時(shí)(例如潘拱,網(wǎng)絡(luò) IO 已收到字節(jié)數(shù)據(jù)),調(diào)度程序?qū)⑻摂M線程掛載到平臺線程上以恢復(fù)執(zhí)行拧略。

JDK 中的絕大多數(shù)阻塞操作會將虛擬線程從平臺線程上卸載芦岂,使平臺線程能夠執(zhí)行其他工作任務(wù)。但是垫蛆,JDK 中的少數(shù)阻塞操作不會卸載虛擬線程禽最,因此會阻塞平臺線程。因?yàn)椴僮飨到y(tǒng)級別(例如許多文件系統(tǒng)操作)或 JDK 級別(例如Object.wait())的限制袱饭。這些阻塞操作阻塞平臺線程時(shí)川无,將通過暫時(shí)增加平臺線程的數(shù)量來補(bǔ)償其他平臺線程阻塞的損失。因此虑乖,調(diào)度器的ForkJoinPool中的平臺線程數(shù)量可能會暫時(shí)超過 CPU 可用核心數(shù)量舀透。調(diào)度器可用的平臺線程的最大數(shù)量可以使用系統(tǒng)屬性jdk.virtualThreadScheduler.maxPoolSize進(jìn)行調(diào)整。這個(gè)阻塞補(bǔ)償機(jī)制與 Go G-M-P 模型中 hand off 機(jī)制有異曲同工之妙决左。

在以下兩種情況下愕够,虛擬線程會被固定到運(yùn)行它的平臺線程,在阻塞操作期間無法卸載虛擬線程:

  1. 當(dāng)在synchronized塊或方法中執(zhí)行代碼時(shí)佛猛。
  2. 當(dāng)執(zhí)行native方法或foreign function時(shí)惑芭。

虛擬線程被固定不會影響程序運(yùn)行的正確性,但它可能會影響系統(tǒng)的并發(fā)度和吞吐量继找。如果虛擬線程在被固定時(shí)執(zhí)行 I/O或BlockingQueue.take()等阻塞操作遂跟,則負(fù)責(zé)運(yùn)行它的平臺線程在操作期間會被阻塞。(如果虛擬線程沒有被固定,那會執(zhí)行 I/O 等阻塞操作時(shí)會從平臺線程上卸載)

如何卸載虛擬線程

我們通過 Stream 創(chuàng)建 5 個(gè)未啟動的虛擬線程幻锁,這些線程的任務(wù)是:打印當(dāng)前線程凯亮,然后休眠 10 毫秒,然后再次打印線程哄尔。然后啟動這些虛擬線程假消,并調(diào)用jion()以確保控制臺可以看到所有內(nèi)容:

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

由控制臺輸出岭接,我們可以發(fā)現(xiàn)富拗,VirtualThread[#21] 首先運(yùn)行在 ForkJoinPool 的線程 1 上鸣戴,當(dāng)它從 sleep 中返回時(shí)啃沪,繼續(xù)在線程 4 上運(yùn)行。

sleep 之后為什么虛擬線程從一個(gè)平臺線程跳轉(zhuǎn)到另一個(gè)平臺線程窄锅?

我們閱讀一下 sleep 方法的源碼创千,會發(fā)現(xiàn)在 Java19 中 sleep 方法被重寫了,重寫后的方法里還增加了虛擬線程相關(guān)的判斷:

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);
  }
}

深追代碼發(fā)現(xiàn)入偷,虛擬線程 sleep 時(shí)真正調(diào)用的方法是 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 會將當(dāng)前虛擬線程的堆棧由平臺線程的堆棧轉(zhuǎn)移到 Java 堆內(nèi)存追驴,然后將其他就緒虛擬線程的堆棧由 Java 堆中拷貝到當(dāng)前平臺線程的堆棧中繼續(xù)執(zhí)行。執(zhí)行 IO 或BlockingQueue.take()等阻塞操作時(shí)會跟 sleep 一樣導(dǎo)致虛擬線程切換盯串。虛擬線程的切換也是一個(gè)相對耗時(shí)的操作氯檐,但是與平臺線程的上下文切換相比戒良,還是輕量很多的体捏。

其他

虛擬線程與異步編程

響應(yīng)式編程解決了平臺線程需要阻塞等待其他系統(tǒng)響應(yīng)的問題。使用異步 API 不會阻塞等待響應(yīng)糯崎,而是通過回調(diào)通知結(jié)果几缭。當(dāng)響應(yīng)到達(dá)時(shí),JVM 將從線程池中分配另一個(gè)線程來處理響應(yīng)沃呢。這樣年栓,處理單個(gè)異步請求會涉及多個(gè)線程

在異步編程中薄霜,我們可以降低系統(tǒng)的響應(yīng)延遲某抓,但由于硬件限制,平臺線程的數(shù)量仍然有限惰瓜,因此我們的系統(tǒng)吞吐量仍有瓶頸否副。另一個(gè)問題是,異步程序在不同的線程中執(zhí)行崎坊,很難調(diào)試或分析它們备禀。

虛擬線程通過較小的語法調(diào)整來提高代碼質(zhì)量(降低編碼、調(diào)試、分析代碼的難度)曲尸,同時(shí)具有響應(yīng)式編程的優(yōu)點(diǎn)赋续,能大幅提高系統(tǒng)吞吐量。

不要池化虛擬線程

因?yàn)樘摂M線程非常輕量另患,每個(gè)虛擬線程都打算在其生命周期內(nèi)只運(yùn)行單個(gè)任務(wù)纽乱,所以沒有池化虛擬線程的必要。

虛擬線程下的 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 的方式與平臺線程相同柴淘,平臺線程不能獲取到虛擬線程設(shè)置的變量迫淹,虛擬線程也不能獲取到平臺線程設(shè)置的變量,對虛擬線程而言为严,負(fù)責(zé)運(yùn)行虛擬線程的平臺線程是透明的敛熬。但是由于虛擬線程可以創(chuàng)建數(shù)百萬個(gè),在虛擬線程中使用 ThreadLocal 請三思而后行第股。如果我們在應(yīng)用程序中創(chuàng)建一百萬個(gè)虛擬線程应民,那么將會有一百萬個(gè) ThreadLocal 實(shí)例以及它們引用的數(shù)據(jù)。大量的對象可能會給內(nèi)存帶來較大的負(fù)擔(dān)夕吻。

使用 ReentrantLock 替換 Synchronized

因?yàn)?Synchronized 會使虛擬線程被固定在平臺線程上诲锹,導(dǎo)致阻塞操作不會卸載虛擬線程,影響程序的吞吐量涉馅,所以需要使用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. 直接替換線程池為虛擬線程池归园。如果你的項(xiàng)目使用了 CompletableFuture 你也可以直接替換執(zhí)行異步任務(wù)的線程池為Executors.newVirtualThreadPerTaskExecutor()

  2. 取消池化機(jī)制稚矿。虛擬線程非常輕量級庸诱,無需池化。

  3. synchronized 改為 ReentrantLock晤揣,以減少虛擬線程被固定到平臺線程桥爽。

總結(jié)

本文描述了 Java 線程模型、Java 虛擬線程的使用昧识、原理以及適用場景钠四,也與風(fēng)靡的 Go 協(xié)程做了比較,也能找到兩種實(shí)現(xiàn)上的相似之處跪楞,希望能幫助你理解 Java 虛擬線程缀去。Java19 虛擬線程是預(yù)覽特性,很有可能在 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 一位后端寫碼師阎曹,一位黑暗料理制造者伪阶。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市处嫌,隨后出現(xiàn)的幾起案子栅贴,更是在濱河造成了極大的恐慌,老刑警劉巖熏迹,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件檐薯,死亡現(xiàn)場離奇詭異,居然都是意外死亡注暗,警方通過查閱死者的電腦和手機(jī)坛缕,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來捆昏,“玉大人赚楚,你說我怎么就攤上這事∑罚” “怎么了宠页?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長寇仓。 經(jīng)常有香客問我举户,道長,這世上最難降的妖魔是什么遍烦? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任俭嘁,我火速辦了婚禮,結(jié)果婚禮上服猪,老公的妹妹穿的比我還像新娘供填。我一直安慰自己,他們只是感情好蔓姚,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布捕虽。 她就那樣靜靜地躺著慨丐,像睡著了一般坡脐。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上房揭,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天备闲,我揣著相機(jī)與錄音,去河邊找鬼捅暴。 笑死恬砂,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的蓬痒。 我是一名探鬼主播泻骤,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了狱掂?” 一聲冷哼從身側(cè)響起演痒,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎趋惨,沒想到半個(gè)月后鸟顺,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡器虾,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年讯嫂,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片兆沙。...
    茶點(diǎn)故事閱讀 37,997評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡欧芽,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出葛圃,到底是詐尸還是另有隱情渐裸,我是刑警寧澤,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布装悲,位于F島的核電站昏鹃,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏诀诊。R本人自食惡果不足惜洞渤,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望属瓣。 院中可真熱鬧载迄,春花似錦、人聲如沸抡蛙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽粗截。三九已至惋耙,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間熊昌,已是汗流浹背绽榛。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留婿屹,地道東北人灭美。 一個(gè)月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像昂利,于是被迫代替她去往敵國和親届腐。 傳聞我的和親對象是個(gè)殘疾皇子铁坎,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評論 2 345

推薦閱讀更多精彩內(nèi)容