Java 21 虚拟线程 - 兄嘚, 我的锁呢?

深入探讨虚拟线程

简介

我们是一家以 Java 作为主要编程语言的微服务架构公司, 拥有悠久的 Java 使用历史. 随着我们采用 Java 的新版本, 我们的 JVM 生态系统团队一直在寻找能够提升系统易用性和性能的新语言特性. 在一篇近期文章中, 我们详细阐述了在迁移至 Java 21 时, 将默认垃圾回收器切换为代际 ZGC 如何为我们的工作负载带来益处. 虚拟线程是此次迁移中我们期待采用的另一项功能.

对于不熟悉虚拟线程的读者, 它们被描述为"轻量级线程, 能够大幅减少编写, 维护和监控高吞吐量并发应用程序的代价". 其强大之处在于, 当遇到阻塞操作时, 虚拟线程可通过延续机制自动暂停和恢复, 从而释放底层操作系统线程以供其他操作复用. 在适当的场景下使用虚拟线程可解锁更高的性能.

本文将探讨我们在 Java 21 上部署虚拟线程过程中遇到的一个特殊案例.

问题

工程师向性能工程和JVM生态系统团队提交了多份独立报告, 反映间歇性超时和应用程序卡死的问题. 经深入分析, 我们发现这些问题存在一系列共同特征和症状. 所有受影响的应用程序均运行在Java 21环境下, 使用SpringBoot 3并通过嵌入式Tomcat处理REST接口的请求. 出现问题的实例会突然停止处理请求, 尽管这些实例的JVM仍处于运行状态. 该问题发作时的一个明显症状是 closeWait 状态下的套接字数量持续增加, 如下图所示:

收集的诊断信息

处于 closeWait 状态的套接字表明远程peer已关闭套接字, 但本地实例从未关闭该套接字, 这可能是因为应用程序未能执行关闭操作. 这通常表明应用程序处于异常状态, 此时应用程序线程转储可能提供额外线索.

为排查此问题, 我们首先利用我们的警报系统捕获处于此状态的实例. 由于我们定期收集并持久化所有JVM工作负载的线程转储, 通常可以通过分析这些线程转储来回溯性地还原行为. 然而, 我们惊讶地发现所有线程转储均显示JVM处于完全空闲状态, 且无明显活动迹象. 审查最近的更改发现, 受影响的服务启用了虚拟线程, 而我们知道虚拟线程的调用堆栈不会出现在jstack生成的线程转储中. 为了获得包含虚拟线程状态的更完整线程转储, 我们改用了jcmd Thread.dump_to_file命令. 作为最后的努力, 我们还从实例中收集了堆转储以检查JVM状态.

分析

线程转储显示了数千个"空白"虚拟线程:

arduino 复制代码
#119821 "" virtual

#119820 "" virtual

#119823 "" virtual

#120847 "" virtual

#119822 "" virtual
...

这些是已创建线程对象但尚未开始运行的虚拟线程(VT), 因此没有堆栈跟踪. 事实上, 空白VT的数量与处于closeWait状态的套接字数量大致相同. 为了理解我们所看到的现象, 我们首先需要了解VT的运作方式.

虚拟线程并非与操作系统级线程一一映射. 相反, 我们可以将其视为被调度到 fork-join 线程池中的任务. 当虚拟线程进入阻塞调用(如等待 Future)时, 它会释放所占用的操作系统线程, 并仅保存在内存中, 直至准备好继续执行. 在此期间, 操作系统线程可以被重新分配以执行同一 fork-join 池中的其他虚拟线程. 这使我们能够将大量虚拟线程复用到仅少数底层操作系统线程上. 在 JVM 术语中, 底层操作系统线程被称为"载体线程", 虚拟线程在执行时可"挂载"于其上, 而在等待时则"卸载". 关于虚拟线程的详细描述可参见JEP 444.

在我们的环境中, 我们采用阻塞模型来运行Tomcat, 这意味着每个请求的生命周期内都会占用一个工作线程. 通过启用虚拟线程, Tomcat将切换到虚拟执行模式. 每个传入的请求都会创建一个新的虚拟线程, 该线程仅作为任务调度到VirtualThreadExecutor上. 我们可以观察到Tomcat在此处创建了一个VirtualThreadExecutor.

将这些信息与我们的问题关联起来, 症状对应于 Tomcat 不断为每个传入请求创建新的 Web 工作线程 VT, 但没有可用的操作系统线程来挂载它们的状态.

为什么 Tomcat 会卡在这里?

我们的操作系统线程发生了什么, 它们在忙什么? 如[该描述](docs.oracle. com/en/java/javase/21/core/virtual-threads.html#GUID-04C03FFC-066D-4857-85B9-E5A27A875AF9)所言, 如果虚拟线程在synchronized块或方法中执行阻塞操作, 它将被绑定到底层操作系统线程. 这就是这里发生的情况. 以下是从卡住的实例中获取的线程转储的相关片段:

php 复制代码
#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)
...

在此堆栈跟踪中, 我们进入 brave.RealSpan.finish(RealSpan.java:134) 中的同步操作. 该虚拟线程实际上已被绑定------即使在等待获取可重入锁时, 它也已挂载到实际的操作系统线程上. 当前有 3 个虚拟线程处于此状态, 另有 1 个虚拟线程被标识为<redacted> @DefaultExecutor - 46542, 其代码路径与上述一致. 这 4 个虚拟线程在等待获取锁时处于固定状态. 由于应用部署在拥有 4 个 vCPU 的实例上, [支撑虚拟线程执行的 fork-join 池](https://github. com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/lang/VirtualThread.java#L1102-L1134) 也包含 4 个操作系统线程. 现在所有线程均已耗尽, 其他虚拟线程无法继续执行. 这解释了为什么Tomcat停止处理请求以及为什么closeWait状态下的套接字数量持续增加. 事实上, Tomcat在套接字上接受连接时, 会创建一个请求以及一个虚拟线程, 并将该请求/线程传递给执行器进行处理. 然而, 新创建的VT无法被调度, 因为fork-join池中的所有操作系统线程都被占用且从未释放. 因此, 这些新创建的虚拟线程被卡在队列中, 同时仍持有套接字.

谁持有锁?

既然我们知道虚拟线程正在等待获取锁, 下一个问题是: 谁持有锁? 回答这个问题是理解导致这种情况的根本原因的关键. 通常, 线程转储会通过- locked <0x...> (at ...)Locked ownable synchronizers指示谁持有锁, 但这两者均未出现在我们的线程转储中. 事实上, jcmd生成的线程转储中未包含任何锁定/停驻/等待信息. 这是 Java 21 的限制, 将在未来版本中得到解决. 仔细分析线程转储可发现, 共有 6 个线程在争夺同一个 ReentrantLock 及其关联的 Condition. 其中 4 个线程已在前一节中详细说明. 以下是另一个线程:

php 复制代码
#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)
      ...

请注意, 尽管该线程看似通过相同的代码路径完成一个跨度, 但并未经过 synchronized 块. 最后是第 6 个线程:

php 复制代码
#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)

这实际上是一个普通平台线程, 而非虚拟线程. 特别注意此堆栈跟踪中的行号, 该线程似乎在内部acquire()方法中被阻塞, 且是在[完成等待](https://github. com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L1761). 换言之, 调用该线程在进入awaitNanos()时已持有锁. 我们知道锁是在这里显式获取的. 然而, 等到等待完成时, 它无法重新获取锁. 总结我们的线程转储分析:

有 5 个虚拟线程和 1 个普通线程在等待锁. 在这 5 个虚拟线程中, 有 4 个与 fork-join 池中的操作系统线程绑定. 目前仍无法确定锁的所有者. 由于线程转储中没有更多可用的信息, 我们的下一步是查看堆转储并检查锁的状态.

检查锁

在堆转储中找到锁相对简单. 我们使用优秀的Eclipse MAT工具, 检查了AsyncReporter非虚拟线程栈中的对象, 以识别锁对象. 推断锁的当前状态可能是我们调查中最棘手的部分. 相关代码的大部分内容可在AbstractQueuedSynchronizer.java中找到. 虽然我们不声称完全理解其内部工作原理, 但我们通过逆向工程足够多的内容, 使其与堆转储中看到的内容相匹配. 以下图表说明了我们的发现:

首先, exclusiveOwnerThread字段为null(2), 表明没有人拥有锁. 列表头部有一个"空"的 ExclusiveNode(3)(waiternullstatus 已清空), 随后是另一个 ExclusiveNode, 其 waiter 指向争夺锁的虚拟线程之一------#119516(4). 我们发现唯一清空 exclusiveOwnerThread 字段的位置是在 ReentrantLock.Sync.tryRelease() 方法中([源代码链接](https://github. com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/ReentrantLock.java#L178)中. 在那里, 我们还设置了 state = 0, 与堆转储中看到的状态一致(1).

基于此, 我们追踪了 代码路径 以释放锁. 在成功调用 tryRelease() 后, 持有锁的线程尝试向列表中的下一个等待线程[发送信号](github.com/openjdk/jdk.... java#L641-L647) 中的下一个等待线程. 此时, 持有锁的线程仍位于列表的头部, 尽管锁的所有权已有效释放 . 列表中的下一个节点指向即将获取锁的线程.

要理解这种信号传递机制, 让我们查看 AbstractQueuedSynchronizer.acquire()方法中的[锁获取路径](https://github. com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L670-L765). 简而言之, 这是一个无限循环, 线程尝试获取锁, 如果尝试失败则进入等待状态:

scss 复制代码
while(true) {
   if (tryAcquire()) {
      return; // lock acquired
   }
   park();
}

当持有锁的线程释放锁并通知下一个等待线程解除等待时, 该线程会再次迭代此循环, 从而获得再次获取锁的机会. 事实上, 我们的线程转储表明, 所有等待线程都停在第754行. 一旦解除挂起状态, 成功获取锁的线程应进入[此代码块](github.com/openjdk/jdk.... java#L716-L723), 高效地重置了列表的头节点并清除对等待者的引用.

为了更简洁地重述这一点, 持有锁的线程由列表的头节点引用. 释放锁会通知列表中的下一个节点, 而获取锁会将列表的头节点重置为当前节点. 这意味着堆转储中显示的状态是: 一个线程已释放锁, 但下一个线程尚未获取锁. 这是一个奇怪的中间状态, 本应是暂时的, 但我们的 JVM 卡在这里. 我们知道线程 #119516 已被通知并即将获取锁, 因为我们在列表头部识别到了 ExclusiveNode 状态. 然而, 线程转储显示线程 #119516 继续等待, 就像其他竞争同一锁的线程一样. 我们如何解释线程转储与堆转储之间的差异?

没有运行空间的锁

得知线程 #119516 实际上已收到通知后, 我们返回线程转储重新检查线程状态. 需注意, 共有 6 个线程正在等待锁, 其中 4 个虚拟线程各自绑定到一个操作系统线程. 这4个线程在获得锁并退出synchronized块之前, 不会释放其操作系统线程. #107 "AsyncReporter <redacted>"是一个普通平台线程, 因此如果它获得锁, 就不会有任何阻碍其继续执行. 这留下了最后一个线程: #119516. 它是一个虚拟线程, 但未与操作系统线程绑定. 即使它被通知解除挂起状态, 也无法继续执行, 因为 fork-join 池中已无可用操作系统线程可用于调度它. 这就是此处发生的情况------尽管 #119516 被通知解除挂起状态, 但由于 fork-join 池已被其他 4 个等待获取同一锁的 VT 占用, 它无法脱离挂起状态. 这些被固定的 VT 无法继续执行, 直到它们获得锁定. 这是一种经典死锁问题的变体, 但这里不是两个锁, 而是一个锁和一个具有 4 个许可的信号量, 由 fork-join 池表示.

现在我们清楚地知道发生了什么, 很容易就能设计出一个可重复的测试用例.

总结一下

虚拟线程旨在通过减少与线程创建和上下文切换相关的开销来提升性能. 尽管在Java 21中仍存在一些尖锐的问题, 但虚拟线程总体上兑现了其承诺. 在追求更高效的Java应用程序的过程中, 我们认为进一步采用虚拟线程是实现这一目标的关键. 我们期待Java 23及以后的版本, 这些版本将带来大量升级, 并有望解决虚拟线程与锁定原语之间的集成问题.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy Coding! Stay GOLDEN!

相关推荐
倔强的小石头_2 小时前
【C语言指南】函数指针深度解析
java·c语言·算法
kangkang-5 小时前
PC端基于SpringBoot架构控制无人机(三):系统架构设计
java·架构·无人机
界面开发小八哥7 小时前
「Java EE开发指南」如何用MyEclipse创建一个WEB项目?(三)
java·ide·java-ee·myeclipse
idolyXyz8 小时前
[java: Cleaner]-一文述之
java
一碗谦谦粉8 小时前
Maven 依赖调解的两大原则
java·maven
netyeaxi8 小时前
Java:使用spring-boot + mybatis如何打印SQL日志?
java·spring·mybatis
收破烂的小熊猫~8 小时前
《Java修仙传:从凡胎到码帝》第四章:设计模式破万法
java·开发语言·设计模式
猴哥源码8 小时前
基于Java+SpringBoot的动物领养平台
java·spring boot
老任与码9 小时前
Spring AI Alibaba(1)——基本使用
java·人工智能·后端·springaialibaba
小兵张健9 小时前
武汉拿下 23k offer 经历
java·面试·ai编程