作者 | Deepu K Sasidharan 譯者 | 張衛濱 ?
本文最初發表于 okta 網站,經原作者 Deepu K Sasidharan 授權由 InfoQ 中文站翻譯分享,未經許可禁止轉載。
Java 19 已經于日前發布,其中最引人注目的特性就要數虛擬線程了,本文介紹了 Loom 項目中虛擬線程和結構化編程的基礎知識,并將其與操作系統線程進行了對比分析。
Java 在其發展早期就具有良好的多線程和并發能力,能夠高效地利用多線程和多核 CPU。Java 開發工具包(Java Development Kit,JDK)1.1 對平臺線程(或操作系統(OS)線程)提供了基本的支持,JDK 1.5 提供了更多的實用工具和更新,以改善并發和多線程。JDK 8 帶來了異步編程支持和更多的并發改善。雖然在多個不同的版本中都進行了改進,但在過去三十多年中,除了基于操作系統的并發和多線程支持之外,Java 并沒有任何突破性的進展。
盡管 Java 中的并發模型非常強大和靈活,但它并不是最易于使用的,而且開發人員的體驗也不是很好。這主要是因為它默認使用的共享狀態并發模型。我們必須借助同步線程來避免數據競爭(data race)和線程阻塞這樣的問題。我曾經在一篇名為“現代編程語言中的并發:Java”的博客文章中討論過 Java 并發問題。
1Loom 項目是什么?
Loom 項目致力于大幅減少編寫、維護和觀察高吞吐量并發應用相關的工作,以最佳的方式利用現有的硬件。?
——Ron?Pressler(Loom?項目的技術負責人)
操作系統線程是 Java 并發模型的核心,圍繞它們有一個非常成熟的生態系統,但是它們也有一些缺點,如計算方式很昂貴。我們來看一下并發的兩個最常見使用場景,以及當前的 Java 并發模型在這些場景下的缺點。
最常見的并發使用場景之一就是借助服務器在網絡上為請求提供服務。在這樣的場景中,首選的方法是“每個請求一個線程(thread-per-request)”模型,即由一個單獨的線程處理每個請求。這種系統的吞吐量可以用 Little 定律來計算,該定律指出,在一個穩定的系統中,平均并發量(服務器并發處理的請求數)L 等于吞吐量(請求的平均速率)λ乘以延遲(處理每個請求的平均時間)W。基于此,我們可以得出,吞吐量等于平均并發除以延遲(λ = L/W)。
因此,在“每個請求一個線程”模型中,吞吐量將受到操作系統線程數量的限制,這取決于硬件上可用的物理核心 / 線程數。為了解決這個問題,我們必須使用共享線程池或異步并發,這兩種方法各有缺點。線程池有很多限制,如線程泄漏、死鎖、資源激增等。異步并發意味著必須要適應更復雜的編程風格,并謹慎處理數據競爭。它們還有可能出現內存泄漏、線程鎖定等問題。
另一個常見的使用場景是并行處理或多線程,我們可能會把一個任務分成跨多個線程的子任務。此時,我們必須編寫避免數據損壞和數據競爭的解決方案。在有些情況下,當執行分布在多個線程上的并行任務時,還必須要確保線程同步。這種實現會非常脆弱,并且將大量的責任推給了開發人員,以確保沒有像線程泄露和取消延遲這樣的問題。
Loom 項目旨在通過引入兩個新特性來解決當前并發模型中的這些問題,即虛擬線程(virtual thread)和結構化并發(structured concurrency)。
2虛擬線程
Java 19 已經于 2022 年 9 月 20 日發布,虛擬線程是其中的一項預覽功能。
虛擬線程是輕量級的線程,它們不與操作系統線程綁定,而是由 JVM 來管理。它們適用于“每個請求一個線程”的編程風格,同時沒有操作系統線程的限制。我們能夠創建數以百萬計的虛擬線程而不會影響吞吐。這與 Go 編程語言(Golang)的協程(如 goroutines)非常相似。
Java 19 中的虛擬線程新特性很易于使用。在這里,我將其與 Golang 的 goroutines 以及 Kotlin 的 coroutines 進行了對比。
虛擬線程
Thread.startVirtualThread(() -> { System.out.println("Hello, Project Loom!"); });
Goroutine
go func() { println("Hello, Goroutines!") }()
Kotlin coroutine
runBlocking { launch { println("Hello, Kotlin coroutines!") } }
冷知識:在 JDK 1.1 之前,Java 曾經支持過綠色線程(又稱虛擬線程),但該功能在 JDK 1.1 中移除了,因為當時該實現并沒有比平臺線程更好。
虛擬線程的新實現是在 JVM 中完成的,它將多個虛擬線程映射為一個或多個操作系統線程,開發人員可以按需使用虛擬線程或平臺線程。這種虛擬線程實現還有如下幾個注意事項:
在代碼、運行時、調試器和剖析器(profiler)中,它是一個 Thread。
它是一個 Java 實體,并不是對原生線程的封裝。
創建和阻塞它們是代價低廉的操作。
它們不應該放到池中。
虛擬線程使用了一個基于任務竊取(work-stealing)的 ForkJoinPool 調度器。
可以將可插拔的調度器用于異步編程中。
虛擬線程會有自己的棧內存。
虛擬線程的 API 與平臺線程非常相似,因此更容易使用或移植。
我們看幾個展示虛擬線程威力的樣例。
線程的總數量
首先,我們看一下在一臺機器上可以創建多少個平臺線程和虛擬線程。我的機器是英特爾酷睿 i9-11900H 處理器,8 個核心、16 個線程、64GB 內存,運行的操作系統是 Fedora 36。
平臺線程
var counter = new AtomicInteger(); while (true) { new Thread(() -> { int count = counter.incrementAndGet(); System.out.println("Thread count = " + count); LockSupport.park(); }).start(); }
在我的機器上,在創建 32,539 個平臺線程后代碼就崩潰了。
虛擬線程
var counter = new AtomicInteger(); while (true) { Thread.startVirtualThread(() -> { int count = counter.incrementAndGet(); System.out.println("Thread count = " + count); LockSupport.park(); }); }
在我的機器上,進程在創建 14,625,956 個虛擬線程后被掛起,但沒有崩潰,隨著內存逐漸可用,它一直在緩慢進行。你可能想知道為什么會出現這種情況。這是因為被 park 的虛擬線程會被垃圾回收,JVM 能夠創建更多的虛擬線程并將其分配給底層的平臺線程。
任務吞吐量
我們嘗試使用平臺線程來運行 100,000 個任務。
try (var executor = Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory())) { IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); System.out.println(i); return i; })); }
在這里,我們使用了帶有默認線程工廠的 newThreadPerTaskExecutor 方法,因此使用了一個線程組。運行這段代碼并計時,我得到了如下的結果。當使用 Executors.newCachedThreadPool() 線程池時,我得到了更好的性能。
# 'newThreadPerTaskExecutor' with 'defaultThreadFactory' 0:18.77 real, 18.15 s user, 7.19 s sys, 135% 3891pu, 0 amem, 743584 mmem # 'newCachedThreadPool' with 'defaultThreadFactory' 0:11.52?real,???13.21?s?user,???4.91?s?sys,?????157%?6019pu,????0?amem,?????????2215972?mmem
看著還不錯。現在,讓我們用虛擬線程完成相同的任務。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 100_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); System.out.println(i); return i; })); }
運行這段代碼并計時,我得到了如下結果:
0:02.62?real,??? 6.83?s?user,?? ??1.46?s?sys,? ????316%?14840pu,?? ?0?amem,?????? ???350268?mmem
這比基于平臺線程的線程池要好得多。當然,這些都是很簡單的使用場景,線程池和虛擬線程的實現都可以進一步優化以獲得更好的性能,但這不是這篇文章的重點。
用同樣的代碼運行 Java Microbenchmark Harness(JMH),得到的結果如下。可以看到,虛擬線程的性能比平臺線程要好很多。
# Throughput Benchmark Mode Cnt Score Error Units LoomBenchmark.platformThreadPerTask thrpt 5 0.362 ± 0.079 ops/s LoomBenchmark.platformThreadPool thrpt 5 0.528 ± 0.067 ops/s LoomBenchmark.virtualThreadPerTask thrpt 5 1.843 ± 0.093 ops/s # Average time Benchmark Mode Cnt Score Error Units LoomBenchmark.platformThreadPerTask avgt 5 5.600 ± 0.768 s/op LoomBenchmark.platformThreadPool avgt 5 3.887 ± 0.717 s/op LoomBenchmark.virtualThreadPerTask????avgt????5??1.098?±?0.020???s/op
你可以在 GitHub 上找到該基準測試的源代碼。如下是其他幾個有價值的虛擬線程基準測試:
在 GitHub 上,Elliot Barlas 使用 ApacheBench 做的一個有趣的基準測試。
Alexander Zakusylo 在 Medium 上使用 Akka actors 的基準測試。
在 GitHub 上,Colin Cachia 做的 I/O 和非 I/O 任務的 JMH 基準測試。
3結構化并發
結構化并發是 Java 19 中的一個孵化功能。
結構化并發的目的是簡化多線程和并行編程。它將在不同線程中運行的多個任務視為一個工作單元,簡化了錯誤處理和任務取消,同時提高了可靠性和可觀測性。這有助于避免線程泄漏和取消延遲等問題。作為一個孵化功能,在穩定過程中可能會經歷進一步的變更。
我們考慮如下這個使用 java.util.concurrent.ExecutorService 的樣例。
void handleOrder() throws ExecutionException, InterruptedException { try (var esvc = new ScheduledThreadPoolExecutor(8)) { Futureinventory = esvc.submit(() -> updateInventory()); Future order = esvc.submit(() -> updateOrder()); int theInventory = inventory.get(); // Join updateInventory int theOrder = order.get(); // Join updateOrder System.out.println("Inventory " + theInventory + " updated for order " + theOrder); } }
我們希望 updateInventory() 和 updateOrder() 這兩個子任務能夠并發執行。每一個任務都可以獨立地成功或失敗。理想情況下,如果任何一個子任務失敗,handleOrder() 方法都應該失敗。然而,如果某個子任務發生失敗的話,事情就會變得難以預料。
設想一下,updateInventory() 失敗并拋出了一個異常。那么,handleOrder() 方法在調用 invent.get() 時將會拋出異常。到目前為止,還沒有什么大問題,但 updateOrder() 呢?因為它在自己的線程上運行,所以它可能會成功完成。但是現在我們就有了一個庫存和訂單不匹配的問題。假設 updateOrder() 是一個代價高昂的操作。在這種情況下,我們白白浪費了資源,不得不編寫某種防護邏輯來撤銷對訂單所做的更新,因為我們的整體操作已經失敗。
假設 updateInventory() 是一個代價高昂的長時間運行操作,而 updateOrder() 拋出一個錯誤。即便 updateOrder() 拋出了錯誤,handleOrder() 任務依然會在 inventory.get() 方法上阻塞。理想情況下,我們希望 handleOrder() 任務在 updateOrder() 發生故障時取消 updateInventory(),這樣就不會浪費時間了。
如果執行 handleOrder() 的線程被中斷,那么中斷不會被傳播到子任務中。在這種情況下,updateInventory() 和 updateOrder() 會泄露并繼續在后臺運行。
對于這些場景,我們必須小心翼翼地編寫變通方案和故障防護措施,把所有的職責推到了開發人員身上。
我們可以使用下面的代碼,用結構化并發實現同樣的功能。
void handleOrder() throws ExecutionException, InterruptedException { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Futureinventory = scope.fork(() -> updateInventory()); Future order = scope.fork(() -> updateOrder()); scope.join(); // Join both forks scope.throwIfFailed(); // ... and propagate errors // Here, both forks have succeeded, so compose their results System.out.println("Inventory " + inventory.resultNow() + " updated for order " + order.resultNow()); } }
與之前使用 ExecutorService 的樣例不同,我們現在使用 StructuredTaskScope 來實現同樣的結果,并將子任務的生命周期限制在詞法的作用域內,在本例中,也就是 try-with-resources 語句體內。這段代碼更易讀,而且意圖也很清楚。StructuredTaskScope 還自動確保以下行為:
基于短路的錯誤處理:如果 updateInventory() 或 updateOrder() 失敗,另一個將被取消,除非它已經完成。這是由 ShutdownOnFailure() 實現的取消策略來管理的,我們還可以使用其他策略。
取消傳播:如果運行 handleOrder() 的線程在調用 join() 之前或調用過程中被中斷的話,當該線程退出作用域時,兩個分支(fork)都會被自動取消。
可觀察性:線程轉儲文件將清楚地顯示任務層次,運行 updateInventory() 和 updateOrder() 的線程被顯示為作用域的子線程。
4Loom 項目狀況
Loom 項目開始于 2017 年,經歷了許多變化和提議。虛擬線程最初被稱為 fibers,但后來為了避免混淆而重新進行了命名。如今隨著 Java 19 的發布,該項目已經交付了上文討論的兩個功能。其中一個是預覽狀態,另一個是孵化狀態。因此,這些特性的穩定化之路應該會更加清晰。
5這對普通的 Java 開發人員意味著什么?
當這些特性生產環境就緒時,應該不會對普通的 Java 開發人員產生太大的影響,因為這些開發人員可能正在使用某些庫來處理并發的場景。但是,在一些比較罕見的場景中,比如你可能進行了大量的多線程操作但是沒有使用庫,那么這些特性就是很有價值的了。虛擬線程可以毫不費力地替代你現在使用的線程池。根據現有的基準測試,在大多數情況下它們都能提高性能和可擴展性。結構化并發有助于簡化多線程或并行處理,使其能加健壯,更易于維護。
6這對 Java 庫開發人員意味著什么?
當這些特性生產環境就緒時,對于使用線程或并行的庫和框架來說,將是一件大事。庫作者能夠實現巨大的性能和可擴展性提升,同時簡化代碼庫,使其更易維護。大多數使用線程池和平臺線程的 Java 項目都能夠從切換至虛擬線程的過程中受益,候選項目包括 Tomcat、Undertow 和 Netty 這樣的 Java 服務器軟件,以及 Spring 和 Micronaut 這樣的 Web 框架。我預計大多數 Java web 技術都將從線程池遷移到虛擬線程。Java web 技術和新興的反應式編程庫,如 RxJava 和 Akka,也可以有效地使用結構化并發。但這并不意味著虛擬線程將成為所有問題的解決方案,異步和反應式編程仍然有其適用場景和收益。
了解更多關于 Java、多線程和 Loom 項目的信息:
On the Performance of User-Mode Threads and Coroutines
https://inside.java/2020/08/07/loom-performance/
State of Loom
http://cr.openjdk.java.net/~rpressler/loom/loom/sol1_part1.html
Project Loom: Modern Scalable Concurrency for the Java Platform
https://www.youtube.com/watch?v=EO9oMiL1fFo
Thinking About Massive Throughput? Meet Virtual Threads!
https://foojay.io/today/thinking-about-massive-throughput-meet-virtual-threads/
Does Java 18 finally have a better alternative to JNI?
https://developer.okta.com/blog/2022/04/08/state-of-ffi-java
OAuth for Java Developers
https://developer.okta.com/blog/2022/06/16/oauth-jav
Cloud?Native Java Microservices with JHipster and Istio?https://developer.okta.com/blog/2022/06/09/cloud-native-java-microservices-with-istio
編輯:黃飛
?
評論
查看更多