同步虛擬線程而不進行線程鎖定
1. 引言
借助虛擬線程,我們可以擴展應用程式效能,以應對任何 I/O 密集型工作流程。相較之下,使用平台執行緒時,我們必須謹慎管理昂貴的作業系統資源,並採用非阻塞 I/O,這涉及複雜的非同步程式碼,例如CompletableFuture 。
儘管虛擬執行緒解決了作業系統執行緒數量有限的問題,但某些情況下仍可能導致平台執行緒阻塞。
在本教程中,我們將透過範例程式碼學習不同的鎖定場景。我們還將調試並實現其中一個範例的修復方案。此外,我們還將學習 JDK 24 中解決的這些場景。
2. 虛擬執行緒固定場景
虛擬執行緒是一種生命週期短、輕量級的執行緒結構,由 JVM 調度器掛載到平台「承載」執行緒上。執行任務後,它便從平台執行緒卸載。平台線程隨後即可用於執行下一個任務。
然而,有時虛擬線程和對應的承載線程會被阻塞較長時間。
雖然這不會影響業務邏輯,但會降低應用程式的可擴展性。一些常見原因包括執行佔用大量 CPU 資源的任務、持有鎖期間的等待,或被阻塞在本地方法的執行中。
雖然我們應該避免將虛擬執行緒用於任何 CPU 密集型操作,但我們仍然需要了解其他使用情境。
2.1. 同步方法或區塊
讓我們設想一個將商品加入購物車的現實例子。
我們將實作CartService類,並新增一個update方法,將productId作為鎖來管理:
public class CartService {
private final Map<String, Integer> products;
private final Map<String, Object> locks = new ConcurrentHashMap<>();
public void update(String productId, int quantity) {
Object lock = locks.computeIfAbsent(productId, k -> new Object());
synchronized (lock) {
simulateAPI();
products.merge(productId, quantity, Integer::sum);
}
LOGGER.info("Updated Cart for {} {}", productId, quantity);
}
}
在上面的程式碼中,我們呼叫的是模擬 API 而不是實際的 API,目的是為了示範。
我們將使用thread sleep方法來模擬下游 API 呼叫:
private void simulateAPI() {
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
接下來,我們將嘗試調試上面的程式碼。
2.2. 調試引腳
讓我們使用 Java 飛行記錄器來測試上面的程式碼。
我們將透過在虛擬執行緒中執行CartService update方法並斷言 JFR 事件來驗證綁定是否成功:
@Test
void givenJFRIsEnabled_whenVThreadIsBlocked_thenDetectVThreadPinned() throws Exception {
Path file = Path.of("pinning.jfr");
try (Recording recording = new Recording()) {
recording.enable("jdk.VirtualThreadPinned")
.withThreshold(Duration.ofMillis(1));
recording.start();
Thread th = Thread.ofVirtual().start(() ->
cartService.update("test1", 2));
th.join();
recording.stop();
recording.dump(file);
}
try (RecordingFile rf = new RecordingFile(file)) {
assertTrue(rf.hasMoreEvents());
while (rf.hasMoreEvents()) {
RecordedEvent event = rf.readEvent();
System.out.println(event);
assertEquals("jdk.VirtualThreadPinned", event.getEventType().getName());
assertEquals("Virtual Thread Pinned", event.getEventType().getLabel());
}
}
Files.delete(file);
}
在上面的測試中,我們斷言RecordedEvent包含jdk.VirtualThreadPinned事件。
此外,我們也可以在控制台日誌中看到jdk.VirtualThreadPinned事件:
jdk.VirtualThreadPinned {
startTime = 13:28:30.738 (2026-03-29)
duration = 101 ms
eventThread = "" (javaThreadId = 32, virtual)
}
此外,我們還可以在 JDK Mission Control 控制面板中查看已置頂的事件:
或者,我們可以使用-Djdk.tracePinnedThreads=full VM 標誌來偵測執行緒鎖定:
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
com.baeldung.virtualthread.synchronize.CartService.simulateAPI(CartService.java:35)
com.baeldung.virtualthread.synchronize.CartService.update(CartService.java:22) <== monitors:1
以上日誌證實平台線程已被阻塞。
此外,任何使用方法級同步、使用wait進行同步或使用LockSupport.park方法的程式碼都會導致鎖定。
2.3. 本地方法
鎖定也可能發生在本機方法呼叫中。本機方法可能會阻塞任何 I/O 操作或回調到 Java 程式碼,進而阻塞顯示器。
我們將實作一個簡單的原生方法:
public class NativeDemo {
static {
System.loadLibrary("native-lib");
}
public native String nativeCall();
}
執行緒被鎖定的原因是該執行緒無法控製本地堆疊,需要等待本地方法呼叫返回。
同樣,在使用外部函數 API 運行原生函數時,我們也可以看到相同的結果:
public void execute() {
LOGGER.info("Running foreign function sleep...");
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle sleep = linker.downcallHandle(stdlib.find("sleep")
.orElseThrow(), FunctionDescriptor.of(JAVA_INT, JAVA_LONG));
try {
sleep.invoke(100);
} catch (Throwable ex) {
throw new RuntimeException(ex);
}
}
在上面的程式碼中,外部函數sleep方法的定義方法是:先建立Linker對象,然後包含 downcall MethodHandle實例。
從 JDK 21/24 開始,JFR 不會為在本機代碼或 FFM 代碼中發生的阻塞發出jdk.VirtualThreadPinned事件。
不過,我們可以透過分析線程轉儲及其對其他虛擬線程的影響來確認線程掛起。
2.4. 靜態初始化區塊
讓我們想像一個帶有靜態初始化塊的類別。
我們將實作一個靜態初始化區塊,其中包含一個Thread.sleep方法來阻塞平台執行緒:
public class HeavyClass {
static {
try {
Thread.sleep(100);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
LOGGER.info("static initialization done");
}
}
在上面的程式碼中,我們期望虛擬執行緒被鎖定,因為靜態初始化器持有一個內在鎖。
2.5. 自訂類別載入器
我們將實作一個自訂類別載入器來驗證上述場景中的虛擬執行緒綁定。
首先,我們透過擴充ClassLoader類別並重寫findClass方法來實作一個CustomClassLoader類別:
class CustomClassLoader extends ClassLoader {
private final Path classDir;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
LOGGER.info("Finding class for {}", name);
try {
Path file = classDir.resolve(name.replace('.', '/') + ".class");
byte[] bytes = java.nio.file.Files.readAllBytes(file);
Thread.sleep(100);
return defineClass(name, bytes, 0, bytes.length);
} catch (InterruptedException | IOException ex) {
LOGGER.error("Error while finding class file {}", ex.getMessage());
throw new ClassNotFoundException(ex.getMessage(), ex);
}
}
}
接下來,我們需要實作loadClass方法來覆寫系統類別classloader :
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
LOGGER.info("Load class for {}", name);
Class<?> clazz = findLoadedClass(name);
if (clazz == null) {
try {
clazz = findClass(name);
} catch (ClassNotFoundException ex) {
clazz = super.loadClass(name, resolve);
}
}
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
我們將對啟用 JFR 錄製的類別載入器執行類似的測試,並驗證固定執行緒事件。
對於任何使用同一類的虛擬線程,如果另一個線程正在初始化該類,則情況也是如此。
3. 實現無綁定同步
儘管 JVM 試圖透過暫時增加虛擬執行緒調度器的並行度來彌補執行緒綁定,但其預設最大值仍為 256。
不過,我們可以利用 Java 的高階並發控制支援來克服這個問題。
我們可以使用支援 Loom 的鎖定機制,例如ReentrantLock或ReadWriteLock類別java.util.concurrent.locks套件中的鎖定實作不會導致綁定。
我們將使用ReentrantLock鎖來實作上述CartService's update方法:
public void update(String productId, int quantity) {
Lock lock = locks.computeIfAbsent(productId, k -> new ReentrantLock());
try {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
simulateAPI();
products.merge(productId, quantity, Integer::sum);
} finally{
lock.unlock();
}
LOGGER.info("Updated Cart for {} {}", productId, quantity);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
如果我們用修復後的版本再次執行先前的測試,則不會觀察到任何執行緒鎖定事件,也不會阻塞其他執行緒。
解決不必要的版本鎖定問題的另一種方法是升級到 JDK 24 或更高版本,開發人員已在這些版本中修復了該問題。
如果升級 Java 版本的工作量遠小於跨服務重新實作程式碼的工作量,我們建議升級Java 版本。
4. JDK 24 中解決的版本鎖定問題
作為JEP-491的一部分,同步方法和程式碼區塊的綁定問題已解決。
Java 團隊更改了synchronized關鍵字的實現,現在虛擬執行緒可以獨立於其承載執行緒取得、持有和釋放鎖定。
系統仍然要求在本機方法、類別載入器和類別初始化器中進行固定。
5. 基準測試
我們將針對先前的CartService update方法,使用AverageTime和Throughput模式實作 JMH 基準測試:
@BenchmarkMode({ Mode.AverageTime, Mode.Throughput })
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 1, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(value = 2)
@State(Scope.Benchmark)
public class BenchmarkVirtualThread {
private final CartService cartService = new CartService();
@Param({"100", "1000", "10000"})
private int CONCURRENCY;
@Benchmark
public void benchmark() throws InterruptedException, IOException {
List<Thread> threads = new ArrayList<>();
IntStream.range(0, CONCURRENCY).forEach(i ->
threads.add(Thread.startVirtualThread(() -> cartService.update(UUID.randomUUID().toString(), 2))));
threads.forEach(th -> {
try {
th.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}
在上面的測試中,我們使用並發控制的基準參數,並在初始預熱後迭代此方法 3 次。
現在我們將分別在 JDK 21 和 25 版本中執行上述基準測試。
首先,我們來看看 JDK 21 版本的效能報告:
Benchmark (CONCURRENCY) Mode Cnt Score Error Units
BenchmarkVirtualThread.benchmark 100 thrpt 6 2.081 ± 0.008 ops/s
BenchmarkVirtualThread.benchmark 1000 thrpt 6 0.214 ± 0.058 ops/s
BenchmarkVirtualThread.benchmark 10000 thrpt 6 0.023 ± 0.001 ops/s
BenchmarkVirtualThread.benchmark 100 avgt 6 0.479 ± 0.011 s/op
BenchmarkVirtualThread.benchmark 1000 avgt 6 4.468 ± 0.033 s/op
BenchmarkVirtualThread.benchmark 10000 avgt 6 44.056 ± 0.279 s/op
接下來,我們來確認一下 JDK 25 版本的報告:
Benchmark (CONCURRENCY) Mode Cnt Score Error Units
BenchmarkVirtualThread.benchmark 100 thrpt 6 18.392 ± 0.206 ops/s
BenchmarkVirtualThread.benchmark 1000 thrpt 6 10.061 ± 0.170 ops/s
BenchmarkVirtualThread.benchmark 10000 thrpt 6 1.005 ± 0.029 ops/s
BenchmarkVirtualThread.benchmark 100 avgt 6 0.058 ± 0.015 s/op
BenchmarkVirtualThread.benchmark 1000 avgt 6 0.108 ± 0.036 s/op
BenchmarkVirtualThread.benchmark 10000 avgt 6 0.962 ± 0.030 s/op
從上述數據可以看出,最新 JDK 版本的吞吐量和平均時間都顯著提高了。
6. 結論
在本文中,我們學習了以下內容:我們實現了對同步方法場景的修復;此外,我們也探討了在後續版本中如何解決一些綁定問題。
和往常一樣,範例程式碼可以在 GitHub 上找到。