Java 21 的虛擬執行緒:高效能併發應用的福音

帶你聊技術發表於2023-12-11

來源:程式新視界


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) 來“攜帶”任何虛擬執行緒,以便在它們需要執行時執行這些執行緒。

Java 21 的虛擬執行緒:高效能併發應用的福音

所需的平臺執行緒在一個 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。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024922/viewspace-2999476/,如需轉載,請註明出處,否則將追究法律責任。

相關文章