Java 21 的虛擬線程:高性能并發應用的福音
Java 21 最重要的特性之一就是虛擬線程 (JEP 444)。這些輕量級的線程降低了編寫、維護和觀察高吞吐量并行應用所需的努力。
在討論新特性之前,讓我們先看一下當前的狀態,以便更好地理解它試圖解決什么問題以及帶來了哪些好處。
平臺線程
在引入虛擬線程之前,我們習慣使用的線程是 java.lang.Thread,它背后是所謂的平臺線程 (platform threads)。
這些線程通常與操作系統調度的內核線程一一映射。操作系統線程相當“重”,這使得它們適合執行所有類型的任務。
根據操作系統和配置,它們默認情況下會消耗大約2到10 MB的內存。因此,如果你想在高負載并發應用程序中使用一百萬個線程,最好要有超過2 TB的可用內存!
這存在一個明顯的瓶頸,限制了我們實際可以在沒有缺點的情況下擁有的線程數量。
每個請求一個線程
這很成問題,因為它直接與典型的服務器應用程序“每個請求一個線程”的方法相沖突。使用每個請求一個線程有很多優點,例如更簡單的狀態管理和清理。但它也創造了可擴展性限制。應用程序的“并發單位”,在這種情況下是一個請求,需要一個“平臺并發單位”。因此,線程很容易被原始CPU能力或網絡耗盡。
即使“每個請求一個線程”有許多優點,共享重量級的線程可以更均勻地利用硬件,但也需要一種完全不同的方法。
異步救援
而不是在單個線程上運行整個請求,它的每個部分都從池中使用一個線程,當它們的任務完成時,另一個任務可能會重用同一個線程。這允許代碼需要更少的線程,但引入了異步編程的負擔。
異步編程伴隨著它自己的范例,具有一定的學習曲線,并且可能會使程序更難理解和跟蹤。請求的每個部分可能都在不同的線程上執行,從而創建沒有合理上下文的堆棧跟蹤,并使調試某些內容變得非常棘手甚至幾乎不可能。
Java有一個用于異步編程的優秀API,CompletableFuture。但這是一個復雜的API,并且不太適合許多Java開發人員習慣的思維方式。
重新審視“每個請求一個線程”模型,很明顯,一種更輕量級的線程方法可以解決瓶頸并提供一種熟悉的做事方式。
輕量級線程
由于平臺線程的數量是無法在沒有更多硬件的情況下改變的,因此需要另一個抽象層,切斷可怕的 1:1 映射,它是首先造成瓶頸的原因。
輕量級線程不與特定的平臺線程綁定,也不會伴隨大量的預分配內存。它們由運行時而不是底層操作系統調度和管理。這就是為什么可以創建大量輕量級線程的原因。
這個概念并不新鮮,許多語言都采用某種形式的輕量級線程:
- Go 語言中的 Goroutine
- Erlang 進程
- Haskell 線程
- 等等
Java最終于第21版中引入了自己的輕量級線程實現:虛擬線程 (Virtual Threads)。
虛擬線程
虛擬線程是一種新的輕量級java.lang.Thread變體,是Project Loom的一部分,它不是由操作系統管理或調度的。相反,JVM負責調度。
當然,任何實際的工作都必須在平臺線程中運行,但是JVM使用所謂的“載體線程”(carrier threads) 來“攜帶”任何虛擬線程,以便在它們需要執行時執行這些線程。
圖片
JVM/操作系統線程調度器
所需的平臺線程在一個 FIFO 工作竊取 ForkJoinPool 中進行管理,該池默認情況下使用所有可用的處理器,但可以通過調整系統屬性jdk.virtualThreadScheduler.parallelism來根據需求進行修改。
ForkJoinPool與其他功能(例如并行流)使用的通用池之間的主要區別在于,通用池以LIFO模式運行。
廉價且豐富的線程
擁有廉價且輕量級的線程,可以使用“每個請求一個線程”模型,而不必擔心實際需要多少個線程。如果你的代碼在虛擬線程中調用阻塞 I/O 操作,則運行時會掛起虛擬線程,直到它可以稍后恢復。
這樣,硬件就可以被優化到幾乎最佳的水平,從而實現高水平的并發性,因此也實現高吞吐量。
因為它們非常廉價,所以虛擬線程不會被重用或需要池化。每個任務都由其自己的虛擬線程表示。
設置邊界
調度器負責管理載體線程,因此需要一定的邊界和分離,以確保可能的“無數”虛擬線程按照預期運行。這是通過在載體線程及其可能攜帶的任何虛擬線程之間不保持線程關聯來實現的:
- 虛擬線程無法訪問載體,Thread.currentThread() 返回虛擬線程本身。
- 堆棧跟蹤是分開的,任何在虛擬線程中拋出的異常只包含其自己的堆棧幀。
- 虛擬線程的線程局部變量對它的載體不可用,反之亦然。
- 從代碼的角度來看,載體及其虛擬線程共享一個平臺線程是不可見的。
讓我們看看代碼
使用Virtual Threads最大的好處是,你不需要學習新的范例或復雜的API,就像使用異步編程一樣。相反,你可以像對待非虛擬線程一樣處理它們。
創建平臺線程
創建平臺線程很簡單,就像使用 Runnable 創建一樣:
Runnable fn = () -> {
// your code here
};
Thread thread = new Thread(fn).start();
隨著Project Loom簡化了新的并發方法,它還提供了一種創建平臺支持線程的新方法:
Thread thread = Thread.ofPlatform().
.start(runnable);
實際上,現在還有一個完整的fluent API,因為ofPlatform()會返回一個Thread.Builder.OfPlatform實例:
Thread thread = Thread.ofPlatform().
.daemon()
.name("my-custom-thread")
.unstarted(runnable);
但你肯定不是來學習創建“舊”線程的新方法的,我們想要一點新的東西。繼續看。
創建虛擬線程
對于虛擬線程,也有類似的fluent API:
Runnable fn = () -> {
// your code here
};
Thread thread = Thread.ofVirtual(fn)
.start();
除了構建器方法之外,你還可以直接使用以下方式執行Runnable:
Thread thread = Thread.startVirtualThread(() -> {
// your code here
});
由于所有虛擬線程始終是守護線程,因此如果你想在主線程上等待,請不要忘記調用join()。
創建虛擬線程的另一種方法是使用 Executor:
var executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> {
// your code here
});
小結
盡管Scoped Values (JEP 446) 和Structured Concurrency (JEP 453) 仍然是Java 21中的預覽功能,但Virtual Threads已經成為一個成熟的、適用于生產環境的功能。
它們是Java并發的一種通用且強大的新方法,將對我們未來的程序產生重大影響。它們使用了熟悉的和可靠的“每個請求一個線程”方法,同時以最優化的方式利用所有可用硬件,而不需要學習新的范例或復雜的API。