Java 21 版本更新中最重要的功能之一就是虛擬線程 (JEP 444)。這些輕量級線程減少了編寫、維護和觀察高吞吐量併發應用程式所需的工作量。 ...
本文翻譯自國外論壇 medium,原文地址:https://medium.com/@benweidig/looking-at-java-21-virtual-threads-0ddda4ac1be1
Java 21 版本更新中最重要的功能之一就是虛擬線程 (JEP 444)。這些輕量級線程減少了編寫、維護和觀察高吞吐量併發應用程式所需的工作量。
正如我的許多其他文章一樣,在推出新功能之前,讓我們先看看 Java 21 版本更新前的現狀,以便更好地瞭解 Java 21 版本試圖解決的問題以及好處是什麼。
平臺線程
在引入虛擬線程之前,java.lang.Thread
包已經支持所謂的平臺線程。
這些線程通常以 1:1 的方式映射到操作系統調度的內核線程。操作系統線程相當“重”。這使得它們可以執行所有類型的任務。
根據操作系統和 JVM 啟動參數配置的不同,一個平臺線程預設消耗 1 MB 的空間。因此如果我們想在重負載高併發應用程式中使用一百萬個線程,我們最好有超過 1 TB 的空閑記憶體!
如上所述,平臺線程有一個明顯的記憶體瓶頸限制了我們實際上可以擁有的線程數量。
每個請求一個線程
每個請求使用單個線程有很多優點,例如更容易的狀態管理和清理。但它也造成了可擴展性限制。應用程式的“併發單元”(在本例中為請求)需要單個“併發平臺單元”(在本例中也就是平臺線程),但是在重負載高併發應用程式中,平臺線程容易因為記憶體不足、CPU 資源耗盡而創建失敗。
儘管“每個請求一個線程”有很多優點,平臺線程可以更均勻地利用硬體,但我們還是需要一種完全不同的方法。
使用線程池
與在單個線程上處理以個請求不同,當任務完成時,線程會被線程池回收,因此另一個請求可能會重用相同的線程。這允許我們的程式使用更少的線程處理更多的請求,但會帶來非同步編程的負擔。
非同步編程具有自己的範例,具有一定的學習曲線,並且可能使我們的程式更難以理解和遵循。請求的每個部分可能在不同的線程上執行,在沒有合理上下文的情況下創建堆棧跟蹤,並使調試變得非常棘手甚至幾乎不可能。
重新審視“每個請求一個線程”模型,很明顯,我們需要一種更輕量級的線程方法來解決這個瓶頸,並最好按照我們熟悉的方式。
輕量級線程
由於平臺線程的數量在不新增硬體資源的情況下無法改變,因此也就需要另一層抽象,以切斷首先產生瓶頸的可怕的 1:1 映射。
輕量級線程不依賴於特定的平臺線程,也不會為其分配大量記憶體。它們由運行時的 JVM 調度和管理而不是底層操作系統。這就是為什麼可以創建大量輕量級線程的原因。
輕量級線程的概念並不新鮮,許多語言都有某種形式的輕量級線程:
- Go 語言中的 Goroutines(協程)
- Erlang 語言中的 Processes(輕量級進程)
- Haskell Threads
Java 也在 21 版本中引入了自己的輕量級線程實現:虛擬線程。
虛擬線程
虛擬線程是一個新的輕量級 java.lang.Thread
變體,是 Project Loom
項目的一部分,不由操作系統管理或調度。相反由 JVM 負責調度。當然在實際工作反映到操作系統還是以平臺線程運行,但 JVM 正是利用所謂的載體線程(即平臺線程)來“承載”虛擬線程,以便在需要時執行。
所需的平臺線程以 FIFO 工作方式在 ForkJoinPool
中進行管理,預設情況下,它使用所有可用的處理器,但可以通過調整系統屬性 jdk.virtualThreadScheduler.parallelism
來根據我們的要求進行修改。我們熟悉的 ForkJoinPool
與並行流等其他功能使用的公共池之間的主要區別在於,公共池以 LIFO 模式運行。
物美價廉
虛擬線程是廉價且輕量級的,我們可以使用“每個請求一個線程”模型,而不必擔心實際需要多少個線程。如果我們的代碼在虛擬線程中調用阻塞 I/O 操作,則運行時會掛起這個被阻塞的虛擬線程,直到掛起結束後就可以恢復。這樣一來,程式對硬體的利用就可以達到近乎最佳並提供高水平的併發性,從而實現高吞吐量。
因為虛擬線程非常便宜,所以虛擬線程不會被重用或需要被池化。每個任務都由其自己的虛擬線程來執行。
設定界限
JVM 調度程式通過載體線程來管理虛擬線程,因此需要一定的邊界和分隔來確保可能的“無數”虛擬線程按預期運行。這是通過在載體線程和它可能承載的任何虛擬線程之間保持無線程關聯來實現的:
- 虛擬線程無法訪問載體線程,
Thread.currentThread()
返回虛擬線程本身。 - 堆棧跟蹤是獨立的,虛擬線程中拋出的任何異常僅包含其自己的堆棧幀。
- 虛擬線程的線程局部變數對其載體線程不可用,反之亦然。
- 從代碼的角度來看,載體線程及其虛擬線程對平臺線程的共用是不可見的。
代碼展示
在我看來,虛擬線程最好的事情之一就是我們不需要學習新的編程範例或複雜的新 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();
除了構建器方法之外,我們還可以直接使用以下命令創建虛擬線程:
Thread thread = Thread.startVirtualThread(() -> {
// your code here
});
由於所有虛擬線程始終都是守護線程,因此如果我們想在主線程上等待虛擬線程執行完畢,可以調用 join()
方法。
創建虛擬線程的另一種方法是使用 Executor
類:
var executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> {
// your code here
});
總結
儘管作用域值 (JEP 446) 和結構化併發 (JEP 453) 仍然是 Java 21 中的預覽功能,但虛擬線程已經成為可投入生產的成熟功能。
虛擬線程是一種通用且強大的 Java 併發新方式,將對我們的未來程式產生重大影響。虛擬線程使用熟悉且可靠的“每個請求一個線程”方法,同時以最佳方式利用所有可用硬體,無需學習新範例或複雜的 API。
關註公眾號【waynblog】每周分享技術乾貨、開源項目、實戰經驗、國外優質文章翻譯等,您的關註將是我的更新動力!