UTC 時間 2023 年 9 月 19 日,期盼已久的 Java 21 終於釋出正式版!
本文一起來看看其中最受 Java 開發者關注的一項新特性:Loom 專案的兩個新特性之一的 ”虛擬執行緒(Virtual Thread)“(另外一個新特性是 ”結構化併發(Structured Concurrency)“,當前是預覽狀態),它被稱之為 Java 版的 ”協程“,它到底是什麼?有什麼神奇之處嗎?
虛擬執行緒是輕量級執行緒(類似於 Go 中的 “協程(Goroutine)”),可以減少編寫、維護和排程高吞吐量併發應用程式的工作量。
執行緒是可供排程的最小處理單元,它與其他類似的處理單元併發執行,並且在很大程度上是獨立執行的。執行緒(java.lang.Thread
)有兩種,平臺執行緒和虛擬執行緒。
平臺執行緒
平臺執行緒也就是之前的普通執行緒 java.lang.Thread
的例項,它被實現為對作業系統執行緒的簡單包裝,它通常以 1:1 的比例對映到由作業系統排程的核心執行緒中。它在其底層作業系統執行緒上執行 Java 程式碼,並且在它的整個生命週期內捕獲著其對映的作業系統執行緒。因此,可用平臺執行緒的數量侷限於對應作業系統執行緒的數量。
平臺執行緒通常有一個大的堆疊和其他由作業系統維護的資源,它適合執行所有型別的任務,但可供使用的資源可能有限。
平臺執行緒可被指定為守護執行緒或非守護執行緒,除了守護執行緒狀態之外,平臺執行緒還具有執行緒優先順序,並且是執行緒組的成員。預設情況下,平臺執行緒會獲得自動生成的執行緒名稱。
與此同時,關於執行緒還有一些需要特別提到的變更,並值得我們的注意:如果我們先前有透過直接 new Thread(...)
手工建立單個平臺執行緒並使用(儘管此做法在大多數情況下是不推薦的)的話,請記住 Java 21 中的 suspend()
、 resume()
、stop()
和 countStackFrames()
等棄用方法將會直接丟擲 UnsupportedOperationException
異常,可能會影響到之前的業務處理邏輯!
虛擬執行緒
與平臺執行緒一樣,虛擬執行緒同樣是 java.lang.Thread
的例項,但是,虛擬執行緒並不與特定的作業系統執行緒繫結。它與作業系統執行緒的對映關係比例也不是 1:1,而是 m:n。虛擬執行緒通常是由 Java 執行時來排程的,而不是作業系統。虛擬執行緒仍然是在作業系統執行緒上執行 Java 程式碼,但是,當在虛擬執行緒中執行的程式碼呼叫阻塞的 I/O 操作時,Java 執行時會將虛擬執行緒掛起,直到其可以恢復為止。此時與掛起的虛擬執行緒相關聯的作業系統執行緒便可以自由地為其他虛擬執行緒來執行操作。
與平臺執行緒不同,虛擬執行緒通常有一個淺層呼叫棧,它只需要很少的資源,單個 Java 虛擬機器可能支援數百萬個虛擬執行緒(也正因為如此,儘管虛擬執行緒支援使用 ThreadLocal
或 InheritableThreadLocal
等執行緒區域性變數,我們也應該仔細考慮是否需要使用它們)。虛擬執行緒適合執行大部分時間被阻塞的任務,這些任務通常需要等待 I/O 操作完成,它不適合用於長時間執行的 CPU 密集型操作。
虛擬執行緒通常使用一小組平臺執行緒作為載體執行緒(Carrier Thread),在虛擬執行緒中執行的程式碼不知道其底層的載體執行緒。
虛擬執行緒是守護執行緒,具有固定的執行緒優先順序,不能更改。預設情況下,虛擬執行緒沒有執行緒名稱,如果未設定執行緒名稱,則獲取當前執行緒名稱時將會返回空字串。
那麼,為什麼要使用虛擬執行緒呢?
在高吞吐量併發應用程式中使用虛擬執行緒,尤其是那些包含由大量併發任務組成的應用程式,這些任務需要花費大量時間等待。例如伺服器應用程式,因為它們通常處理許多執行阻塞 I/O 操作(例如獲取資源)的客戶端請求。
虛擬執行緒並不是更快的執行緒,它們執行程式碼的速度並不會比平臺執行緒更快。它們的存在是為了提高擴充套件性(更高的吞吐量,而吞吐量意味著系統在給定時間內可以處理多少個資訊單元),而不是速度(更低的延遲)。
建立和執行虛擬執行緒
1. Thread.ofVirtual() 建立和執行虛擬執行緒
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join(); // 等待虛擬執行緒終止
Thread.startVirtualThread(task)
可以快捷地建立並啟動虛擬執行緒,它與 Thread.ofVirtual().start(task)
是等價的。
2. Thread.Builder 建立和執行虛擬執行緒
Thread.Builder
介面允許我們建立具有通用的執行緒屬性(例如執行緒名稱)的執行緒,Thread.Builder.OfPlatform
子介面建立平臺執行緒,而 Thread.Builder.OfVirtual
子介面則建立虛擬執行緒。
Thread.Builder builder = Thread.ofVirtual().name("MyThread"); // 虛擬執行緒的名稱是 MyThread
Runnable task = () -> System.out.println("Running thread");
Thread t = builder.start(task);
System.out.println("Thread t name: " + t.getName()); // 控制檯列印:Thread t name: MyThread
t.join();
下面的示例程式碼建立了 2 個虛擬執行緒,名稱分別是 worker-0 和 worker-1(這個是由 name()
中的兩個引數 prefix
和 start
指定的):
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
Runnable task = () -> System.out.println("Thread ID: " + Thread.currentThread().threadId());
// 虛擬執行緒 1,名稱為 worker-0
Thread t1 = builder.start(task);
t1.join();
System.out.println(t1.getName() + " terminated");
// 虛擬執行緒 2,名稱為 worker-1
Thread t2 = builder.start(task);
t2.join();
System.out.println(t2.getName() + " terminated");
以上示例程式碼執行結果,在控制檯中列印內容如下:
Thread ID: 21
worker-0 terminated
Thread ID: 24
worker-1 terminated
3. Executors.newVirtualThreadPerTaskExecutor() 建立和執行虛擬執行緒
Executor
允許我們將執行緒管理和建立與應用程式的其餘部分分開:
// Java 21 中 ExecutorService 介面繼承了 AutoCloseable 介面,
// 所以可以使用 try-with-resources 語法使 Executor 在最後被自動地 close()
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
// 每次 submit() 呼叫向 Executor 提交任務時都會建立和啟動一個新的虛擬執行緒
Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
future.get(); // 等待執行緒任務執行完成
System.out.println("Task completed");
} catch (ExecutionException | InterruptedException ignore) {}
4. 一個多執行緒的回顯客戶端伺服器示例
EchoServer
為回顯伺服器程式,監聽本地 8080 埠併為每個客戶端連線建立並啟動一個新的虛擬執行緒:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class EchoServer {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(8080)) {
while (true) {
try {
// 接受傳入的客戶端連線
Socket clientSocket = serverSocket.accept();
// 啟動服務執行緒,處理這個客戶端連線傳輸的資料並回顯。可以透過虛擬執行緒同時服務多個客戶端,每個客戶端連線一個執行緒。
Thread.ofVirtual().start(() -> {
try (PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()))) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println(inputLine);
out.println(inputLine);
}
} catch (IOException ignore) {}
});
} catch (Throwable unknown) {
break;
}
}
} catch (IOException e) {
System.err.println("Exception caught when trying to listen on port 8080 or listening for a connection: " + e.getMessage());
System.exit(1);
}
}
}
EchoClient
為回顯客戶端程式,它連線到本地的伺服器併傳送在命令列輸入的文字訊息:
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
public class EchoClient {
public static void main(String[] args) {
try (Socket echoSocket = new Socket("127.0.0.1", 8080);
PrintWriter out = new PrintWriter(echoSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(echoSocket.getInputStream()))) {
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
String userInput;
while ((userInput = stdIn.readLine()) != null) {
out.println(userInput);
System.out.println("echo: " + in.readLine());
if (userInput.equals("bye")) {
break;
}
}
} catch (Exception e) {
System.err.println("Couldn't get I/O for the connection to 127.0.0.1:8080: " + e.getMessage());
System.exit(1);
}
}
}
在上面的示例程式程式碼中,可以看到 EchoServer
的 while (true) {...}
無限迴圈體內每次接受到一個新的客戶端連線時,都建立和啟動一個新的虛擬執行緒,並且沒有用到虛擬執行緒池。請不要擔心,事實上以上不管哪種建立和執行虛擬執行緒的方式,其背後都有一個執行緒池 ForkJoinPool
(Carrier Thread 載體執行緒的池,這些載體執行緒是平臺執行緒)。ForkJoinPool
的預設的排程引數:parallelism
並行度為計算機處理器的可用核心數、maxPoolSize
池的最大執行緒數為 256 和 parallelism
的最大值、minRunnable
允許的不被 join
或阻塞的最小核心執行緒數為 1 和 parallelism
/2 的最大值,它們可以透過系統屬性啟動引數 jdk.virtualThreadScheduler.parallelism
、jdk.virtualThreadScheduler.maxPoolSize
、jdk.virtualThreadScheduler.minRunnable
自定義修改。
5. CompletableFuture 應當如何適應虛擬執行緒?
CompletableFuture
平常我們用得比較多,在有虛擬執行緒以前,它一個慣常的使用方法如下:
long startMills = System.currentTimeMillis();
ExecutorService executor = Executors.newFixedThreadPool(256);
List<CompletableFuture<Void>> futures = new ArrayList<>();
IntStream.range(0, 10000).forEach(i -> {
// 如果 runAsync 不指定 Executor,則會使用預設的執行緒池(除非系統不支援並行,否則會使用一個通用的 ForkJoinPool.commonPool 執行緒池)
CompletableFuture<Void> f = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException ignore) {
Thread.currentThread().interrupt();
}
}, executor);
futures.add(f);
});
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
System.out.println("【執行緒池】任務執行時間:" + (System.currentTimeMillis() - startMills) / 1000 + " 秒!");
以上示例程式碼執行結果,在控制檯中列印內容如下:
【執行緒池】任務執行時間:40 秒!
在有虛擬執行緒後,其實改動非常少,只需要將平臺執行緒池的 executor
替換為虛擬執行緒的 executor
即可:
long startMills = System.currentTimeMillis();
List<CompletableFuture<Void>> futures = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10000).forEach(i -> {
// 如果 runAsync 不指定 Executor,則會使用預設的執行緒池(除非系統不支援並行,否則會使用一個通用的 ForkJoinPool.commonPool 執行緒池)
CompletableFuture<Void> f = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException ignore) {
Thread.currentThread().interrupt();
}
}, executor);
futures.add(f);
});
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
System.out.println("【虛擬執行緒】任務執行時間:" + (System.currentTimeMillis() - startMills) / 1000 + " 秒!");
以上示例程式碼執行結果,在控制檯中列印內容如下:
【虛擬執行緒】任務執行時間:1 秒!
同時,也可以看到在這個示例程式碼的場景下,虛擬執行緒相比平臺執行緒池的方案在效能上提升了約 40 倍!
排程和固定虛擬執行緒
平臺執行緒由作業系統來排程並決定何時執行,但是虛擬執行緒是由 Java 執行時來排程並決定何時執行的。當 Java 執行時排程虛擬執行緒時,它在平臺執行緒上分配或掛載虛擬執行緒,然後作業系統像往常一樣排程該平臺執行緒,這個平臺執行緒稱為載體(Carrier)。執行一些程式碼後,虛擬執行緒可以從它的載體解除安裝,這通常發生在虛擬執行緒執行阻塞 I/O 操作時。虛擬執行緒從它的載體上解除安裝後,載體是空閒的,這意味著 Java 執行時排程器可以在其上掛載不同的虛擬執行緒。
在阻塞操作期間,當虛擬執行緒被固定到它的載體上時,它不能被解除安裝。虛擬執行緒在以下情況下會被固定(pinning):
- 虛擬執行緒在
synchronized
同步塊或方法中執行程式碼; - 虛擬執行緒執行本地方法(
native method
)或外部函式(foreign function
)。
固定不會使應用程式出錯,但可能會影響其擴充套件性。嘗試透過修改頻繁執行的 synchronized
同步塊或方法,並使用java.util.concurrent.locks.ReentrantLock
來保護可能長時間執行的 I/O 操作,以避免頻繁和長時間的虛擬執行緒固定。
除錯虛擬執行緒
虛擬執行緒仍然是執行緒,偵錯程式可以像平臺執行緒那樣對它們進行步進。Java Flight Recorder (JFR) 和 jcmd
工具具有額外的特性功能可以幫助觀察應用程式中的虛擬執行緒。
1. 用於虛擬執行緒的 JFR 事件
Java Flight Recorder (JFR) 可以發出以下與虛擬執行緒相關的事件:
jdk.VirtualThreadStart
和jdk.VirtualThreadEnd
虛擬執行緒的開始和結束的時間,這些事件在預設情況下是禁用的;jdk.VirtualThreadPinned
表示一個虛擬執行緒被固定(並且它的載體執行緒沒有被釋放)的超過閾值的持續時間,預設情況下啟用該事件,閾值為 20 毫秒;jdk.VirtualThreadSubmitFailed
表示啟動或取消掛起(unpark)虛擬執行緒失敗,可能是由於資源問題。掛起(park)一個虛擬執行緒釋放底層的載體執行緒去做其他工作,取消掛起(unpark)一個虛擬執行緒以被排程它繼續,該事件預設開啟。
要列印這些事件,請執行以下命令,其中 recording.jfr
是我們記錄的檔名:
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed recording.jfr
2. 檢視 jcmd 執行緒轉儲中的虛擬執行緒
可以建立純文字或 JSON 格式的執行緒轉儲:
jcmd <PID> Thread.dump_to_file -format=text <file>
jcmd <PID> Thread.dump_to_file -format=json <file>
jcmd
執行緒轉儲列出在網路 I/O 操作中阻塞的虛擬執行緒和由 ExecutorService
介面建立的虛擬執行緒。它不包括物件地址、鎖、JNI 統計資訊、堆統計資訊和其他出現在傳統執行緒轉儲中的資訊。
總結:虛擬執行緒採用指南
虛擬執行緒是由 Java 執行時而不是作業系統實現的 Java 執行緒。虛擬執行緒和傳統執行緒(我們現在稱之為平臺執行緒)之間的主要區別在於,我們可以很容易地在同一個 Java 程式中執行大量活動的虛擬執行緒,甚至數百萬個。大量的虛擬執行緒賦予了它們強大的功能:透過允許伺服器併發處理更多的請求,它們可以更有效地執行以每個請求一個執行緒的方式編寫的伺服器應用程式,從而實現更高的吞吐量和更少的硬體浪費。
由於虛擬執行緒是 java.lang.Thread
的實現,並且遵循自 Java SE 1.0 以來指定的 java.lang.Thread
的相同規則,因此開發人員不需要學習使用它們的新概念。然而,由於無法生成非常多的平臺執行緒(多年來 Java 中唯一可用的執行緒實現),因此產生了旨在應對其高成本的實踐做法。當這些做法應用於虛擬執行緒時會適得其反,必須摒棄。此外,成本上的巨大差異提示了一種考慮執行緒的新方式,這些執行緒一開始可能是外來的。
1. 編寫簡單、同步的程式碼,採用單請求單執行緒風格的阻塞 I/O API
虛擬執行緒可以顯著提高以單請求單執行緒(Thread-Per-Request)的方式編寫的伺服器應用程式的吞吐量(而不是延遲)。在這種風格中,伺服器在整個持續時間內專用一個執行緒來處理每個傳入請求。它至少專用一個執行緒,因為在處理單個請求時,我們可能希望使用更多的執行緒來併發地執行一些任務。
阻塞平臺執行緒的代價很高,因為它佔用了系統執行緒(相對稀缺的資源),而它並沒有做多少有意義的工作。因為虛擬執行緒可能很多,所以阻塞它們的成本很低,而且應該得到提倡。因此,應該以直接的同步風格編寫程式碼,並使用阻塞 I/O API。
以下這種以非阻塞、非同步風格編寫的程式碼不會從虛擬執行緒中獲得太多好處:
CompletableFuture.supplyAsync(info::getUrl, pool)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
.thenApply(info::findImage)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
.thenApply(info::setImageData)
.thenAccept(this::process)
.exceptionally(ignore -> null);
但是下面這種以同步風格編寫並使用簡單阻塞 I/O 的程式碼卻將受益匪淺:
try {
String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
String imageUrl = info.findImage(page);
byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
info.setImageData(data);
process(info);
} catch (Exception ignore) {}
這樣的程式碼也更容易在偵錯程式中進行除錯,在分析器中進行概要分析,或者使用執行緒轉儲進行觀察。為了觀察虛擬執行緒,使用 jcmd
命令建立一個執行緒轉儲:
jcmd <pid> Thread.dump_to_file -format=json <file>
以這種風格編寫的堆疊越多,虛擬執行緒的效能和可觀察性就越好。用其他風格編寫的程式或框架,如果沒有為每個任務指定一個執行緒,就不應該期望從虛擬執行緒中獲得顯著的好處。避免將同步、阻塞程式碼與非同步框架混在一起。
2. 將每個併發任務表示為一個虛擬執行緒,不要池化虛擬執行緒
關於虛擬執行緒,最難內化的是,雖然它們具有與平臺執行緒相同的行為,但它們不應該表示相同的程式概念。
平臺執行緒是稀缺的,因此是一種寶貴的資源。需要管理寶貴的資源,管理平臺執行緒的最常用方法是使用執行緒池。接下來需要回答的問題是,池中應該有多少執行緒?
但是虛擬執行緒非常多,因此每個執行緒不應該代表一些共享的、池化的資源,而應該代表一個任務。執行緒從託管資源轉變為應用程式域物件。我們應該有多少個虛擬執行緒的問題變得很明顯,就像我們應該使用多少個字串在記憶體中儲存一組使用者名稱的問題一樣:虛擬執行緒的數量總是等於應用程式中併發任務的數量。
將 n 個平臺執行緒轉換為 n 個虛擬執行緒不會產生什麼好處;相反,需要轉換的是任務。
為了將每個應用程式任務表示為一個執行緒,不要像下面的例子那樣使用共享執行緒池執行器:
Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);
// ... 使用 f1、f2
相反地,應該使用虛擬執行緒執行器,如下例所示:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { // 注意這裡實際上並沒有將虛擬執行緒進行池化
Future<ResultA> f1 = executor.submit(task1);
Future<ResultB> f2 = executor.submit(task2);
// ... 使用 f1、f2
}
程式碼仍然使用 ExecutorService
,但是從 Executors.newVirtualThreadPerTaskExecutor()
返回的那個沒有使用執行緒池。相反,它為每個提交的任務建立一個新的虛擬執行緒。
此外,ExecutorService
本身是輕量級的,我們可以建立一個新的,就像處理任何簡單的物件一樣。這允許我們依賴於新新增的ExecutorService.close()
方法和 try-with-resources
語句。在 try
塊結束時隱式呼叫的 close
方法將自動等待提交給ExecutorService
的所有任務(即由 ExecutorService
生成的所有虛擬執行緒)終止。
對於 fanout 場景,這是一個特別有用的模式,在這種場景中,我們希望併發地向不同的服務執行多個傳出呼叫,如下面的示例所示:
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);
}
}
我們應該建立一個新的虛擬執行緒,如上例所示,即使是小型的、短暫的併發任務也是如此。
為了在編寫 fanout 模式和其他常見併發模式時獲得更多幫助,並且具有更好的可觀察性,請使用結構化併發。
根據經驗,如果我們的應用程式從來沒有 10000 個或更多的虛擬執行緒,那麼它不太可能從虛擬執行緒中獲益。要麼它的負載太輕,不需要更好的吞吐量,要麼我們沒有向虛擬執行緒表示有足夠多的任務。
3. 使用訊號量限制併發
有時需要限制某個確定操作的併發性。例如,某些外部服務可能無法處理 10 個以上的併發請求。由於平臺執行緒是通常在池中管理的寶貴資源,因此執行緒池已經變得如此普遍,以至於它們被用於限制併發性的目的,如下例所示:
ExecutorService es = Executors.newFixedThreadPool(10); // 固定執行緒池的核心及最大執行緒數量為 10
...
Result foo() {
try {
var fut = es.submit(() -> callLimitedService());
return f.get();
} catch (...){ ...}
}
此示例確保對有限的服務最多有 10 個併發請求。
但是限制併發性只是執行緒池操作的副作用。池被設計為共享稀缺資源,而虛擬執行緒並不稀缺,因此永遠不應該被池化!
在使用虛擬執行緒時,如果希望限制訪問某些服務的併發性,則應該使用專門為此目的設計的構造:Semaphore
類。如下示例:
Semaphore sem = new Semaphore(10); // 初始化一個訊號量,擁有 10 個許可
...
Result foo() {
sem.acquire(); // 申請許可,如果當前沒有許可了,則阻塞直至其他執行緒 release 以釋放許可
try {
return callLimitedService(); // 只有申請並獲得了許可的執行緒,才能進入此處執行業務邏輯,從而控制了併發性
} finally {
sem.release(); // 釋放許可,以供其他執行緒使用
}
}
簡單地用訊號量阻塞一些虛擬執行緒可能看起來與將任務提交到一個固定執行緒池有很大的不同,但事實上並非如此。將任務提交到執行緒池會將它們排隊等待以供稍後執行,但是訊號量內部(或任何其他類似的阻塞同步構造)會建立一個阻塞在它上面的執行緒佇列,這些執行緒被阻塞在其上,與等待池化的平臺執行緒來執行它們的任務佇列相對應。因為虛擬執行緒即是任務,所以其結果結構是等價的:
即使我們可以將平臺執行緒池視為從佇列中提取任務並處理它們的工作執行緒,而將虛擬執行緒視為等待繼續執行的任務本身,但在計算機中的基礎表示實際上幾乎相同。認識到排隊的任務和被阻塞的執行緒之間的等效性將有助於我們充分利用虛擬執行緒。
4. 不要線上程區域性變數中快取昂貴的可重用物件
虛擬執行緒與平臺執行緒一樣支援執行緒區域性變數。通常,執行緒區域性變數用於將某些與當前執行的程式碼相關的上下文特定資訊關聯起來,例如當前的事務和使用者 ID。在虛擬執行緒中,使用執行緒本地變數來實現這種用途是完全合理的。但是,考慮使用更安全和更高效的作用域值(java.lang.ScopedValue
,當前為預覽特性)。
還有一種使用執行緒區域性變數的方式與虛擬執行緒存在根本性衝突:快取可重複使用的物件。這些物件通常建立昂貴(並消耗大量記憶體),是可變的,並且不是執行緒安全的。它們被快取線上程區域性變數中,以減少它們被例項化的次數和記憶體中的例項數量,但它們會被在不同時間執行線上程上的多個任務重複使用。
例如,SimpleDateFormat
的例項建立昂貴且不是執行緒安全的。一種常見的做法是將這樣的例項快取在 ThreadLocal 中,如下例所示:
static final ThreadLocal<SimpleDateFormat> cachedFormatter = ThreadLocal.withInitial(SimpleDateFormat::new);
void foo() {
...
cachedFormatter.get().format(...);
...
}
這種型別的快取僅線上程(因此線上程區域性快取的昂貴物件)被多個任務共享和重複使用時才有幫助,就像在平臺執行緒池中的池化執行緒時的情況一樣。線上程池中執行時,許多工可能會呼叫 foo
,但由於池中只包含一些執行緒,該物件只會被例項化幾次 - 每個池執行緒一次 - 然後被快取和重複使用。
然而,虛擬執行緒從不被池化,也不會被不相關的任務重複使用。因為每個任務都有自己的虛擬執行緒,來自不同任務的每次對 foo
的呼叫都會觸發新的 SimpleDateFormat
例項的例項化。而且,由於可能有大量虛擬執行緒同時執行,昂貴的物件可能會消耗大量記憶體。這與執行緒區域性快取的預期成果完全相反。
沒有單一的通用替代方案,但在 SimpleDateFormat
的情況下,我們應該將其替換為 DateTimeFormatter
。DateTimeFormatter
是不可變的,因此可以由所有執行緒共享單個例項:
static final DateTimeFormatter formatter = DateTimeFormatter….;
void foo() {
...
formatter.format(...);
...
}
請注意,有時候,使用執行緒區域性變數來快取共享的昂貴物件是由非同步框架在幕後完成的,這是它們的隱式假設,認為它們會被一個非常小的執行緒池中的執行緒使用。這就是為什麼混合使用虛擬執行緒和非同步框架不是一個好主意的原因之一:呼叫一個方法可能會導致在本應快取和共享的執行緒本地變數中例項化昂貴的物件。
5. 避免長時間和頻繁的固定
目前虛擬執行緒的實現存在一個限制,即在 synchronized
同步塊或方法內執行阻塞操作會導致 JDK 的虛擬執行緒排程器阻塞一個寶貴的作業系統執行緒,而如果阻塞操作在 synchronized
同步塊或方法之外執行,就不會出現這種情況。我們稱這種情況為 “pinning”(固定)。如果阻塞操作既長時間存在又頻繁發生,pinning 可能會對伺服器的吞吐量產生不利影響。使用 synchronized
同步塊或方法保護短時操作(例如記憶體操作)或不頻繁的操作應該不會產生不利影響。
為了檢測可能有害的 pinning 情況,JDK Flight Recorder(JFR)在阻塞操作被固定時會發出 jdk.VirtualThreadPinned
執行緒事件;預設情況下,當操作持續時間超過 20 毫秒時,此事件被啟用。
或者,我們可以使用系統屬性 jdk.tracePinnedThreads
,線上程被固定時發出堆疊跟蹤。使用選項 -Djdk.tracePinnedThreads=full
時,當執行緒被固定時會列印完整的堆疊跟蹤,突出顯示本機幀和持有監視器的幀。使用選項 -Djdk.tracePinnedThreads=short
時,輸出將限制為僅包括有問題的幀。
如果這些機制檢測到 pinning 在某些地方既長時間存在又頻繁發生,那麼在那些特定地方使用 ReentrantLock
替代 synchronized
(再次強調,不需要替代用於保護短時操作或不頻繁操作的 synchronized
)。以下是一個長時間存在且頻繁使用同步塊的示例:
synchronized(lockObj) {
frequentIO();
}
我們可以將其替換為:
lock.lock();
try {
frequentIO();
} finally {
lock.unlock();
}