1 全新併發編程模式 JDK9 後的版本你覺得沒必要折騰,我也認可,但是JDK21有必要關註。因為 JDK21 引入全新的併發編程模式。 一直沽名釣譽的GoLang吹得最厲害的就是協程了。JDK21 中就在這方面做了很大的改進,讓Java併發編程變得更簡單一點,更絲滑一點。 之前寫過JDK21 Fe ...
1 全新併發編程模式
JDK9 後的版本你覺得沒必要折騰,我也認可,但是JDK21有必要關註。因為 JDK21 引入全新的併發編程模式。
一直沽名釣譽的GoLang吹得最厲害的就是協程了。JDK21 中就在這方面做了很大的改進,讓Java併發編程變得更簡單一點,更絲滑一點。
之前寫過JDK21 Feature。Virtual Threads
、Scoped Values
、Structured Concurrency
就是針對多線程併發編程的幾個功能。。
2 發展歷史
虛擬線程是輕量級線程,極大地減少了編寫、維護和觀察高吞吐量併發應用的工作量。
虛擬線程是由JEP 425提出的預覽功能,併在JDK 19中發佈,JDK 21中最終確定虛擬線程,以下是根據開發者反饋從JDK 20中的變化:
- 現在,虛擬線程始終支持線程本地變數。與在預覽版本中允許的不同,現在不再可能創建不能具有線程本地變數的虛擬線程。對線程本地變數的有保障支持確保了許多現有庫可以不經修改地與虛擬線程一起使用,並有助於將以任務為導向的代碼遷移到使用虛擬線程
- 直接使用Thread.Builder API創建的虛擬線程(而不是通過Executors.newVirtualThreadPerTaskExecutor()創建的虛擬線程)現在預設情況下也會在其生命周期內進行監控,並且可以通過描述在"觀察虛擬線程"部分中的新線程轉儲來觀察。
基於協程的線程,與其他語言中的協程有相似之處,也有不同。虛擬線程是依附於主線程的,如果主線程銷毀了,虛擬線程也不復存在。
3 目標
- 使採用簡單的 thread-per-request 模式編寫的伺服器應用程式,能以接近最佳的硬體利用率擴展
- 使利用java.lang.Thread API的現有代碼能在最小更改下採用虛擬線程
- 通過現有的JDK工具輕鬆進行虛擬線程的故障排除、調試和分析
4 非目標
- 不是刪除傳統的線程實現,也不是悄悄將現有應用程式遷移到使用虛擬線程
- 不是改變Java的基本併發模型
- 不是在Java語言或Java庫中提供新的數據並行構造。Stream API仍是處理大型數據集的首選方式。
5 動機
Java開發人員在近30年來一直依賴線程作為併發服務端應用程式的構建塊。每個方法中的每個語句都在一個線程內執行,並且由於Java是多線程,多個線程同時執行。
線程是Java的併發單元:它是一段順序代碼,與其他這樣的單元併發運行,很大程度上是獨立的。每個線程提供一個堆棧來存儲局部變數和協調方法調用及在出現問題時的上下文:異常由同一線程中的方法拋出和捕獲,因此開發可使用線程的堆棧跟蹤來查找發生了啥。
線程也是工具的核心概念:調試器逐步執行線程方法中的語句,分析工具可視化多個線程的行為,以幫助理解它們的性能。
6 thread-per-request模式
伺服器應用程式通常處理彼此獨立的併發用戶請求,因此將一個線程專用於處理整個請求在邏輯上是合理的。這種模式易理解、易編程,且易調試和分析,因為它使用平臺的併發單元來表示應用程式的併發單元。
伺服器應用程式的可擴展性受到Little定律約束,該定律關聯延遲、併發性和吞吐量:對給定的請求處理持續時間(即延遲),應用程式同時處理的請求數量(併發性)必須與到達速率(吞吐量)成比例增長。如一個具有平均延遲為50ms的應用程式,通過同時處理10個請求實現每秒處理200個請求的吞吐量。為使該應用程式擴展到每秒處理2000個請求吞吐量,它要同時處理100個請求。如每個請求在其持續時間內都使用一個線程(因此使用一個os線程),那在其他資源(如CPU或網路連接)耗盡前,線程數量通常成為限制因素。JDK對線程的當前實現將應用程式的吞吐量限制在遠低於硬體支持水平的水平。即使線程進行池化,仍然發生,因為池化可避免啟動新線程的高成本,但並不會增加匯流排程數。
7 使用非同步模式提高可擴展性
一些開發人員為了充分利用硬體資源,已經放棄了採用"thread-per-request"的編程風格,轉而採用"共用線程"。這種方式,請求處理的代碼在等待I/O操作完成時會將其線程返回給一個線程池,以便該線程可以為其他請求提供服務。這種對線程的精細共用,即只有在執行計算時才保持線程,而在等待I/O時釋放線程,允許高併發操作而不消耗大量線程資源。雖然它消除了由於os線程有限而導致的吞吐量限制,但代價高:它需要一種非同步編程風格,使用一組專門的I/O方法,這些方法不會等待I/O操作完成,而是稍後通過回調通知其完成。
在沒有專用線程情況下,開發須將請求處理邏輯分解為小階段,通常編寫為lambda表達式,然後使用API(如CompletableFuture或響應式框架)將它們組合成順序管道。因此,他們放棄語言的基本順序組合運算符,如迴圈和try/catch塊。
非同步風格中,請求的每個階段可能在不同線程執行,每個線程交錯方式運行屬於不同請求的階段。這對於理解程式行為產生了深刻的影響:堆棧跟蹤提供不了可用的上下文,調試器無法逐步執行請求處理邏輯,分析器無法將操作的成本與其調用者關聯起來。使用Java的流API在短管道中處理數據時,組合lambda表達式是可管理的,但當應用程式中的所有請求處理代碼都必須以這種方式編寫時,會帶來問題。這種編程風格與Java平臺不符,因為應用程式的併發單位——非同步管道——不再是平臺的併發單位。
8 通過虛擬線程保持 thread-per-request 編程風格
為了在保持與平臺和諧的情況下使應用程式能擴展,應努力通過更高效方式實現線程,以便它們可更豐富存在。os無法更高效實現操作系統線程,因為不同編程語言和運行時以不同方式使用線程堆棧。然而,JRE可通過將大量虛擬線程映射到少量操作系統線程來實現線程的偽裝豐富性,就像os通過將大型虛擬地址空間映射到有限的物理記憶體一樣,JRE可通過將大量虛擬線程映射到少量操作系統線程來實現線程的偽裝豐富性。
虛擬線程是java.lang.Thread一個實例,不與特定os線程綁定。相反,平臺線程是java.lang.Thread的一個實例,以傳統方式實現,作為包裝在操作系統線程周圍的薄包裝。
採用 thread-per-request 編程風格的應用程式,可在整個請求的持續時間內在虛擬線程中運行,但虛擬線程僅在它在CPU上執行計算時才會消耗os線程。結果與非同步風格相同,只是它是透明實現:當在虛擬線程中運行的代碼調用java.* API中的阻塞I/O操作時,運行時會執行非阻塞的os調用,並自動暫停虛擬線程,直到可稍後恢復。對Java開發,虛擬線程只是便宜且幾乎無限豐富的線程。硬體利用率接近最佳,允許高併發,因此實現高吞吐量,同時應用程式與Java平臺及其工具的多線程設計保持和諧一致。
9 虛擬線程的含義
虛擬線程成本低且豐富,因此永遠都不應被池化:每個應用程式任務應該創建一個新的虛擬線程。因此,大多數虛擬線程將是短暫的,且具有淺層次的調用棧,執行的操作可能只有一個HTTP客戶端調用或一個JDBC查詢。相比之下,平臺線程是重量級且代價昂貴,因此通常必須池化。它們傾向於具有較長的生命周期,具有深層次調用棧,併在許多任務間共用。
總之,虛擬線程保留了與Java平臺設計和諧一致的可靠的 thread-per-request 編程風格,同時最大限度地利用硬體資源。使用虛擬線程無需學習新概念,儘管可能需要放棄為應對當前線程成本高昂而養成的習慣。虛擬線程不僅將幫助應用程式開發人員,還將幫助框架設計人員提供與平臺設計相容且不會犧牲可伸縮性的易於使用的API。
10 描述
如今,JDK 中的每個 java.lang.Thread 實例都是平臺線程。平臺線程在底層os線程上運行 Java 代碼,併在代碼的整個生命周期內捕獲os線程。平臺線程的數量受限於os線程的數量。
虛擬線程是 java.lang.Thread 的一個實例,它在底層os線程上運行 Java 代碼,但並不在代碼的整個生命周期內捕獲操作系統線程。這意味著許多虛擬線程可在同一個os線程上運行其 Java 代碼,有效地共用它。而平臺線程會獨占一個寶貴的os線程,虛擬線程則不會。虛擬線程的數量可 >> os線程的數量。
虛擬線程是 JDK 提供的輕量級線程實現,不是由os提供。它們是用戶態線程的一種形式,在其他多線程語言(如 Go 的 goroutine 和 Erlang 的進程)中取得成功。
早期版本 Java,當os線程尚未成熟和廣泛使用時,Java 的綠色線程都共用一個os線程(M:1 調度),最終被作為os線程的包裝器(1:1 調度)超越。虛擬線程採用 M:N 調度,其中大量(M)虛擬線程被調度在較少(N)的os線程上運行。
11 使用虛擬線程與平臺線程
開發人員可選擇使用虛擬線程或平臺線程。
11.1 創建大量虛擬線程demo
先獲取一個 ExecutorService,用於為每個提交的任務創建一個新的虛擬線程。然後,它提交 10,000 個任務並等待它們全部完成:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// 任務即休眠1s
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits
現代硬體可輕鬆支持同時運行 10,000 個虛擬線程來執行這樣代碼。幕後,JDK 在較少的os線程上運行代碼,可能只有一個:
- 若此程式使用一個為每個任務創建一個新的平臺線程的 ExecutorService,如
Executors.newCachedThreadPool()
,情況將完全不同。ExecutorService 將嘗試創建 10,000 個平臺線程,因此會創建 10,000 個操作系統線程,根據電腦和os的不同,程式可能會崩潰 - 若程式改用從池中獲取平臺線程的 ExecutorService,如
Executors.newFixedThreadPool(200)
,情況也不好多少。ExecutorService 將創建 200 個平臺線程供所有 10,000 個任務共用,因此許多任務將順序而非併發運行,程式將要很久才能完成
該程式,具有 200 個平臺線程的池只能實現每秒 200 個任務的吞吐量,而虛擬線程在足夠熱身後,可實現每秒約 10,000 個任務的吞吐量。
若將demo中的 10_000
更改為 1_000_000
,則程式將提交 1,000,000 個任務,創建 1,000,000 個同時運行的虛擬線程,併在足夠熱身後實現每秒約 1,000,000 個任務的吞吐量。
若此程式任務執行一個需要1s計算(如對大型數組排序),而不僅是休眠,那增加線程數量超過CPU核數量將無法提高吞吐量,無論是虛擬線程、平臺線程。虛擬線程不是更快的線程 —— 它們不會比平臺線程運行代碼更快。它們存在目的是提供規模(更高吞吐量),而非速度(更低的延遲)。虛擬線程的數量可以遠遠多於平臺線程的數量,因此它們可以實現更高的併發,從而實現更高的吞吐量,根據 Little 定律。
換句話說,虛擬線程可在以下情況顯著提高應用吞吐量:
- 併發任務的數量很高(超過幾千)
- 工作負載不是 CPU 限制的,因為此時,比CPU核數量更多的線程無法提高吞吐量
虛擬線程有助提高典型伺服器應用程式的吞吐量,因為這種應用程式由大量併發任務組成,這些任務在大部分時間內都在等待。
虛擬線程可運行任何平臺線程可運行的代碼。特別是,虛擬線程支持線程本地變數和線程中斷,就像平臺線程一樣。這意味著已存在的用於處理請求的 Java 代碼可輕鬆在虛擬線程中運行。許多服務端框架可能會自動選擇這樣做,為每個傳入的請求啟動一個新的虛擬線程,併在其中運行應用程式的業務邏輯。
11.2 聚合服務demo
聚合了另外兩個服務的結果。一個假設的伺服器框架(未顯示)為每個請求創建一個新的虛擬線程,併在該虛擬線程中運行應用程式的處理代碼。
又創建兩個新虛擬線程併發通過與第一個示例相同的 ExecutorService 獲取資源:
void handle(Request request, Response response) {
var url1 = ...
var url2 = ...
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
response.fail(e);
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
這程式具有直接的阻塞代碼,因為它可以使用大量虛擬線程,所以能很好擴展。
Executor.newVirtualThreadPerTaskExecutor()
不是創建虛擬線程的唯一方式。下麵討論新的 java.lang.Thread.Builder
API 可創建和啟動虛擬線程。
結構化併發提供更強大 API,用於創建和管理虛擬線程,特別是在類似這伺服器示例的代碼,其中線程之間的關係對於平臺和其工具是已知的。
12 解除預設禁用限制
虛擬線程是一項預覽 API,預設禁用。上面程式使用 Executors.newVirtualThreadPerTaskExecutor()
方法,所以要在 JDK 19 上運行它們,須啟用預覽 API:
- 使用
javac --release 19 --enable-preview Main.java
編譯程式,然後使用java --enable-preview Main
運行它; - 當使用源代碼啟動器時,使用
java --source 19 --enable-preview Main.java
運行程式 - 當使用 jshell 時,啟動它時加上
jshell --enable-preview
13 不要池化虛擬線程
開發通常會將應用程式代碼從基於線程池的傳統 ExecutorService 遷移到基於虛擬線程的virtual-thread-per-task的 ExecutorService。線程池就像所有資源池一樣,旨在共用昂貴資源,但虛擬線程並不昂貴,永遠不要對它們池化。
開發人員有時使用線程池限制對有限資源的併發訪問。如一個服務不能處理超過 20 個併發請求,通過提交到大小為 20 的線程池的任務來執行對該服務的所有訪問將確保這點。由於平臺線程高成本已使線程池無處不在,這種習慣也無處不在,但開發不應誘惑自己在虛擬線程中進行池化以限制併發。應該使用專門設計用於此目的的構造,如信號量來保護對有限資源的訪問。這比線程池更有效方便,也安全,因為不存線上程本地的數據意外泄漏給另一個任務的風險。
13 觀察虛擬線程
編寫清晰的代碼還不夠,運行中程式狀態的清晰呈現對故障排除、維護和優化也重要,而 JDK 一直提供調試、分析和監視線程的機制。這些工具應對虛擬線程執行相同操作,儘管可能需要適應它們的大量存在,因為它們畢竟是 java.lang.Thread 的實例。
13.1 Java 調試器
可逐步執行虛擬線程,顯示調用棧,並檢查棧幀變數。JDK Flight Recorder(JFR)是 JDK 的低開銷分析和監視機制,可將來自應用程式代碼(如對象分配和 I/O 操作)的事件與正確的虛擬線程關聯起來。這些工具無法為採用非同步編程風格編寫的應用程式執行這些操作。在該風格中,任務與線程無關,因此調試器無法顯示或操作任務的狀態,分析器無法判斷任務等待 I/O 所花費的時間。
13.2 線程dump
故障排除線程-每請求編程風格應用程式的常用工具。但 JDK 的傳統線程轉儲(使用 jstack 或 jcmd 獲取)呈現為線程的扁平列表。適用於幾十或數百平臺線程,但不適用於數千或數百萬虛擬線程。因此,官方不會擴展傳統線程轉儲以包括虛擬線程,而是會引入一種新的線程轉儲類型,在 jcmd 中以有意義的方式將虛擬線程與平臺線程一起顯示。當程式使用結構化併發時,可顯示線程之間更豐富的關係。
由於可視化和分析大量線程可受益於工具支持,jcmd 還可以 JSON 格式輸出新的線程轉儲,而不僅是純文本:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
新的線程轉儲格式列出了在網路 I/O 操作中被阻塞的虛擬線程以及由上面示例中的 new-thread-per-task ExecutorService 創建的虛擬線程。它不包括對象地址、鎖、JNI 統計信息、堆統計信息和傳統線程轉儲中顯示的其他信息。此外,由於可能需要列出大量線程,生成新的線程轉儲不會暫停應用程式。
類似第二個demo程式的線程轉儲示例,以 JSON 呈現:
{
"virtual_threads": [
{
"id": 1,
"name": "VirtualThread-1",
"state": "RUNNABLE",
"stack_trace": [
{
"class": "java.base/java.lang.Thread",
"method": "lambda$main$0",
"file": "Main.java",
"line": 10
}
]
},
{
"id": 2,
"name": "VirtualThread-2",
"state": "BLOCKED",
"stack_trace": [
{
"class": "java.base/java.net.SocketInputStream",
"method": "socketRead0",
"file": "SocketInputStream.java",
"line": 61
}
]
}
],
"platform_threads": [
{
"id": 11,
"name": "Thread-11",
"state": "RUNNABLE",
"stack_trace": [
{
"class": "java.base/java.lang.Thread",
"method": "run",
"file": "Thread.java",
"line": 834
}
]
},
{
"id": 12,
"name": "Thread-12",
"state": "WAITING",
"stack_trace": [
{
"class": "java.base/java.lang.Object",
"method": "wait",
"file": "Object.java",
"line": 328
}
]
}
]
}
由於虛擬線程是在 JDK 實現的,不與任何特定 OS 線程綁定,因此它們對os不可見的,os 也不知道它們、存在。操作系統級別的監控將觀察到 JDK 進程使用的 OS 線程少於虛擬線程的數量。
14 虛擬線程調度
要執行有用的工作,線程需要被調度,即分配給一個處理器核心來執行。對於作為 OS 線程實現的平臺線程,JDK 依賴os中的調度程式。對虛擬線程,JDK 有自己調度程式。JDK 調度程式不是直接將虛擬線程分配給處理器,而是將虛擬線程分配給平臺線程(虛擬線程 M:N 調度)。然後,os會像往常一樣對這些平臺線程調度。
JDK 虛擬線程調度程式是以 FIFO 運行的 work-stealing ForkJoinPool。調度程式的並行度是用於調度虛擬線程的可用平臺線程數量。預設為可用處理器數量,但可使用系統屬性 jdk.virtualThreadScheduler.parallelism
調整。這 ForkJoinPool 與通常用於並行流實現等的公共池不同,後者 LIFO 運行。
調度程式分配虛擬線程給平臺線程就是虛擬線程的載體。虛擬線程可在其生命周期內被分配給不同載體,即調度程式不會在虛擬線程和任何特定的平臺線程之間保持關聯。從 Java 代碼角度看,正在運行的虛擬線程邏輯上與其當前載體無關:
- 虛擬線程無法獲取載體標識。
Thread.currentThread()
返回值始終是虛擬線程本身 - 載體和虛擬線程的棧軌跡是分開的。在虛擬線程中拋出的異常不會包含載體棧幀。線程轉儲不會在虛擬線程的棧中顯示載體的棧幀,反之亦然
- 載體的線程本地變數對虛擬線程不可見,反之亦然
Java代碼角度,虛擬線程及其載體暫時共用一個 OS 線程的事實是不可見的。本地代碼角度,與虛擬線程多次調用相同本地代碼可能會在每次調用時觀察到不同的 OS 線程標識。
時間共用
目前,調度程式不實現虛擬線程的時間共用。時間共用是對已消耗的 CPU 時間進行強制搶占的機制。雖然時間共用在某些任務的延遲降低方面可能有效,但在平臺線程相對較少且 CPU 利用率達 100% 時,不清楚時間共用是否同樣有效,尤其擁有百萬虛擬線程時。
15 執行虛擬線程
要利用虛擬線程,無需重寫程式。虛擬線程不需要或不期望應用程式代碼明確將控制權交還給調度程式,即虛擬線程不是協作式的。用戶代碼不應假設虛擬線程是如何或何時分配給平臺線程的,就像它不應假設平臺線程是如何或何時分配給處理器核。
要在虛擬線程中運行代碼,JDK虛擬線程調度程式通過將虛擬線程掛載到平臺線程,為其分配平臺線程來執行。這使平臺線程成為虛擬線程的載體。稍後,在運行一些代碼後,虛擬線程可以從其載體卸載。在這點上,平臺線程是空閑的,因此調度程式可以再次將不同的虛擬線程掛載到上面,從而使其成為載體。
通常,當虛擬線程在 JDK 中的某些阻塞操作(如 BlockingQueue.take())阻塞時,它會卸載。當阻塞操作准備完成(如在套接字上接收到位元組)時,它會將虛擬線程提交回調度程式,後者將掛載虛擬線程到載體上以恢復執行。
虛擬線程的掛載和卸載頻繁而透明地發生,不會阻塞任何 OS 線程。如前面示例中的伺服器應用程式包含以下一行代碼,包含對阻塞操作的調用:
response.send(future1.get() + future2.get());
這些操作將導致虛擬線程多次掛載和卸載,通常對每次調用 get()
進行一次,可能在執行 send(...)
中的 I/O 操作期間多次進行。
JDK大多數阻塞操作都會卸載虛擬線程,釋放其載體和底層 OS 線程以承擔新工作。然而,JDK一些阻塞操作不會卸載虛擬線程,因此會阻塞其載體和底層 OS 線程。這是因為在 OS 級別(如許多文件系統操作)或 JDK 級別(如Object.wait())存在一些限制。這些阻塞操作實現將通過臨時擴展調度程式的並行性來彌補 OS 線程的占用,因此調度程式的 ForkJoinPool 中的平臺線程數量可能會在短時間內超過可用處理器的數量。可通過系統屬性 jdk.virtualThreadScheduler.maxPoolSize
調整調度程式可用於的最大平臺線程數量。
如下情況下,虛擬線程在阻塞操作期間無法卸載,因為它被固定在其載體:
- 當它執行同步塊或方法內部的代碼時
- 當它執行本機方法或外部函數時
固定不會使應用程式不正確,但可能會阻礙其可擴展性。若虛擬線程在固定狀態下執行阻塞操作,如 I/O 或 BlockingQueue.take(),則其載體和底層 OS 線程將在操作的持續時間內被阻塞。頻繁而長時間的固定可能會損害應用程式的可擴展性,因為它會占用載體。
調度程式不會通過擴展其並行性來補償固定。相反,避免頻繁和長時間的固定,通過修改頻繁運行並保護潛在的長時間 I/O 操作的同步塊或方法,以使用 java.util.concurrent.locks.ReentrantLock
,而不是 synchronized。無需替換僅在啟動時執行的同步塊和方法(如僅在啟動時執行的同步塊和方法,或者保護記憶體中操作的同步塊和方法)。一如既往,努力保持鎖策略簡單明瞭。
新的診斷工具有助於將代碼遷移到虛擬線程並評估是否應該用 java.util.concurrent
鎖替換特定的 synchronized 使用:
-
當線程在固定狀態下阻塞時,會發出 JDK Flight Recorder (JFR) 事件(參閱 JDK Flight Recorder)。
-
系統屬性
jdk.tracePinnedThreads
觸發線程在固定狀態下阻塞時的堆棧跟蹤。使用-Djdk.tracePinnedThreads=full
運行時會列印完整的堆棧跟蹤,突出顯示了持有監視器的本機幀和幀。使用-Djdk.tracePinnedThreads=short
會將輸出限製為僅包含有問題的幀。
將來版本可能能夠解決上述的第一個限制(在同步塊內部固定)。第二個限制是為了與本機代碼進行正確交互而需要的。
16 記憶體使用和與垃圾回收的交互
虛擬線程的堆棧存儲在 Java 的垃圾回收堆中,作為堆棧塊對象。隨應用運行,堆棧會動態增長和收縮,既能高效使用記憶體,又能夠容納任意深度的堆棧(最多達到 JVM 配置的平臺線程堆棧大小)。這種效率是支持大量虛擬線程的關鍵,因此線程每請求的風格在伺服器應用程式中仍然具有持續的可行性。
第二個示例中,一個假設的框架通過創建一個新的虛擬線程並調用 handle
方法來處理每個請求;即使它在深層次的調棧末尾(經過身份驗證、事務等)調用 handle
,handle
本身也會生成多個僅執行短暫任務的虛擬線程。因此,對有深度調用棧的每個虛擬線程,都將有多個具有淺調用棧的虛擬線程,占用記憶體很少。
虛擬線程與非同步代碼的堆空間使用和垃圾回收活動難以比較:
- 一百萬個虛擬線程需至少一百萬個對象
- 但共用平臺線程池的一百萬個任務也需要一百萬個對象
- 處理請求的應用程式代碼通常會在 I/O 操作之間保留數據
Thread-per-request的代碼可將這些數據保留在本地變數,這些變數存儲在堆中的虛擬線程棧,而非同步代碼須將相同的數據保留在從管道的一個階段傳遞到下一個階段的堆對象。一方面,虛擬線程所需的棧更浪費空間,而非同步管道總是需要分配新對象,因此虛擬線程可能需要較少的分配。總體而言,線程每請求代碼與非同步代碼的堆消耗和垃圾回收活動應該大致相似。隨時間推移,希望將虛擬線程棧的內部表示大大壓縮。
與平臺線程棧不同,虛擬線程棧不是 GC root,因此不會在垃圾收集器(如 G1)進行併發堆掃描時遍歷其中的引用。這還意味著,如虛擬線程被阻塞在如 BlockingQueue.take()
,並且沒有其他線程可獲取到虛擬線程或隊列的引用,那該線程可進行垃圾回收 — 這沒問題,因為虛擬線程永遠不會被中斷或解除阻塞。當然,如虛擬線程正在運行或正在阻塞且可能會被解除阻塞,那麼它將不會被垃圾回收。
16.1 當前限制
G1不支持龐大的(humongous)堆棧塊對象。如虛擬線程的堆棧達到region大小一半,這可能只有 512KB,那可能會拋 StackOverflowError
。
17 詳情變化
在Java平臺及其實現中的更改:
java.lang.Thread
API更新:
- Thread.Builder、Thread.ofVirtual()和Thread.ofPlatform(),創建虛擬線程和平臺線程的新API。如
// 創建一個名為"duke"的新的未啟動的虛擬線程
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
-
Thread.startVirtualThread(Runnable),創建並啟動虛擬線程的便捷方式
-
Thread.Builder可創建線程或ThreadFactory可創建具有相同屬性的多個線程
-
Thread.isVirtual():測試線程是否為虛擬線程
-
Thread.join和Thread.sleep的新重載接受java.time.Duration的等待和休眠參數
-
新的final方法Thread.threadId()返回線程的標識符。現有的非final方法Thread.getId()已棄用。
-
Thread.getAllStackTraces()現在返回所有平臺線程的映射,而不是所有線程。
java.lang.Thread API在其他方面不變。Thread類定義的構造函數仍創建平臺線程,與以前一樣。沒有新構造函數。
虛擬線程和平臺線程API區別
- public Thread構造函數無法創建虛擬線程
- 虛擬線程始終是守護線程。Thread.setDaemon(boolean)方法無法將虛擬線程更改為非守護線程
- 虛擬線程具有Thread.NORM_PRIORITY的固定優先順序。Thread.setPriority(int)方法對虛擬線程沒有影響。這個限制可能會在將來版本重審
- 虛擬線程不是線程組的活躍成員。在虛擬線程上調用時,Thread.getThreadGroup()返回一個帶有名稱"VirtualThreads"的占位符線程組。Thread.Builder API不定義設置虛擬線程線程組的方法
- 設置SecurityManager時,虛擬線程在運行時沒有許可權
- 虛擬線程不支持stop()、suspend()或resume()方法。在虛擬線程上調用這些方法會拋異常
線程本地變數
虛擬線程支持:
- 線程本地變數(ThreadLocal)
- 可繼承線程本地變數(InheritableThreadLocal)
就像平臺線程,因此它們可運行使用線程本地變數的現有代碼。然而,由於虛擬線程可能非常多,使用線程本地變數時需謹慎考慮。
不要使用線程本地變數線上程池中共用昂貴資源,多個任務共用同一個線程。
虛擬線程不應被池化,因為每個虛擬線程的生命周期只用於運行單個任務。為在運行時具有數百萬個線程時減少記憶體占用,已從java.base模塊刪除了許多線程本地變數的用法。
更多的
Thread.Builder API定義了一個方法,用於在創建線程時選擇不使用線程本地變數。它還定義了一個方法,用於選擇不繼承inheritable thread-locals的初始值。在不支持線程本地變數的線程上調用ThreadLocal.get()將返回初始值,ThreadLocal.set(T)會拋異常。
傳統的上下文類載入器現在被指定為像inheritable thread local一樣工作。如在不支持thread locals的線程上調用Thread.setContextClassLoader(ClassLoader),則拋異常。
範圍本地變數可能對某些用例來說是線程本地變數的更好選擇。
JUC
支持鎖的基本API,java.util.concurrent.LockSupport,現支持虛擬線程:
- 掛起虛擬線程會釋放底層的平臺線程以執行其他工作
- 而喚醒虛擬線程會安排它繼續執行
這對LockSupport的更改使得所有使用它的API(鎖、信號量、阻塞隊列等)在虛擬線程中調用時能夠優雅地掛起。
此外
Executors.newThreadPerTaskExecutor(ThreadFactory)和Executors.newVirtualThreadPerTaskExecutor()創建一個ExecutorService,它為每個任務創建一個新線程。這些方法允許遷移和與使用線程池和ExecutorService的現有代碼進行互操作。
ExecutorService現擴展AutoCloseable,可使用try-with-resource構造來使用此API,如上面demo。
Future現定義了獲取已完成任務的結果或異常及獲取任務狀態的方法。它們組合可將Future對象用作流的元素,過濾包含已完成任務的流,然後map以獲取結果的流。這些方法也將對結構化併發的API添加非常有用。
18 網路
java.net和java.nio.channels包中的網路API的實現現在與虛擬線程一起工作:在虛擬線程上執行的操作,如建立網路連接或從套接字讀取時,將釋放底層平臺線程以執行其他工作。
為允許中斷和取消操作,java.net.Socket、ServerSocket和DatagramSocket定義的阻塞I/O方法現在在虛擬線程中調用時被規定為可中斷:中斷在套接字上阻塞的虛擬線程將喚醒線程並關閉套接字。從InterruptibleChannel獲取的這些類型套接字上的阻塞I/O操作一直是可中斷,因此這個更改使得這些API在使用它們的構造函數創建時的行為與從通道獲取時的行為保持一致。
java.io
提供了位元組和字元流的API。這些API的實現在被虛擬線程使用時需要進行更改以避免固定(pinning)。
作為背景,面向位元組的輸入/輸出流沒有規定是線程安全的,也沒有規定線上程在讀取或寫入方法中被阻塞時調用close()的預期行為。大多情況下,不應在多個併發線程中使用特定的輸入或輸出流。面向字元的讀取/寫入器也沒規定是線程安全的,但它們為子類公開了一個鎖對象。除了固定,這些類中的同步存在問題且不一致;例如,InputStreamReader和OutputStreamWriter使用的流解碼器和編碼器在流對象上同步,而不是在鎖對象上同步。
為了防止固定,現在實現的工作方式如下:
- BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream和PrintWriter現在在直接使用時使用顯式鎖,而不是監視器。當它們被子類化時,這些類會像以前一樣同步
- InputStreamReader和OutputStreamWriter使用的流解碼器和編碼器現在使用與包含它們的InputStreamReader或OutputStreamWriter相同的鎖
- BufferedOutputStream、BufferedWriter和OutputStreamWriter使用的流編碼器的初始緩衝區大小現在更小,以減少在堆中存在許多流或編寫器時的記憶體使用——如果有百萬個虛擬線程,每個線程都有一個套接字連接上的緩衝流,這種情況可能會發生。
JNI
JNI定義了一個新的函數IsVirtualThread,用於測試一個對象是否是虛擬線程。
調試
調試架構包括三個介面:JVM工具介面(JVM TI)、Java調試線協議(JDWP)和Java調試介面(JDI)。這三個介面現在都支持虛擬線程。
JVM TI的更新包括:
- 大多數使用jthread(即對Thread對象的JNI引用)調用的函數現在可以使用對虛擬線程的引用來調用。一小部分函數,即PopFrame、ForceEarlyReturn、StopThread、AgentStartFunction和GetThreadCpuTime,不支持虛擬線程。SetLocal*函數僅限於在中斷或單步事件時掛起的虛擬線程的最頂層幀中設置本地變數
- GetAllThreads和GetAllStackTraces函數現在規定返回所有平臺線程,而不是所有線程
- 所有事件,除了在早期VM啟動或堆迭代期間發佈的事件外,都可以在虛擬線程的上下文中調用事件回調
- 掛起/恢復實現允許調試器掛起和恢復虛擬線程,以及在掛載虛擬線程時掛起平臺線程
- 一個新的能力can_support_virtual_threads允許代理程式對虛擬線程的線程啟動和結束事件有更精細的控制
現有的JVM TI代理程式大多將像以前一樣工作,但如果調用不支持虛擬線程的函數,可能會遇到錯誤。這些錯誤將在使用不瞭解虛擬線程的代理程式與使用虛擬線程的應用程式時發生。將GetAllThreads更改為返回僅包含平臺線程的數組可能對某些代理程式構成問題。已啟用ThreadStart和ThreadEnd事件的現有代理程式可能會遇到性能問題,因為它們無法將這些事件限製為平臺線程。
JDWP的更新包括:
- 一個新的命令允許調試器測試一個線程是否是虛擬線程
- EventRequest命令上的新修飾符允許調試器將線程啟動和結束事件限製為平臺線程。
JDI的更新包括:
- com.sun.jdi.ThreadReference中的一個新方法測試一個線程是否是虛擬線程
- com.sun.jdi.request.ThreadStartRequest和com.sun.jdi.request.ThreadDeathRequest中的新方法限制了為請求生成的事件的線程到平臺線程
如上所述,虛擬線程不被認為是線程組中的活動線程。因此,JVM TI函數GetThreadGroupChildren、JDWP命令ThreadGroupReference/Children和JDI方法com.sun.jdi.ThreadGroupReference.threads()返回的線程列表僅包含平臺線程。
JDK Flight Recorder(JFR)
JFR支持虛擬線程,並引入了幾個新的事件:
jdk.VirtualThreadStart和jdk.VirtualThreadEnd表示虛擬線程的啟動和結束。這些事件預設情況下是禁用的。
jdk.VirtualThreadPinned表示虛擬線程被固定(pinned)時的情況,即在不釋放其平臺線程的情況下被掛起。此事件預設情況下啟用,閾值為20毫秒。
jdk.VirtualThreadSubmitFailed表示啟動或喚醒虛擬線程失敗,可能是由於資源問題。此事件預設情況下啟用。
Java管理擴展(JMX)
java.lang.management.ThreadMXBean僅支持監視和管理平臺線程。findDeadlockedThreads()方法查找處於死鎖狀態的平臺線程的迴圈;它不會查找處於死鎖狀態的虛擬線程的迴圈。
com.sun.management.HotSpotDiagnosticsMXBean中的一個新方法生成了上面描述的新式線程轉儲。可以通過平臺MBeanServer從本地或遠程JMX工具間接調用此方法。
java.lang.ThreadGroup
java.lang.ThreadGroup是一個用於分組線程的遺留API,在現代應用程式中很少使用,不適合分組虛擬線程。我們現在將其標記為已過時並降級,預計將來將在結構化併發的一部分中引入新的線程組織構造。
作為背景,ThreadGroup API來自Java 1.0。最初,它的目的是提供作業控制操作,如停止組中的所有線程。現代代碼更有可能使用自Java 5引入的java.util.concurrent包的線程池API。ThreadGroup支持早期Java版本中小程式的隔離,但Java 1.2中Java安全性架構的演進顯著,線程組不再扮演重要角色。ThreadGroup還旨在用於診斷目的,但這個角色在Java 5引入的監視和管理功能,包括java.lang.management API,中已被取代。
除了現在基本無關緊要外,ThreadGroup API和其實現存在一些重要問題:
銷毀線程組的能力存在缺陷。
API要求實現具有對組中的所有活動線程的引用。這會增加線程創建、線程啟動和線程終止的同步和爭用開銷。
API定義了enumerate()方法,這些方法本質上是競態條件的。
API定義了suspend()、resume()和stop()方法,這些方法本質上容易產生死鎖且不安全。
ThreadGroup現在被規定為已過時和降級如下:
明確刪除了顯式銷毀線程組的能力:已終止過時的destroy()方法不再執行任何操作。
刪除了守護線程組的概念:已終止過時的setDaemon(boolean)和isDaemon()方法設置和檢索的守護狀態被忽略。
現在,實現不再保持對子組的強引用。ThreadGroup現在在組中沒有活動線程且沒有其他東西保持線程組存活時可以被垃圾回收。
已終止的suspend()、resume()和stop()方法總是拋出異常。
替代方案
繼續依賴非同步API。非同步API難與同步API集成,創建了兩種表示相同I/O操作的不同表示,不提供用於上下文的操作序列的統一概念,無法用於故障排除、監視、調試和性能分析。
向Java添加語法無堆棧協程(即async/await)。與用戶模式線程相比,這些更易實現,並且將提供一種表示操作序列上下文的統一構造。然而,這個構造是新的,與線程分開,與線程在許多方面相似但在一些微妙的方式中不同。它將線上程設計的API和工具層面引入新的類似線程的構造。這需要更長時間來被生態系統接受,並且不像用戶模式線程與平臺一體的設計那樣優雅和和諧。
大多採用協程的語言之所以採用這種方法,是因為無法實現用戶模式線程(如Kotlin)、遺留的語義保證(如天生單線程的JavaScript)或特定於語言的技術約束(如C++)。這些限制不適用於Java。
引入一個新的用於表示用戶模式線程的公共類,與java.lang.Thread無關。這將是一個機會,可以擺脫Thread類在25年來積累的不必要負擔。探討和原型化了這種方法的幾個變體,但在每種情況下都遇到瞭如何運行現有代碼問題。
主要問題是Thread.currentThread()廣泛用於現有代碼,直接或間接。現有代碼中,這個方法必須返回一個表示當前執行線程的對象。如果我們引入一個新的類來表示用戶模式線程,那currentThread()將不得不返回某種看起來像Thread但代理到用戶模式線程對象的包裝對象。
有兩個對象表示當前執行線程將會令人困惑,最終決定保留舊的Thread API不是一個重大障礙。除了一些方法(例如currentThread())外,開發人員很少直接使用Thread API;他們主要與高級API(例如ExecutorService)交互。隨時間推移,將通過棄用和刪除Thread類和ThreadGroup等類中的過時方法來擺脫不需要負擔。
測試
現有的測試將確保我們在運行它們的多種配置和執行模式下的更改不會導致意外的回退。
我們將擴展jtreg測試工具,以允許在虛擬線程的上下文中運行現有測試。這將避免需要有許多測試的兩個版本。
新測試將測試所有新的和修訂的API,以及支持虛擬線程的所有更改區域。
新的壓力測試將針對可靠性和性能關鍵區域。
新的微基準測試將針對性能關鍵區域。
我們將使用多個現有伺服器,包括Helidon和Jetty,進行大規模測試。
風險和假設
此提案的主要風險是由於現有API和其實現的更改而產生的相容性問題:
對java.io.BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream和PrintWriter類中的內部(和未記錄的)鎖定協議的修訂可能會影響那些假設I/O方法在調用時會在其上同步的代碼。這些更改不會影響通過擴展這些類並假定由超類同步的代碼,也不會影響擴展java.io.Reader或java.io.Writer並使用這些API公開的鎖對象的代碼。
java.lang.ThreadGroup不再允許銷毀線程組,不再支持守護線程組的概念,並且其suspend()、resume()和stop()方法始終引發異常。
有一些源不相容的API更改和一個二進位不相容的更改,可能會影響那些擴展java.lang.Thread的代碼:
如果現有源文件中的代碼擴展了Thread並且子類中的方法與任何新的Thread方法衝突,則該文件將無法在不進行更改的情況下編譯。
Thread.Builder被添加為嵌套介面。如果現有源文件中的代碼擴展了Thread,導入了名為Builder的類,並且子類中引用“Builder”作為簡單名稱的代碼,則該文件將無法在不進行更改的情況下編譯。
Thread.threadId()被添加為一個返回線程標識符的final方法。如果現有源文件中的代碼擴展了Thread,並且子類聲明瞭一個名為threadId的無參數方法,則它將無法編譯。如果存在已編譯的擴展Thread的代碼,並且子類定義了一個返回類型為long且沒有參數的threadId方法,則在載入子類時將拋出IncompatibleClassChangeError。
在混合現有代碼與利用虛擬線程或新API的較新代碼時,可能會觀察到平臺線程和虛擬線程之間的一些行為差異:
Thread.setPriority(int)方法不會對虛擬線程產生影響,虛擬線程始終具有Thread.NORM_PRIORITY優先順序。
Thread.setDaemon(boolean)方法對虛擬線程沒有影響,虛擬線程始終是守護線程。
Thread.stop()、suspend()和resume()方法在虛擬線程上調用時會引發UnsupportedOperationException異常。
Thread API支持創建不支持線程本地變數的線程。在不支持線程本地變數的線程上調用ThreadLocal.set(T)和Thread.setContextClassLoader(ClassLoader)時會引發UnsupportedOperationException異常。
Thread.getAllStackTraces()現在返回所有平臺線程的映射,而不是所有線程的映射。
java.net.Socket、ServerSocket和DatagramSocket定義的阻塞I/O方法現在在虛擬線程的上下文中被中斷時可中斷。當線程在套接字操作上被中斷時,現有代碼可能會中斷,這將喚醒線程並關閉套接字。
虛擬線程不是ThreadGroup的活動成員。在虛擬線程上調用Thread.getThreadGroup()將返回一個名為"VirtualThreads"的虛擬線程組,該組為空。
虛擬線程在設置了SecurityManager的情況下沒有許可權。
在JVM TI中,GetAllThreads和GetAllStackTraces函數不返回虛擬線程。已啟用ThreadStart和ThreadEnd事件的現有代理程式可能會遇到性能問題,因為它們無法將這些事件限製為平臺線程。
java.lang.management.ThreadMXBean API支持監視和管理平臺線程,但不支持虛擬線程。
-XX:+PreserveFramePointer標誌對虛擬線程性能產生嚴重的負面影響。
依賴關係
JEP 416(使用Method Handles重新實現核心反射)在JDK 18中移除VM本機反射實現。這允許虛擬線程在通過反射調用方法時正常掛起。
JEP 353(使用新實現替換傳統Socket API)在JDK 13中,以及JEP 373(使用新實現替換傳統DatagramSocket API)在JDK 15中,替換了java.net.Socket、ServerSocket和DatagramSocket的實現,以適應虛擬線程的使用。
JEP 418(Internet地址解析SPI)在JDK 18中定義了一種主機名和地址查找的服務提供程式介面。這將允許第三方庫實現不會在主機查找期間釘住線程的替代java.net.InetAddress解析器。
本文由博客一文多發平臺 OpenWrite 發佈!