Vadim Filanovsky 2024-07-29
引言
Netflix 在我们庞大的微服务集群中长期将 Java 作为主要编程语言。随着我们不断升级到更新版本的 Java,JVM 生态系统团队始终在寻找能够提升系统开发体验和性能的新语言特性。在之前的一篇文章中,我们详细介绍了在迁移到 Java 21 时,通过切换为分代 ZGC(Generational ZGC)作为默认垃圾回收器,我们的工作负载获得了显著收益。虚拟线程(Virtual Threads)是我们在此次迁移中同样期待采用的另一项重要特性。
对于刚接触虚拟线程的读者来说,它们被描述为“轻量级线程,能大幅降低编写、维护和观测高吞吐并发应用程序的难度”。其强大之处在于:当发生阻塞操作时,虚拟线程可通过延续(continuations)机制自动挂起和恢复,从而释放底层操作系统线程,供其他任务复用。在合适的场景下使用虚拟线程,可以显著提升系统性能。
本文将讨论我们在部署 Java 21 虚拟线程过程中遇到的一个特殊问题。
问题现象
Netflix 工程师向性能工程与 JVM 生态系统团队报告了多起间歇性超时和实例挂起的问题。经过深入分析,我们发现这些案例具有一些共同特征和症状:
- 所有受影响的应用均运行在 Java 21 + Spring Boot 3 环境下;
- 使用内嵌的 Tomcat 提供 REST 接口服务;
- 出现问题的实例虽然 JVM 仍在运行,但完全停止处理请求。
一个清晰的症状是:CLOSE_WAIT 状态的套接字数量持续上升,如下图所示:
(此处原图略)
CLOSE_WAIT 状态表明远程对端已关闭连接,但本地应用未关闭对应的 socket。这通常意味着应用处于某种异常挂起状态。此时,应用线程转储(thread dump)可能提供进一步线索。
诊断收集
为了排查此问题,我们首先利用告警系统捕获处于该异常状态的实例。由于我们定期为所有 JVM 工作负载收集并持久化线程转储,通常可以通过回溯这些转储来还原行为。然而,令我们惊讶的是:所有 jstack 生成的线程转储都显示 JVM 完全空闲,没有任何活动迹象。
回顾近期变更后,我们注意到这些受影响的服务启用了虚拟线程。而我们知道:虚拟线程的调用栈不会出现在 jstack 生成的线程转储中。为了获得包含虚拟线程状态的完整线程转储,我们改用 jcmd Thread.dump_to_file 命令。此外,作为最后手段,我们还从该实例收集了堆转储(heap dump)。
分析过程
线程转储揭示了成千上万个“空白”的虚拟线程:
#119821 "" virtual
#119820 "" virtual
#119823 "" virtual
#120847 "" virtual
#119822 "" virtual
...
这些是已创建 Thread 对象但尚未开始运行的虚拟线程(VT),因此没有栈跟踪。事实上,这类“空白” VT 的数量与 CLOSE_WAIT 套接字数量大致相当。
要理解这一现象,需先了解虚拟线程的工作机制:
虚拟线程并非一对一映射到 OS 线程,而是作为任务被调度到一个 Fork-Join 线程池中。当虚拟线程执行阻塞操作(如等待 Future)时,它会释放所占用的 OS 线程(称为“载体线程”,carrier thread),仅保留在内存中,直到可恢复执行。此时,OS 线程可被重新分配给其他虚拟线程。这种机制使我们能将大量虚拟线程复用到少量 OS 线程上。JEP 444 对此有详细说明。
在我们的环境中,Tomcat 默认使用阻塞模型,即每个请求独占一个工作线程直至完成。启用虚拟线程后,Tomcat 切换为虚拟线程执行模式:每个请求创建一个新的虚拟线程,并将其作为任务提交给 VirtualThreadExecutor(源码位置)。
结合上述信息,问题症状可解释为:Tomcat 不断为每个新请求创建虚拟线程,但没有可用的 OS 线程来“挂载”(mount)它们。
Tomcat 为何卡住?
那么,我们的 OS 线程去哪儿了?它们在忙什么?
根据 JEP 444 的说明,如果虚拟线程在 synchronized 块或方法内执行阻塞操作,它会被“钉住”(pinned)到当前 OS 线程,无法释放该线程供其他虚拟线程使用。
这正是我们遇到的情况。以下是来自卡住实例的线程转储片段:
#119515 "" virtual
java.base/jdk.internal.misc.Unsafe.park(Native Method)
java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)
...
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(...)
brave.RealSpan.finish(RealSpan.java:134)
io.micrometer.tracing.brave.bridge.BraveSpan.end(...)
io.micrometer.tracing.annotation.SpanAspect.newSpanMethod(...)
...
关键点在于:brave.RealSpan.finish() 方法内部存在同步代码块。该虚拟线程在尝试获取 ReentrantLock 时被阻塞,导致其被钉在 OS 线程上。
我们共发现 3 个处于此状态的虚拟线程,另有 1 个名为 <redacted> @DefaultExecutor - 46542 的虚拟线程也走相同路径。这 4 个虚拟线程全部被钉住。
由于应用部署在 4 vCPU 的实例上,底层 Fork-Join 池也仅有 4 个 OS 线程。现在它们全部被占用且无法释放,其他虚拟线程无法被调度执行。
这解释了:
- 为何 Tomcat 停止处理请求;
- 为何
CLOSE_WAIT套接字持续累积。
Tomcat 接受连接、创建请求和虚拟线程后,将其提交给执行器。但由于无可用 OS 线程,新 VT 无法运行,只能排队等待——但仍持有 socket 连接,导致连接无法正常关闭。
谁持有锁?
既然多个 VT 在等待同一把锁,下一个问题是:谁持有这把锁?
通常线程转储会通过 - locked <0x...> 或 “Locked ownable synchronizers” 标明锁持有者,但在我们的转储中没有任何相关信息。这是 Java 21 的一个限制,未来版本将改进。
仔细检查转储发现,共有 6 个线程在竞争同一把 ReentrantLock 及其关联的 Condition:
- 其中 4 个是前述被钉住的 VT;
- 第 5 个 VT(#119516)也尝试获取锁,但未进入
synchronized块; - 第 6 个是平台线程(非虚拟线程):
#107 "AsyncReporter <redacted>"
java.base/java.util.concurrent.locks.LockSupport.park(...)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(...)
zipkin2.reporter.internal.CountBoundedQueue.drainTo(...)
zipkin2.reporter.internal.AsyncReporter$Flusher.run(...)
这个平台线程在 awaitNanos() 中等待,按理说它曾持有锁,并在等待结束后尝试重新获取。但转储显示它仍处于 acquire() 方法中,未能成功重获锁。
总结:
- 5 个 VT + 1 个平台线程在等待锁;
- 4 个 VT 被钉住,占用全部 OS 线程;
- 仍无法确定当前锁的持有者。
于是我们转向堆转储,直接检查锁对象的状态。
锁状态检查
使用 Eclipse MAT 工具,我们通过 AsyncReporter 线程的栈帧定位到锁对象。分析 AbstractQueuedSynchronizer(AQS)的内部结构后,得出以下结论(参考下图):
(此处原图略)
关键发现:
exclusiveOwnerThread字段为 null → 当前无人持有锁;- 队列头部是一个“空”节点(
waiter = null); - 下一个节点的
waiter指向 VT #119516; - 锁的
state = 0,表明已被释放。
根据 AQS 源码,tryRelease() 成功后会:
- 清空
exclusiveOwnerThread; - 将
state设为 0; - 唤醒队列中的下一个等待者。
而获取锁的逻辑大致如下(简化):
while (true) {
if (tryAcquire()) return; // 获取成功
park(); // 否则挂起
}
当锁被释放并唤醒下一个线程后,该线程应再次尝试获取锁,并成为新的队列头。
但我们的 JVM 卡在了“锁已释放,但下一个线程无法获取”的中间状态。
无处可运行的锁
关键线索在于:VT #119516 已被唤醒,但无法继续执行。
为什么?
- 它是一个未被钉住的虚拟线程;
- 但它需要一个 OS 线程才能运行;
- 而全部 4 个 OS 线程已被那 4 个被钉住的 VT 占用;
- 那些被钉住的 VT 又在等待同一把锁……
于是形成死锁变体:
1 把锁 + 4 个 OS 线程(相当于 4 个许可的信号量) → 所有线程互相阻塞,无法推进。
尽管 #119516 被通知 unpark,但因无可用 OS 线程,它无法退出 park 状态,永远停留在“即将获取锁”的临界点。
至此,我们成功复现了该问题。
结论
虚拟线程有望通过减少线程创建和上下文切换开销来提升性能。尽管 Java 21 中仍存在一些“锋利的边缘”(sharp edges),但总体上它兑现了承诺。在追求更高性能 Java 应用的道路上,我们视虚拟线程的进一步采用为关键一步。
我们期待 Java 23 及以后版本带来更多改进,特别是虚拟线程与锁原语的更好集成。
本次探索仅展示了 Netflix 性能工程师所解决的问题类型之一。希望这一问题排查思路能为其他开发者未来的调查提供借鉴。