Java19虛擬線程都來了,我正在寫的線程代碼會被淘汰掉嗎?
Java19中引入了虛擬線程,雖然默認是關閉的,但是可以以Preview模式啟用,這絕對是一個重大的更新,今天Java架構雜談帶大家開箱驗貨,看看這傢伙實現了什麼了不起的功能。
1 為什麼需要虛擬線程?
小張貪小便宜,在路邊攤花一塊錢買了一籠熱氣騰騰的小籠包,下肚之後肚子疼得不行,於是在公司找坑位。掃了幾層樓,沒找到一個坑位,坑裏面的人要麼在抽煙,要麼在外放刷視頻、要麼腸道不是很順暢,蹲了半天沒拉出來。小張很鄙視在坑位裏面不幹正事的行為,此刻,與小張一同排隊等坑位的還有幾個同事…
小張突然感受到了從菊花傳來的一股無法壓制的推力,像極了JVM發生OOM前一刻的癥狀。在這千鈞一髮的時刻,小張爆發了。
他把在廁所裏面抽煙刷視頻拉不出來的人全部都趕出來了,急着釋放內存的同事立刻進行解決了,然後趁味道還沒消散,立刻再讓出坑位把抽煙的人趕進去接着抽。
坑位就是操作系統的線程,以前一個同事蹲坑之後,就佔了坑位了,其他同事無法使用。而用了虛擬線程後,誰要是在廁所裏面刷視頻、抽煙就會被趕出來,避免佔用資源,這就是虛擬線程的好處。
虛擬線程在Project Loom項目中已經孵化很久了,現在 Project Loom 的JEP 425: 虛擬線程可以在Java 19中以預覽的方式使用了,接下來Java架構雜談就帶大家深入地了解一下它。
在一個高並發系統中,給每個請求創建一個線程來處理,是很不可取的,Java線程對應一個操作系統線程,而操作系統線程資源是比較寶貴的。但是如果沒有開啟這麼多線程,又無法處理這麼多請求,特別是遇到一些鎖、IO、系統調用等操作時,需要更長的時間來處理請求。我們一般的的做法是引入線程池,但是線程池也是有限制的,假設以下場景:
線程池設置為100個線程,一個請求需要花費兩秒,而大部分時間都花在了IO上,那麼每秒最多可以響應50個請求。此時,CPU可能利用率很低,因為系統需要花費大部分時間來執行IO等待。
我們以往只能通過各種響應式框架來克服這個問題。但是引入了響應式編程框架後,代碼將會變得越來越複雜,看看以下SpringCloud Gateway的源碼,當你想調試它時,你會感到抓狂:
@Override
public Mono<Void> handle(ServerWebExchange exchange) {
if (this.handlerMappings == null) {
return createNotFoundError();
}
if (CorsUtils.isPreFlightRequest(exchange.getRequest())) {
return handlePreFlight(exchange);
}
return Flux.fromIterable(this.handlerMappings)
.concatMap(mapping -> mapping.getHandler(exchange))
.next()
.switchIfEmpty(createNotFoundError())
.flatMap(handler -> invokeHandler(exchange, handler))
.flatMap(result -> handleResult(exchange, result));
}
為了使用響應式編程,你不僅需要試圖接受這些難以閱讀的代碼,而且數據庫程序和其他外部服務的驅動程序也必須支持響應式模式。
現在,有了虛擬線程,你可以不用寫這種反人類的代碼了。
接下來,Java架構雜談帶您深入淺出徹底弄懂虛擬線程是怎麼回事。
2 什麼是虛擬線程?
通過使用虛擬線程,可以讓我們繼續編寫易於閱讀和可維護性好的高性能代碼。從Java的角度來看,虛擬線程跟普通的線程沒有什麼區別,但是虛擬線程與操作系統線程並不是一對一的關係。
2.1 虛擬線程模型
虛擬線程有一個所謂的載體線程池,虛擬線程臨時映射到該線程池上,一旦虛擬線程遇到阻塞操作,虛擬線程就從載體線程中移除,然後載體線程就可以執行其他新的虛擬線程或者之前阻塞後恢復的虛擬線程了。
載體線程與虛擬線程的關係如下圖所示,一個載體線程上面可以運行很多虛擬線程,每當虛擬線程變為非Runnable狀態時,就從載體線程上卸載:
可以看到,虛擬線程中的阻塞操作不在阻塞正在執行的線程,這允許我們使用少量的載體線程並行處理大量的請求。
虛擬線程的載體線程是
ForkJoinPool
在 FIFO 模式下運行的線程。此池的大小默認為可用處理器的數量,可以使用系統屬性jdk.virtualThreadScheduler.parallelism
進行調整。將來,可能會有更多選項來創建自定義調度程序。請注意:此
ForkJoinPool
不同於 [common pool](//docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/concurrent/ForkJoinPool.html #commonPool()),在並行流的實現中就使用到了common pool,此pool是在 LIFO 模式下運行的。
2.2 線程
線程是Java的基礎。當我們運行一個Java程序時,它的main方法作為第一個棧幀被調用。當一個方法調用另一個方法時,被調用者與調用者在同一個線程上運行,返回信息記錄到線程堆棧上。方方法使用局部變量時,它們存儲在線程堆棧的方法調用棧幀中。
當程序出現問題時,我們可以通過遍歷當前線程堆棧來進行跟蹤。
線程是Java程序調度的基本單位。當線程阻塞等待磁盤IO、網絡IO或者鎖時,該線程被掛起,以便另一個線程可以在CPU上運行。構建在線程之上的異常處理、單步調試和分析、順序控制流和局部變量等已經成為了編碼中使用率非常高的東西。線程是Java並發模型的基礎。
2.2.1 平台線程
在進入虛擬線程的世界之前,我需要重新審視經典線程,我們可以將之稱為平台線程。
常見的Java線程模式實現方式有:
- 使用內核線程實現;
- 使用用戶線程實現;
- 使用用線程+輕量級進程混合實現;
JVM規範並沒有限定Java線程需要那種模型,對於Windows和Linux版本使用的是1:1的線程,映射到輕量級進程中(每個輕量級進程都需要有一個內核線程支持)。
詳細閱讀:Java架構雜談的另一篇文章:一文帶你徹底理解同步和鎖的本質(乾貨)
由於大多數操作系統的實現方式決定了創建線程的代價比較昂貴,因此系統能夠創建的線程數量收到了限制,最終導致我們在程序中使用線程的方式產生了影響。
線程棧大小的限制
操作系統通常在創建線程時將線程堆棧分配為單片內存塊,一旦分配完成,無法調整大小。為此我們需要根據實際情況手動設置一個固定的線程棧大小。
如果線程棧被配置的過大,我們將會需要使用更多的內存,如果配置的太小,很容易就觸發StackOverflowException。為了避免StackOverflowException,一般的我們傾向於預留多點線程棧,因為消耗多點內存總比程序報錯要好。但這樣會限制我們在給定的內存量的情況下可以擁有的並發線程數量。
而限制可以創建的線程數量又會帶來並發的問題。因為服務器應用程序一般的處理方法是每個請求分配一個線程( thread-per-request)
。這種處理將應用程序的並發單元(任務)與平台(線程)對齊,可以最大限度的簡化開發、調試和維護的複雜度,同時無形的帶來很多好處,如程序執行的順序錯覺(比起異步框架,好處太明顯了)。在這種模式下,開發人員需要很少的並發意識,因為大多數請求是相互獨立的。
也許這種模式可以輕鬆的為1000個並發請求提供服務,但是卻無法支撐100萬並發的請求,即使具有足夠好的CPU和足夠大的IO帶寬。
擴展閱讀,如何讓服務器支持更高的並發:網絡編程範式:高性能服務器就這麼回事 | C10K,Event Loop,Reactor,Proactor
為了讓服務器支持更大的並發請求,Java開發人員只能選擇以下幾個比較糟糕的選擇:
- 限制代碼的編寫方式,使其可以使用更小的堆棧大小,這迫使我們方式大多數第三方庫;
- 投入更多的硬件,或者切換到Reactor或者Proactor編程風格。雖然異步模型最近幾年有些流行,前幾年就聽說有同學的公司在項目裏面推異步編程框架,但是這樣意味着我們必須以高度受限的風格進行編碼,這要求我們放棄線程給我們帶來的許多好處,例如斷點調試,堆棧跟蹤等。最終會導致犧牲了Java編程語言原本具有的一些優勢。
2.2.2 虛擬線程
虛擬線程則是一種線程的替代實現。在這種實現中,線程棧幀存儲在Java的堆內存中,而不是存儲在操作系統分配到單片內存塊中。我們再也不需要去猜測一個線程可能需要多少棧空間,虛擬線程佔用的內存開始時只有幾百位元組,隨着調用堆棧的擴展和收縮而自動擴展和收縮,這使得系統具有了更好的可伸縮性。
對於操作系統來說,仍然只知道平台線程,它是基本的調度單元,虛擬線程是在JVM中實現的,Java運行虛擬線程時通過將其安裝在某個平台線程(稱為載體線程)上來運行它。掛載虛擬線程到平台線程的時候,JVM會將所需的線程棧從堆中臨時複製到載體線程堆棧中,並在掛載時借用載體堆棧。
當在虛擬線程中運行的代碼因為IO、鎖或者其他資源可用性而阻塞時,虛擬線程可以從載體線程中卸載,並且複製的任何線程棧改動信息都將會存回到堆中,從而釋放載體線程,以使其繼續運行其他虛擬線程。
JDK中幾乎所有的阻塞掉都已經調整過了,因此當在虛擬線程上遇到阻塞操作時,虛擬線程會從其載體上卸載而不是阻塞。
例如,在LockSupport中,要park線程的時候,做了虛擬線程的兼容處理:
在Thread的sleep方法中,也做了兼容處理:
JDK中幾乎所有的阻塞點,都做了虛擬線程判斷,並且會卸載虛擬線程而不是阻塞它。
虛擬線程對編寫多線程程序有影響嗎?
在載體線程上掛載和卸載虛擬現在是JVM內部的處理邏輯,在Java代碼層面是完全不可見的。Java代碼無法觀察到當天載體的身份,也就是說,調用Thread.currentThtread總是返回虛擬線程。
載體線程的ThreadLocal值對已掛載的虛擬線程不可見,載體線程的線程棧幀不會出現在虛擬線程的異常或者線程轉儲中。
在虛擬線程的生命周期中,可能在許多不同的載體線程上運行。
如何創建虛擬線程
虛擬線程具有相對較少的新的API,創建完虛擬線程後,他們是普通的Thread對象,並且表現得向我們已經所了解的線程。例如,Thread.currentThread、ThreadLocal、中斷、堆棧遍歷等,在虛擬線程上的工作方式與在平台線程上的工作方式完全相同,這意味着我們可以自信地在虛擬線程上運行我們現有的代碼。
虛擬線程不就是綠色線程嗎?
在計算機程序設計中,綠色線程是一種由運行環境或虛擬機調度,而不是由本地底層操作系統調度的線程。綠色線程並不依賴底層的系統功能,模擬實現了多線程的運行,這種線程的管理調配發生在用戶空間而不是內核空間,所以它們可以在沒有原生線程支持的環境中工作。
綠色線程名字由來:綠色線程的名稱來源於最初的Java線程庫。這是因為甲骨文公司的「綠色團隊」最初設計了Java 的線程庫。
在Java 1.0時代,一些JVM使用綠色線程來實現線程。虛擬線程與綠色線程表面上有許多相似之處,因為他們都是由JVM而不是操作系統管理的。
但是Java 1.0的綠色線程仍然有大的單一的堆棧,綠色線程是那個時代的產物,當時系統是單核的,操作系統沒有支持線層。而虛擬線程與其他語言中的用戶模式線程有更多的共同點(例如Go中的goroutine或者Erlang中的進程),而Java的虛擬線程的語義同時還擁有與已有的線程語義相同的優勢。
3 性能對比
3.1 虛擬線程與平台線程區別?
下面通過一個案例來對比虛擬線程和平台線程。
在本例子中,開啟2000個線程,每個線程休眠1秒,觀察執行情況。通過同樣的環境和硬件配置執行以下代碼。
3.1.1 平台線程
代碼如下:
package com.itzhai.demo.jdk19;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ThreadTest implements Callable<Boolean> {
private final int number;
public ThreadTest(int number) {
this.number = number;
}
@Override
public Boolean call() {
System.out.printf("線程:%s - 任務:%d sleep...%n", Thread.currentThread().getName(), number);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.printf("線程:%s - 任務:%d canceled...%n", Thread.currentThread().getName(), number);
return false;
}
System.out.printf("線程:%s - 任務:%d finished....%n", Thread.currentThread().getName(), number);
return true;
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
Thread.sleep(10000L);
System.out.println("開始執行任務");
ExecutorService executor = Executors.newFixedThreadPool(2_000);
List<ThreadTest> tasks = new ArrayList<>();
for (int i = 0; i < 2_000; i++) {
tasks.add(new ThreadTest(i));
}
long start = System.currentTimeMillis();
List<Future<Boolean>> futures = executor.invokeAll(tasks);
long successCount = 0;
for (Future<Boolean> future : futures) {
if (future.get()) {
successCount ++;
}
}
long end = System.currentTimeMillis();
System.out.println("總共耗時: " + (end - start) + " ms,成功數量:" + successCount);
executor.shutdown();
Thread.sleep(10000L);
}
}
開啟了2016個系統線程。
內存使用最多達到了接近60M,CPU使用率最多超過了4%。
3.1.2 虛擬線程
代碼如下:
package com.itzhai.demo.jdk19;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(10000L);
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 2_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
long end = System.currentTimeMillis();
System.out.println("總共耗時: " + (end - start));
Thread.sleep(10000L);
}
}
開啟了25個系統線程:
內存使用最多不到25M,CPU使用率最多1%左右。
對比之下,高下立斷。單單是看這幾個指標差距就已經很明顯了。
3.2 如何創建虛擬線程
你可以使用Executors.newVirtualThreadPerTaskExecutor()
為每個任務創建一個新的虛擬線程:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
使用Thread.startVirtualThread()
or Thread.ofVirtual().start()
,我們也可以顯式啟動虛擬線程:
Thread.startVirtualThread(() -> {
// code to run in thread
});
Thread.ofVirtual().start(() -> {
// code to run in thread
});
4 虛擬線程優勢
4.1 更好的系統伸縮性
一般的服務器程序中,會存在大量的非活動線程,服務器程序花在網絡、文件、或者數據IO上的時間要比實際執行運算要多得多。如果我們在平台線程中運行每個任務,大多數時候,線程將在IO獲取其他資源的可用性上被阻塞。而虛擬線程運行 IO-bound thread-per-task應用程序更好的擺脫最大線程數限制的瓶頸,從而提高硬件的利用率。
虛擬線程是我們可以即實現硬件的最佳利用率,又可以繼續與平台協調的編程風格繼續編碼,而不是類似異步編程框架那種與意外編程風格格格不入的方式重寫代碼。不得不說,Java的虛擬線程真的是實現的很不錯。
虛擬線程擅長IO密集型任務的擴展性
對於一般的CPU密集型的任務,我們一般會通過fork-join框架和並行流來獲取最佳的CPU利用率,可以很輕鬆的擴展受CPU限制的工作負載。
而對於IO密集型的任務,虛擬線程則提供了對應的應對方案,為IO密集型的任務工作提供了擴展性優勢。
虛擬線程不是萬能的,與fork-join是互補的關係。
4.2 幹掉醜陋的響應式編程
響應式編程框架是定義了很多API來實現異步處理,而Java的虛擬線程是直接改造了JDK,讓你可以直接使用Java原生API就可以實現響應式編程框架對性能提升的效果,而且不用編寫那些令人頭疼的回調代碼。
許多響應式框架要求開發人員折中考慮thread-per-request的編程模式,更多的考慮異步IO、回調、線程共享等,來實現更充分的利用硬件資源。在響應式編程模型中,當一個活動要執行IO時,它會啟動給一個異步操作,該操作將在完成時調用回調函數。框架將在某個線程上調用回調函數,但不一定是啟動操作的同一線程。這意味着開發人員必須將它們的邏輯分解為IO交換和計算步驟,這些步驟被縫合到一個連續的工作流程中。因為請求旨在計算任務中才使用線程,並發請求的數量不受線程數量的限制。
響應式編程框架的這種可伸縮性需要付出巨大的代價:你進程不得不放棄開發平台和生態系統的一些基本特性。
在thread-per-request模型中,如果你想按順序執行兩件事情,只需順序編寫即可,其他的如循環、條件或者try-catch塊都是可以直接使用的。但是在異步風格中,通常無法使用編程語言為你提供的順序組合、迭代或其他功能來構建工作流,必須通過在異步框架使用特定的API來完成模擬循環和條件等,這絕對不會比語言中內置的結構那樣靈活或熟悉。如果我們使用的是阻塞操作庫,而整個庫還沒有適配異步工作方式,我們可能也沒法使用這些。總結來說,就是我們可以在響應式編程中獲得可擴展性,但是我們必須放棄使用部分語言特性和生態系統。
這些框架也是我們放棄類許多便捷的Java運行時特性,如堆棧跟蹤、調試器和分析器等,因為請求在每個階段都可能在不同的線程中執行,並且服務線程可能交錯屬於不同請求的計算。異步框架的並發單元是異步管道的一個階段,與平台的並發單元不同。
而虛擬線程運行我們在不放棄語言特性和運行時特性的情況活動相同的吞吐量優勢,這正是虛擬線程令人着迷的地方。
4.3 阻塞操作將不再掛起內核線程
這就跟虛擬線程的實現有關了,JDK做了大量的改進,以確保應用程序在使用虛擬線程是擁有良好的體驗:
- 新的套接字實現:為了更好的支持虛擬線程,需要讓阻塞方法可被中斷,為此使用了JEP 353 (重新實現 Legacy Socket API) and JEP 373 (重新實現舊版 DatagramSocket API)替換了Socket、ServerScoket和DatagramSocket的實現。
- 虛擬線程感知:JDK中幾乎所有的阻塞點,都做了虛擬線程判斷,並且會卸載虛擬線程而不是阻塞它;
- 重新審視ThreadLocal:JDK中的許多ThreadLocal用法都根據線程使用模式的預期變化進行了修訂;
- 重新審視鎖:當虛擬線程在執行synchronized塊時,無法從載體線程中卸載,這會影響系統吞吐量的可伸縮性,如果要避免這種情況,請使用ReentrantLock代替synchronized。有一些跟蹤排查方法可以使用,具體閱讀:JEP 425: Virtual Threads (Preview)#Executing virtual threads;
- 改進的線程轉儲:通過使用jcmd,提供了更好的線程轉儲,可以過濾掉虛擬線程、將相關的虛擬線程組合在一起,或者以機器可讀的方式生成轉儲,這些轉儲可以進行後處理以獲得更好的可觀察性。
4.4 虛擬線程會取代掉原有的線程嗎?
可能很多朋友都會有這個疑問。
在JEP 425: Virtual Threads (Preview)[^1]中,提到了虛擬線程的設計目標,同時也提到了Non-Goals(非目標):
- 刪除傳統的線程實現,或靜默遷移現有應用程序以使用虛擬線程不是目標;
- 改變 Java 的基本並發模型不是目標;
- 在 Java 語言或 Java 庫中提供新的數據並行結構不是目標。Stream API仍然是並行處理大型數據集的首選方式。
虛擬線程不替代原有的線程,它們是互補的。但是許多服務器應用程序會選擇虛擬線程來實現更大的可擴展性。服務端的程序員們也無需多操心,等使用的框架都支持虛擬線程的時候,理想的情況下,只需要改動一下框架配置,就完成了虛擬線程的切換,也許這個時候,我們可以為開源框架的虛擬線程改造做點貢獻。
5 使用虛擬線程,請忘掉這些東西
虛擬線程與之前的線程API沒有什麼差別,為此,使用虛擬線程,你需要學習的東西比較少。
但是為了更好的使用虛擬線程,你需要忘掉以前的一些東西。
5.1 不再依賴線程池
Java 5 引入了java.util.concurrent
包,其中包括了ExecutorService框架,通過使用ExecutorService以策略驅動的方式管理和池化線程池通常比直接創建線程要好得多。
對於平台線程,我們習慣於將它們池化,並且在一些公司的開發規範中,是一種強制措施,以限制資源利用率,否則容易耗盡內存,並將線程啟動的成本分攤到多個請求上。但是也引入了其他的問題,流例如ThreadLocal污染導致內存泄露。
但是在虛擬線程面前,池化技術反而成了反模式。因為虛擬線程的初始化佔用空間非常小,所以創建虛擬線程在時間和內存上都比創建平台線程開銷小得多,甚至數百萬個虛擬線程才使用1G內存。如果限制線程本身以外的某些資源的並發度,例如數據庫連接,我們可以使用Semaphore來獲取稀缺資源的許可。
虛擬線程非常輕量級,即使是短期任務也可以創建虛擬線程,而嘗試重用或者回收他們會適得其反。虛擬線程的設計也考慮到了短期任務,如HTTP請求或者JDBC查詢。
注意:我們不必放棄使用ExecutorService,依舊可以通過新的工廠方法Executors::newVirtualThreadPerTaskExecutor來獲得一個ExecutorService偽每個任務創建一個新的虛擬線程。
5.2 請勿過渡使用ThreadLocal
有時候,使用ThreadLocal來緩存分配內存開銷大的資源,或者為了避免重複分配常用對象,當系統有幾百個線程的時候,這種模式的資源使用通常不會過多,並且比起每次使用都重新分配更高效。但是當有幾百萬個線程時,每個線程只執行一個任務,但是可能分配了更多的實例,每個實例被重用的機會要小得多,最終會導致消耗更大的性能開銷。
從本文我們可以看到虛擬線程給我帶來的諸多好處,它允許我們編寫可讀且可維護的代碼的同事,不會阻塞操作系統線程。
在常見的後端框架支持虛擬線程之前,我們還需要耐心的等待一段時間。到時小張急着上廁所的時候再也不用排長隊了。
我精心整理了一份Redis寶典給大家,涵蓋了Redis的方方面面,面試官懂的裏面有,面試官不懂的裏面也有,有了它,不怕面試官連環問,就怕面試官一上來就問你Redis的Redo Log是幹啥的?畢竟這種問題我也不會。
在Java架構雜談
公眾號發送Redis
關鍵字獲取pdf文件:
本文作者: arthinking
博客鏈接: //www.itzhai.com/articles/virtual-thread-in-java19.html
Java19虛擬線程都來了,我正在寫的線程代碼會被淘汰掉嗎?
版權聲明: 版權歸作者所有,未經許可不得轉載,侵權必究!聯繫作者請加公眾號。
Refrences
[1]: JEP 425: Virtual Threads (Preview). Retrieved from //openjdk.org/jeps/425
[2]: Virtual Threads: New Foundations for High-Scale Java Applications. Retrieved from //www.infoq.com/articles/java-virtual-threads/
[3]: Virtual Threads in Java (Project Loom). Retrieved from //www.happycoders.eu/java/virtual-threads/
[4]: 綠色線程. Retrieved from //zh.m.wikipedia.org/zh-hans/%E7%BB%BF%E8%89%B2%E7%BA%BF%E7%A8%8B
[5]: Coming to Java 19: Virtual threads and platform threads. Retrieved from //blogs.oracle.com/javamagazine/post/java-loom-virtual-threads-platform-threads
[6]: Difference Between Thread and Virtual Thread in Java. Retrieved from //www.baeldung.com/java-virtual-thread-vs-thread