今天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ù)不少資源去處理線程上下文切換。
近幾十年來一也,我們一直依賴上述多線程模型來解決 Java 并發(fā)編程的問題巢寡。為了增加系統(tǒng)的吞吐量,我們要不斷增加線程的數(shù)量椰苟,但機(jī)器的線程是昂貴的抑月、可用線程數(shù)量也是有限的。即使我們使用了各種線程池來最大化線程的性價(jià)比舆蝴,但是線程往往會在 CPU谦絮、網(wǎng)絡(luò)或者內(nèi)存資源耗盡之前成為我們應(yīng)用程序的性能提升瓶頸题诵,不能最大限度的釋放硬件應(yīng)該具有的性能。
為了解決這個(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.join
和Thread.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 等信息。
隊(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:
- G1 在 M1 上運(yùn)行夷都,P 的 LRQ 有其他 3 個(gè) G;
- G1 進(jìn)行同步調(diào)用予颤,阻塞 M囤官;
- 調(diào)度器將 M1 與 P 分離冬阳,此時(shí) M1 下只運(yùn)行 G1,沒有 P党饮。
- 將 P 與空閑 M2 綁定肝陪,M2 從 LRQ 選擇其他 G 運(yùn)行。
- G1 結(jié)束堵塞操作刑顺,移回 LRQ氯窍。M1 會被放置到空閑隊(duì)列中備用。
work stealing機(jī)制
G-M-P 為了最大限度釋放硬件性能蹲堂,當(dāng) M 空閑時(shí)會使用任務(wù)竊取機(jī)制執(zhí)行其他等待執(zhí)行的 G:
- 有兩個(gè) P狼讨,P1,P2贯城。
- 如果 P1 的 G 都執(zhí)行完了,LRQ 為空霹娄,P1 就開始任務(wù)竊取能犯。
- 第一種情況,P1從 GRQ 獲取 G犬耻。
- 第二種情況踩晶,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)整哭靖。注意,這里的ForkJoinPool
與ForkJoinPool.commonPool()
不同侈离,ForkJoinPool.commonPool()
用于實(shí)現(xiàn)并行流试幽,并在 LIFO 模式下運(yùn)行。
ForkJoinPool
和ExecutorService
的工作方式不同卦碾,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ī)制有異曲同工之妙钝诚。
虛擬線程的執(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)行它的平臺線程,在阻塞操作期間無法卸載虛擬線程:
- 當(dāng)在
synchronized
塊或方法中執(zhí)行代碼時(shí)佛猛。 - 當(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();
}
}
如何遷移
直接替換線程池為虛擬線程池归园。如果你的項(xiàng)目使用了
CompletableFuture
你也可以直接替換執(zhí)行異步任務(wù)的線程池為Executors.newVirtualThreadPerTaskExecutor()
。取消池化機(jī)制稚矿。虛擬線程非常輕量級庸诱,無需池化。
synchronized
改為ReentrantLock
晤揣,以減少虛擬線程被固定到平臺線程桥爽。
總結(jié)
本文描述了 Java 線程模型、Java 虛擬線程的使用昧识、原理以及適用場景钠四,也與風(fēng)靡的 Go 協(xié)程做了比較,也能找到兩種實(shí)現(xiàn)上的相似之處跪楞,希望能幫助你理解 Java 虛擬線程缀去。Java19 虛擬線程是預(yù)覽特性,很有可能在 Java21 成為正式特性甸祭,未來可期缕碎。筆者水平有限,如有寫的不好的地方還請大家批評指正淋叶。
參考
https://howtodoinjava.com/java/multi-threading/virtual-threads/
https://mccue.dev/pages/5-2-22-go-concurrency-in-java
公眾號:DailyHappy 一位后端寫碼師阎曹,一位黑暗料理制造者伪阶。