Netflix 的 Java 21 虚拟线程死锁排查
Netflix 技术博客的一篇排障记录。起点是一批 Java 21 + Spring Boot 3 应用间歇性挂起,最后定位到虚拟线程在 synchronized 块里被 pin 在 carrier thread 上,把一次 ReentrantLock 的正常释放拖成了死锁。
问题#
Netflix 接到了 jvm 间歇性超时、实例被挂起的问题报告。这些报告的共同点是都运行着 Java 21 的 Spring Boot 3 应用,且都使用嵌入式的 Tomcat 来提供服务。这些 jvm 虽然仍然在运行,但却无法提供服务。
Netflix 内部应该还有 Java 17 的技术栈服务,所以可以将以上信息作为问题的公共特征抽取出来。
它们的 metrics 检测到 closewait 状态的 socket 数量持续增加。
排查#
收集信息#
closewait 状态出现的原因是连接的对端关闭了连接,但本地还没关闭,仍在等待。这通常意味着本地线程可能因为某种原因挂起了。所以他们先排查了 thread dump。
- Netflix 的服务有定期保存 thread dump 数据
他们在告警系统中找到一个这类被挂起的实例,并查看对应的 thread dump 数据。从这些数据,只能看出该 jvm 处于空闲状态,并没有特别的活动。
他们查看了这些服务的最近变更,发现它们都启用了 virtual threads 特性。而 virtual thread 的调用栈并不会出现在 jstack 生成的 thread dump 中。
Netflix 内部服务启用 virtual thread 特性应该也是分阶段的,应该还有许多服务未启用该特性。
为了获取包含 virtual thread 状态的完整 thread dump,他们使用了 jcmd Thread.dump_to_file 命令。
为了查看 jvm 内部的完整状态,他们对该实例也收集了 heap dump。
分析#
他们在 thread dump 信息中看到了成千上万的空虚拟线程。
#119821 "" virtual
#119820 "" virtual
#119823 "" virtual
#120847 "" virtual
#119822 "" virtual
...
这些虚拟线程被创建出来,但尚未开始执行,所以没有 stack trace 信息。且这些空虚拟线程的数量大致等于处于 closewait 状态的 socket 数量。
虚拟线程在遇到阻塞的时候,会让出占据的系统线程,并保留在内存中,等待恢复执行。JVM 会将空闲出来的系统线程分配给其他虚拟线程,从而充分利用 CPU 资源。所以多个虚拟线程可以复用同一个系统线程。
在 JVM 术语中,该系统线程被称为
carrier thread。虚拟线程在执行的时候,被它mounted,在等待的时候被unmounted。JEP 444JVM 会维护一个系统线程(carrier thread)池,通过一个 fork join pool 来创建和管理虚拟线程。默认情况下,这个线程池中的
carrier thread数量与 CPU 核心数量相同。
在 Netflix 中,他们原先使用 Tomcat 的 blocking model,也就是每个请求都会有一个专门的工作线程来处理,该工作线程直到返回 response 才会被释放。在启用虚拟线程之后,Tomcat 的 VirtualThreadExecutor 会对每个请求都创建一个虚拟线程。
根据前面的现象,可以推断 Tomcat 创建虚拟线程之后,并没有执行该虚拟线程。
为什么 Tomcat 没有执行这些虚拟线程#
在 Oracle 的虚拟线程说明中有这么一段
A virtual thread cannot be unmounted during blocking operations when it is pinned to its carrier. A virtual thread is pinned in the following situations:
The virtual thread runs code inside a synchronized block or method
The virtual thread runs a native method or a foreign function (see Foreign Function and > Memory API)
也就是在 synchronized 块中的时候,一个虚拟线程会被固定(pin)到系统线程上。
他们获取的 thread dump 信息如下:
#119515 "" virtual
java.base/jdk.internal.misc.Unsafe.park(Native Method)
java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
java.base/java.lang.VirtualThread.park(VirtualThread.java:593)
java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)
java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)
zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)
zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)
brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)
brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)
brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)
brave.RealSpan.finish(RealSpan.java:134)
brave.RealSpan.finish(RealSpan.java:129)
io.micrometer.tracing.brave.bridge.BraveSpan.end(BraveSpan.java:117)
io.micrometer.tracing.annotation.AbstractMethodInvocationProcessor.after(AbstractMethodInvocationProcessor.java:67)
io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.proceedUnderSynchronousSpan(ImperativeMethodInvocationProcessor.java:98)
io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.process(ImperativeMethodInvocationProcessor.java:73)
io.micrometer.tracing.annotation.SpanAspect.newSpanMethod(SpanAspect.java:59)
java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
java.base/java.lang.reflect.Method.invoke(Method.java:580)
org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637)
...
从上面的日志,我们可以看出该虚拟线程尝试获取锁 java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322),但是没有成功。之后虚拟线程就被阻塞了(park),等待获取锁。
他们的代码从 brave.RealSpan.finish(RealSpan.java:134) 开始进入 synchronization。
谁持有锁#
一般来说,thread dump 会通过 - locked <0x…> (at …) 或 Locked ownable synchronizers 来指示是谁持有锁,但他们并没有看到相关信息。不论 jstack 还是 jcmd 的 thread dump 都没有这些信息。这是 Java 21 的一个 limitation,会在未来被解决。
他们发现有 6 个线程在竞争同一个 ReentrantLock。他们有 4 个 vCPU,4 个线程的日志和上面一样。以下是剩下 2 个中的一个线程的日志
#119516 "" virtual
java.base/java.lang.VirtualThread.park(VirtualThread.java:582)
java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)
java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)
zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)
zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)
brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)
brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)
brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)
brave.RealScopedSpan.finish(RealScopedSpan.java:64)
...
虽然这个日志和其他 4 个线程很类似,但是没有 brave.RealSpan.finish(RealSpan.java:134)。
剩下的第6个线程的日志
#107 "AsyncReporter <redacted>"
java.base/jdk.internal.misc.Unsafe.park(Native Method)
java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1761)
zipkin2.reporter.internal.CountBoundedQueue.drainTo(CountBoundedQueue.java:81)
zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.flush(AsyncReporter.java:241)
zipkin2.reporter.internal.AsyncReporter$Flusher.run(AsyncReporter.java:352)
java.base/java.lang.Thread.run(Thread.java:1583)
这实际上是一个普通的平台线程,而不是虚拟线程。特别注意这个堆栈跟踪中的行号,线程似乎在完成 wait 之后卡在了内部的 acquire() 方法中。也就是说,该调用线程在进入 awaitNanos() 时持有了锁。然而,当等待结束时,它无法重新获取锁。
表格显示结果:
| Thread ID/name | Virtual? | “synchronized” block? | Pinned? | Waiting for the lock? |
|---|---|---|---|---|
| #119513 "" | Yes | Yes | Yes | Yes |
| #119514 "" | Yes | Yes | Yes | Yes |
| #119515 "" | Yes | Yes | Yes | Yes |
| #119517 “<redacted> @DefaultExecutor - 46542” | Yes | Yes | Yes | Yes |
| #119516 "" | Yes | No | No | Yes |
| #107 “AsyncReporter |
No | No | N/A | Yes |
有 5 个虚拟线程和 1 个普通线程正在等待获取锁。在这 5 个虚拟线程中,有 4 个被固定在系统线程上。我们仍然没有获取到谁持有锁的信息。由于 thread dump 中没有更多可供分析的内容,接下来的逻辑步骤是查看 heap dump 并检查锁的状态。
检查锁状态#
他们使用 Eclipse Mat 来分析 heap dump。他们检查了 AsyncReporter 这个非虚拟线程的堆栈对象,以定位锁对象。

exclusiveOwnerThread字段为null(2),表示没有线程持有锁。ExclusiveNode(3)为空(waiter为 null、status为 0),后面紧跟着另一个ExclusiveNode,它的waiter指向了虚拟线程#119516。- 他们发现此处唯一清理
exclusiveOwnerThread字段的地方在 ReentrantLock.Sync.tryRelease() 方法中。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (getExclusiveOwnerThread() != Thread.currentThread())
throw new IllegalMonitorStateException();
boolean free = (c == 0);
if (free)
setExclusiveOwnerThread(null);
setState(c);
return free;
}
在成功调用了 tryRelease 之后,该锁的持有线程尝试向下一个 waiter 发送信号。code
public final boolean release(int arg) {
if (tryRelease(arg)) {
signalNext(head);
return true;
}
return false;
}
此时锁的持有线程仍然是 the head of the list,即便该锁的 ownership 已经释放掉了。下一个 waiter 的线程即将尝试去获取该锁。
要理解这里信号发送的流程,需要了解 AbstractQueuedSynchronizer.acquire() 这个方法。简单来说,就是线程尝试获取锁,如果没有获取,就将自己 park。
while(true) {
if (tryAcquire()) {
return; // lock acquired
}
park();
}
在锁的持有线程释放锁,并发送信号来 unpark 下一个线程的时候,该 unpark 的线程执行这个 loop,尝试去获取锁。但是从 thread dump 信息来看,所有的虚拟线程都 park 在 754 行 处。一旦被唤醒,成功获取锁的线程应该进入此代码块,重置 head of the list 为自己,并清除对 waiter 的引用。
但是这里的问题是虚拟线程 #119516 仍然在等待,和其他的虚拟线程一样。这是为什么呢?
- 总共有 6 个线程在等待获取锁,其中 4 个虚拟线程分别固定在一个操作系统线程上。这 4 个线程在获取到锁并离开同步块之前不会释放它们的系统线程。
#107 “AsyncReporter <redacted>”是个普通的平台线程,因此在获取锁后,就可以正常执行。#119516是个虚拟线程,但是并没有固定在系统线程上,因为只有 4 个 vCPU,系统线程已经被上面 4 个虚拟线程占满了。所以该虚拟线程无法被调度。- 所以这里产生了一个死锁。普通线程想要释放锁,但是被唤醒的线程却无法获取锁,因为该线程无法被执行。
几点笔记(个人观点)#
- 这个坑对想上虚拟线程的项目来说不算冷门:只要调用链上有库在
synchronized块里拿锁(这次是 brave 的RealSpan.finish),就有机会踩到。新特性看着好用,坑也不少,重要项目我不会急着直接在线上开。 - 修复版本更正一下(截至我这次整理,2026-08):笔记里原先按 elasticsearch 的这个 issue 写的是「Java 23 修掉」,实际晚了一个版本:JEP 491: Synchronize Virtual Threads without Pinning 进的是 JDK 24,之后在
synchronized里阻塞不再把虚拟线程 pin 在 carrier thread 上。还停在 JDK 21~23 的服务,规避办法仍然是把热点路径上的synchronized换成ReentrantLock。