虛擬執行緒是由 Java 21 版本中實現的一種輕量級執行緒。它由 JVM 進行建立以及管理。虛擬執行緒和傳統執行緒(我們稱之為平臺執行緒)之間的主要區別在於,我們可以輕鬆地在一個 Java 程式中執行大量、甚至數百萬個虛擬執行緒。
由於虛擬執行緒的數量眾多,也就賦予了 Java 程式強大的力量。虛擬執行緒適合用來處理大量請求,它們可以更有效地執行 “一個請求一個執行緒” 模型編寫的 web 應用程式,可以提高吞吐量以及減少硬體浪費。
由於虛擬執行緒是 java.lang.Thread 的實現,並且遵守自 Java SE 1.0 以來指定 java.lang.Thread 的相同規則,因此開發人員無需學習新概念即可使用它們。
但是虛擬執行緒才剛出來,對我們來說有一些陌生。由於 Java 歷來版本中無法生成大量平臺執行緒(多年來 Java 中唯一可用的執行緒實現),已經讓程式設計師養成了一套關於平臺執行緒的使用習慣。這些習慣做法在應用於虛擬執行緒時會適得其反,我們需要摒棄。
此外虛擬執行緒和平臺執行緒在建立成本上的巨大差異,也提供了一種新的關於執行緒使用的方式。Java 的設計者鼓勵使用虛擬執行緒而不必擔心虛擬執行緒的建立成本。
本文無意全面涵蓋虛擬執行緒的每個重要細節,目的只是提供一套介紹性指南,以幫助那些希望開始使用虛擬執行緒的人充分利用它們。
本文完整大綱如下,
請大方使用同步阻塞 IO
虛擬執行緒可以顯著提高以 “一個請求一個執行緒” 模型編寫的 web 應用程式的吞吐量(注意不是延遲)。在這種模型中,web 應用程式針對每個客戶端請求都會建立一個執行緒進行處理。因此為了處理更多的客戶端請求,我們需要建立更多的執行緒。
在 “一個請求一個執行緒” 模型中使用平臺執行緒的成本很高,因為平臺執行緒與作業系統執行緒對應(作業系統執行緒是一種相對稀缺的資源),阻塞了平臺執行緒,會讓它無事可做一直處於阻塞中,這樣就會造成很大的資源浪費。
然而,在這個模型中使用虛擬執行緒就很合適,因為虛擬執行緒非常廉價就算被阻塞也不會造成資源浪費。因此在虛擬執行緒出來後,Java 的設計者是建議我們應該以簡單的同步風格編寫程式碼並使用阻塞 IO。
舉個例子,以下用非阻塞非同步風格編寫的程式碼是不會從虛擬執行緒中受益太多的,
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(t -> { t.printStackTrace(); return null; });
另一方面,以下用同步風格並使用阻塞 IO 編寫的程式碼使用虛擬執行緒將受益匪淺,
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 ex) {
t.printStackTrace();
}
並且上面的同步程式碼也更容易在偵錯程式中除錯、在分析器中分析或透過執行緒轉儲進行觀察。要觀察虛擬執行緒,可以使用 jcmd 命令建立執行緒轉儲,
jcmd <pid> Thread.dump_to_file -format=json <file>
用同步風格並使用阻塞 IO 風格編寫的程式碼越多,虛擬執行緒的效能和可觀察性就越好。而用非同步非阻塞 IO 風格編寫的程式或框架,如果每個任務沒有專用一個執行緒,則無法從虛擬執行緒中獲得顯著的好處。
使用虛擬執行緒,我們因該避免將同步阻塞 IO 與非同步非阻塞 IO 混為一談。
避免池化虛擬執行緒
關於虛擬執行緒使用方面最難理解的一件事情就是,我們不應該池化虛擬執行緒。雖然虛擬執行緒具有與平臺執行緒相同的行為,但虛擬執行緒和執行緒池其實是兩種概念。
平臺執行緒是一種稀缺資源,因為它很寶貴。越寶貴的資源就越需要管理,管理平臺執行緒最常見的方法是使用執行緒池。
不過在使用執行緒池後,我們需要回答的一個問題,執行緒池中應該有多少個執行緒?最小執行緒數、最大執行緒數應該設定多少?這也是一個問題。
虛擬執行緒是一種非常廉價的資源,每個虛擬執行緒不應代表某些共享的、池化的資源,而應代表單一任務。在應用程式中,我們應該直接使用虛擬執行緒而不是透過執行緒池使用它。
那麼我們應該建立多少個虛擬執行緒嘞?答案是不必在乎虛擬執行緒的數量,我們有多少個併發任務就可以有多少個虛擬執行緒。
如下是一段提交任務的程式碼,將每個任務都提交到執行緒池中執行,在 Java 21 以後,不建議再使用共享執行緒池執行器,程式碼如下,
Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);
// ... use futures
建議使用虛擬執行緒執行器,程式碼如下,
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<ResultA> f1 = executor.submit(task1);
Future<ResultB> f2 = executor.submit(task2);
// ... use futures
}
上面程式碼雖然仍使用 ExecutorService,但從 Executors.newVirtualThreadPerTaskExecutor() 方法返回的執行器不再使用執行緒池。它會為每個提交的任務都建立一個新的虛擬執行緒。
此外,ExecutorService 本身是輕量級的,我們可以像建立任何簡單物件一樣直接建立一個新的 ExecutorService 物件而不必考慮複用。
這使我們能夠依賴 Java 19 中新新增的 ExecutorService.close() 方法和 try-with-resources 語法糖。在 try 塊末尾隱式呼叫 ExecutorService.close() 方法,會自動等待提交給 ExecutorService 的所有任務(即 ExecutorService 生成的所有虛擬執行緒)終止。
對於廣播場景來說,使用 Executors.newVirtualThreadPerTaskExecutor() 比較合適,在這種場景中,希望同時對不同的服務執行多個傳出呼叫,並且方法結束時就關閉執行緒池,程式碼如下,
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);
}
}
針對廣播模式和其他常見的併發模式,如果希望有更好的可觀察性,建議使用結構化併發。這是 Java 21 中新出的特性,這裡給大家賣個關子,我將在後續進行講解。
根據經驗來說,如果我們的應用程式從未經歷 1 萬的併發訪問,那麼它不太可能從虛擬執行緒中受益。一方面它負載太輕而不需要更高的吞吐量,一方面併發請求任務也不夠多。
參考資料
最後說兩句
針對虛擬執行緒的使用,相信大家心裡已經有了答案。虛擬執行緒不同於平臺執行緒,它非常廉價,Java 的設計者鼓勵我們直接使用虛擬執行緒,而無需池化,也不必擔心過多的虛擬現場會影響效能。
事實上,虛擬現場就是為了解決同步阻塞 IO 對硬體的資源利用率不夠高這一問題。
關注公眾號【waynblog】每週分享技術乾貨、開源專案、實戰經驗、國外優質文章翻譯等,您的關注將是我的更新動力!