監(jiān)控Java虛擬線程
監(jiān)控 Java 虛擬線程

開發(fā)便利性 與 運行高效性
簡介
在我之前的文章中,我們已經(jīng)討論了什么是虛擬線程(VTs),他們與物理線程(PTs)之間的區(qū)別,以及如何使用 Java 虛擬線程(JVTs)來創(chuàng)建反應(yīng)式微服務(wù)(Helidon 4)。
簡單來說,VTs(虛擬線程)引入了一種新的并發(fā)模型。與使用可能會被阻塞的PTs(物理線程)不同,我們僅使用少量的VTs,且?guī)缀醪粫蛔枞W枞粋€PT是昂貴的,可能會限制我們的擴展能力。另一方面,VTs即使遭遇阻塞,亦不會增加額外成本。
有了VTs(虛擬線程),我們可以以平鋪直敘的方式直接編碼,而不必使用復(fù)雜的響應(yīng)式編程,與此同時仍然享受代碼高效運行好處。換句話說,我們不再需要在“開發(fā)便利性 與 運行高效性”之間做出兩難選擇。
為了進(jìn)一步探討這個主題,我邀請您觀看我在2023年EclipseCon上關(guān)于Java Loom的開發(fā)者視角的演講。
虛擬線程監(jiān)控的具體細(xì)節(jié)
有一個值得考慮的重要問題被VTs(虛擬線程)引入了,那就是監(jiān)控:我們?nèi)绾未_保我們的應(yīng)用程序以最佳方式使用虛擬線程?在涉及虛擬線程時,我們到底需要監(jiān)控哪些具體內(nèi)容?
為了回答這些問題,我們需要理解VTs(虛擬線程)在底層是如何工作的,特別是如何管理阻塞操作:

在JVM 內(nèi)部一切都圍繞著一個名為Continuation的類展開。Continuation類是Java的內(nèi)部一個類,作為開發(fā)者,我們不必直接與Continuation交互。像是Java內(nèi)部的NIO庫和垃圾回收器一樣被很好的封裝。
Continuation類暴露了兩個主要方法:
- run 方法用于啟動(或重啟)一個任務(wù)
- yield 方法用于暫停正在運行的任務(wù)。
在內(nèi)部,虛擬線程(VT)將其任務(wù)執(zhí)行委托給一個Continuation類的示例。
從本質(zhì)上講,虛擬線程(VT)是一種輕量級并發(fā)構(gòu)造器,它沒有自己的執(zhí)行能力。當(dāng)它運行時,被掛載到一個物理線程(PT)上運行。更確切地說,這個物理線程(PT)是專門提供給虛擬線程(VT)執(zhí)行的ForkJoinPool。這些專門定制的物理線程(PT)又被成為載荷線程Carrier Threads(CTs)。
虛擬線程(VT),物理線程(PT),載荷線程(CT),這么多詞匯。別迷糊,一切都會變得清晰。
當(dāng)虛擬線程(VT)被阻塞,例如等待IO時,會調(diào)用Continuation類的yield方法,并且虛擬線程會被卸載。此時載荷線程(CT)不會被阻塞(這就是神奇之處!),載荷線程持有任務(wù)資源將會被返還,此時會執(zhí)行其他虛擬線程(VT)任務(wù)。
當(dāng)IO讀寫完畢,Continuation的run方法將會被調(diào)用(由JVM內(nèi)部的讀取輪詢線程調(diào)度),并且該虛擬線程會被重新掛載到一個載荷線程(CT)上(并不一定仍然是之前的線程)重新繼續(xù)運行。
為了啟用這種掛載/卸載機制,我們需要保存和恢復(fù) Java 堆棧,即任務(wù)的執(zhí)行上下文。它被保存在 Java 堆中,所有對象都存儲在那里。這意味著虛擬線程的使用會增加內(nèi)存占用,并給垃圾收集器(GC)增加壓力。作為 Java 開發(fā)人員,我們知道當(dāng) GC 開始進(jìn)行full collections時,延遲會提升、可伸縮性會變差。對于虛擬線程來說更是如此!
顯然,使用虛擬線程加強了對內(nèi)存占用和垃圾收集器活動進(jìn)行監(jiān)控的必要性。
我們可以通過使用以下經(jīng)典方法來監(jiān)控虛擬線程的使用:
- Java 啟動參數(shù):-Xlog:gc 或 -XX:NativeMemoryTracking
- Java 飛行記錄器(JFR)事件。
跟蹤牽制線程(pinned threads)
所以,多虧了Continuation的魔力,載荷線程(CTs)永遠(yuǎn)不會被阻塞...真是這樣嗎?實際上,它們幾乎不會阻塞。但在某些情況下,虛擬線程(VT)不能被卸載,從而阻止其關(guān)聯(lián)的載荷線程(CT)。
當(dāng)Java堆棧地址被非Java代碼引用時會發(fā)生這種情況。當(dāng)從堆中還原時,Java地址會被重新定位,使非Java引用不再有效。
為了防止這種錯誤發(fā)生,將載荷線程(CT)設(shè)置為“牽制”狀態(tài)。(這意味著它永遠(yuǎn)不會被卸載。)

這種情況發(fā)生在以下情況下:
- 調(diào)用了同步(synchronized)塊或方法
- 調(diào)用了本地代碼:即來自 JVM 本身的內(nèi)部代碼,或使用 JNI 或外部調(diào)用代碼的外部調(diào)用函數(shù)。
這是一個問題嗎?這取決于…… 當(dāng)牽制線程背后的處理過程緩慢且頻繁調(diào)用時,這可能會限制性能,例如在 JDBC 驅(qū)動程序中。 另一方面,當(dāng)處理速度快或調(diào)用不頻繁時,這就不是問題了。
這意味著我們必須跟蹤牽制線程,以識別那些可能影響性能的線程。
我們可以利用以下方式跟蹤:
- -Djdk.tracePinnedThreads Java 啟動選項:它能夠確定代碼中發(fā)生牽制線程的位置。
- JFR 事件 jdk.VirtualThreadPinned:它能夠識別可能影響性能的牽制線程。此事件默認(rèn)啟用,閾值為 20 毫秒(可配置):
例如,這種配置可以設(shè)置跟蹤牽制線程閾值為 5 毫秒:
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">5 ms</setting>
</event>
在這種情況下,只有持續(xù)時間超過 5 毫秒的牽制線程才會被監(jiān)視。
我的框架如何使用虛擬線程?
沒有使用虛擬線程(VTs)的唯一標(biāo)準(zhǔn)方式。每個框架都根據(jù)其架構(gòu)和繼續(xù)需求進(jìn)行取舍適配。
正如我們在之前的文章中看到的那樣,Helidon 4提出了一種激進(jìn)的方案,即業(yè)務(wù)代碼系統(tǒng)性且有條理地在虛擬線程(VTs)的上下文中運行。

這需要注意的是,Helidon 4 在內(nèi)部利用物理線程(PTs)來滿足其連接管理需求。業(yè)務(wù)代碼是系統(tǒng)性且有條理地在虛擬線程(VTs)中運行。
由于其架構(gòu)的原因,Quarkus 提供了一種不同的方案。按Quarkus的設(shè)計,其使用兩種類型的專門化物理線程(PTs):
- IO 線程,執(zhí)行 IO 和非阻塞反應(yīng)式代碼
- Worker 線程,執(zhí)行阻塞代碼。
作為一種可選項,可以通過使用特性的注解,在虛擬線程(VTs)中運行阻塞代碼:

這是一種混合方案,允許開發(fā)者逐步遷移到虛擬線程(VTs)中。
那么,Helidon 的激進(jìn)方法還是 Quarkus 的混合方法更好?這并沒有絕對的答案。這取決于業(yè)務(wù)背景上下文:
- 激進(jìn)方法的優(yōu)點在于簡單易行。然而,在處理CPU密集型任務(wù)或固定線程影響性能時,它并不是最佳選擇。
- 混合方法允許逐步、可控地采用虛擬線程。然而,由于導(dǎo)致IO線程和虛擬線程工作線程之間的上下文切換,它并不理想。Quarkus 架構(gòu)可能會進(jìn)化出跟優(yōu)秀的方案以更好地利用虛擬線程(VTs)。
以上兩個示例表明,我們需要了解我們喜愛的框架如何使用虛擬線程(VTs),并監(jiān)控虛擬線程(VTs)的創(chuàng)建和刪除方式
。
這可以通過啟用和跟蹤 JFR 事件來完成:jdk.VirtualThreadStart 和 jdk.VirtualThreadEnd。這些事件默認(rèn)是禁用的,要啟用它們,必須使用特定的 JFR 配置:
<event name="jdk.VirtualThreadStart">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
</event>
<event name="jdk.VirtualThreadEnd">
<setting name="enabled">true</setting>
</event>
并且引入對應(yīng)配置:
java -XX:StartFlightRecording,settings=/path/to/jfr-config ...
監(jiān)控 ForkJoinPool
正如我們所知,F(xiàn)orkJoinPool 扮演了一個至關(guān)重要的角色在 Java 虛擬線程(VTs)中。如果池子太小,調(diào)度將會減慢并且降低性能。
可以使用以下系統(tǒng)屬性配置 ForkJoinPool:
jdk.virtualThreadScheduler.parallelism:池大小(有多少個載荷線程(CT)),默認(rèn)為 CPU 核心數(shù)jdk.virtualThreadScheduler.maxPoolSize:池的最大大小,默認(rèn)為 256。當(dāng)載荷線程(CT)被阻塞時(由于操作系統(tǒng)或 JVM 的限制),載荷線程(CT)的數(shù)量可能會暫時超過并行值設(shè)置的數(shù)量。請注意,調(diào)度程序不會通過擴大并行性來補償牽制jdk.virtualThreadScheduler.minRunnable:在池中保持可運行的最小線程數(shù)。
在大多數(shù)情況下,默認(rèn)值是合適的,不需要更改它們。
在這個階段,我還沒有找到監(jiān)測專用于虛擬線程(VTs)的 ForkJoinPool 的方法。例如,擁有確定虛擬線程(VTs)調(diào)度延遲的指標(biāo)(虛擬線程(VTs)裝載到載荷線程(CT)上需要多長時間)并相應(yīng)地調(diào)整配置會很有趣。
結(jié)論
在本文中,我們確定了使用虛擬線程(VTs)時需要監(jiān)控的主要技術(shù)要素:
- 內(nèi)存堆大小和 GC 活動:這些是需要用虛擬線程(VTs)加強的經(jīng)典監(jiān)控元素
- 牽制線程,尤其是那些可能影響性能的線程
- 虛擬線程(VTs)的創(chuàng)建:每個框架都有自己的虛擬線程(VTs)使用策略,需要加以理解
- ForkJoinPool 調(diào)度程序大小調(diào)整:不適當(dāng)?shù)拇笮≌{(diào)整可能導(dǎo)致性能下降,即使(據(jù)我所知)沒有真正的監(jiān)控方法,我們也可以對配置進(jìn)行一些處理總的來說,以下是我推薦的 Java 選項:
java \
-Djdk.tracePinnedThreads=short \
-XX:NativeMemoryTracking=summary \
-Xlog:gc:/path/to/gc-log \
-XX:StartFlightRecording,settings=...,name=...,filename=... \
-jar /path/to/app-jar-file
我們將在下一篇文章中研究使用 Helidon 和 Quarkus 的具體方法。
參考
Networking I/O with Virtual Threads – Under the hood
The Ultimate Guide to Java Virtual Threads
浮生潦草閑愁廣,一聽啤酒一口盡
浙公網(wǎng)安備 33010602011771號