为了账号安全,请及时绑定邮箱和手机立即绑定

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

标签:
Java JVM
了解虚拟线程的实际情况

Vadim FilanovskyMike HuangDanny ThomasMartin Chalupa

介绍

Netflix 有着悠久的历史背景,使用 Java 作为我们主要编程语言之一,用于我们众多的微服务。随着我们采用更新版本的 Java,我们的 JVM 团队会寻找新的语言特性,以提升系统的易用性和性能表现。在一篇 最近的文章 中,我们详细介绍了在迁移过程中,切换到代际型 ZGC 如何使我们的工作负载受益。虚拟线程是我们在迁移过程中期待采纳的功能之一。

对于第一次接触虚拟线程的人,它们被描述 为“轻量级线程,可大幅减少编写、维护和观察高吞吐量并发应用程序所需的努力。”它们的强项在于能在遇到阻塞操作时自动挂起并恢复,这得益于延续机制,从而释放底层操作系统线程,让它们可以用于其他任务。在适当的情境下使用虚拟线程可以大大提升性能。

在这篇文章中,我们讨论了在将虚拟线程部署到 Java 21 的过程中遇到的一个特别的案例。

这个问题

奈飞的工程师向性能工程和JVM生态系统团队提交了几份独立的报告,报告了间歇性超时和实例挂起的问题。经过仔细检查,我们发现一系列共同的特征和症状。在所有情况下,受影响的应用都运行在Java 21、Spring Boot 3和嵌入式Tomcat上,处理指向REST端点的流量。出现问题的实例停止服务,但JVM仍然在运行。其中明显的症状是closeWait状态的套接字数量持续上升,如下图所示:

收集的诊断

处于 closeWait 状态的套接字表示远程对等体已关闭套接字连接,但本地一直没有关闭,这可能是应用程序没有执行关闭操作导致的。这种情况通常表明应用程序出了问题,在这种情况下,查看应用程序的线程转储可能会提供更多线索。

为了处理这个问题,我们首先采取了行动,借助了我们的警报系统来捕捉处于这种状态的实例。由于我们定期收集并保存所有JVM工作负载的线程转储,通常通过检查这些线程转储,可以追溯到实例的行为。然而,我们惊讶地发现所有的线程转储都显示了一个完全空闲的JVM,没有任何明显的活动。通过回顾最近的变更发现,受影响的服务启用了虚拟线程功能,我们了解到虚拟线程的调用栈不会出现在jstack生成的线程转储中。为了获得一个包含虚拟线程状态在内的更完整的线程转储,我们使用了“jcmd Thread.dump_to_file”命令。作为最后的努力,我们也从实例中收集了堆转储,以便更好地了解JVM的状态。

分析一下,

线程转储信息显示有数千个空闲的虚拟线程(virtual threads)。

    #119821 ""虚拟  

    #119820 ""虚拟  

    #119823 ""虚拟  

    #120847 ""虚拟  

    #119822 ""虚拟  
    ...

这些是创建了线程对象的虚拟线程(VT),但尚未开始运行,因此没有堆栈跟踪。实际上,空白VT的数量大约和处于关闭等待状态的套接字数量一样多。为了理解我们看到的情况,我们首先需要了解VT的工作原理。

虚拟线程并不是一对一映射到操作系统级别的线程。我们可以将其视为一个任务,这个任务被调度到一个分叉-合并线程池中。当虚拟线程遇到阻塞调用,例如等待一个 Future 时,它会释放它占用的操作系统线程,并简单地留在内存中,直到它准备恢复。在此期间,操作系统线程可以用来执行同一分叉-合并池中的其他虚拟线程。这样,我们可以将大量的虚拟线程共享到少量的操作系统线程上。在JVM中,底层的操作系统线程被称为“载体线程”,虚拟线程在执行时可以绑定到一个操作系统线程上,当它等待时则解除绑定。关于虚拟线程的深入描述可以在JEP 444中找到。

在我们的环境中,我们使用了 Tomcat 的阻塞模型,该模型实际上会在请求的整个过程中保留一个工作线程。通过启用虚拟线程,Tomcat 切换到虚拟执行模式。每个传入请求都会创建一个新的虚拟线程,每个虚拟线程会被调度到 Virtual Thread Executor 上执行任务。我们可以在这里看到 Tomcat 创建了一个 VirtualThreadExecutor这里

将这些信息与我们的问题联系起来,这种症状表明,每当有新的请求时,Tomcat 会创建一个新的 web 工作者线程 VT,但没有可用的操作系统线程可以挂载这些新线程。

瑞典为什么卡住了?

为什么Tomcat卡住了?

我们的操作系统线程出了什么状况?它们现在都在忙些什么?如此处所述,如果在synchronized块或方法内部执行阻塞操作,(VT)将被绑定到底层的操作系统线程。这正是这里发生的情形。以下是从卡顿实例中获取的线程转储的相关部分:

    #119515 "" 虚拟  
          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](https://github.com/openzipkin/brave/blob/6.0.3/brave/src/main/java/brave/RealSpan.java#L134))中进入同步代码块。这个虚拟线程实际上被固定住了——即使在等待获取可重入锁时,它也一直绑定到一个实际的操作系统线程。在这个确切状态下有3个虚拟线程,还有一个虚拟线程遵循相同的代码路径。这4个虚拟线程在等待获取锁时被固定住了。由于应用程序部署在拥有四个虚拟CPU的实例上,支持虚拟线程执行的fork-join线程池也包含4个操作系统线程。现在我们已经用完了这些线程,没有其他虚拟线程可以取得进展。这就解释了为什么Tomcat停止处理请求,为什么处于closeWait状态的套接字数量持续增加。确实,Tomcat在一个套接字上接受连接,创建一个请求以及一个虚拟线程,然后将这个请求/线程传递给执行器进行处理。然而,新创建的虚拟线程无法被调度,因为fork-join池中的所有操作系统线程都被固定住了,无法释放。因此,这些新创建的虚拟线程被卡在队列中,仍然占用着套接字。

谁有这把锁?

既然我们知道VTs正在等待获取锁,接下来的问题是:谁持有这个锁?回答这个问题是理解这一状况最初是如何被触发的关键。通常,线程转储会用“- locked <0x…> (at …)”或“Locked ownable synchronizers”来显示谁持有锁,但我们的线程转储中都没有出现这些信息。实际上,jcmd生成的线程转储中没有包含任何锁定、挂起或等待的信息,这是Java 21中的一个限制,在未来的版本中将会解决这个问题。仔细检查线程转储后发现,总共有6个线程正在争夺同一个ReentrantLock及其相关的Condition。在这六个线程中,有四个在前一节已经详细说明过。下面是另一个线程的信息:

    #119516 "" 虚拟  
          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)  
          ...

需要注意的是,尽管这个线程看起来走过了完成一个跨度的相同代码路径,但它并没有经过一个同步代码块。最后,这是第6个线程:

#107 "异步报告器 <redacted>"
java.base/jdk.internal.misc.Unsafe.park(本地方法)
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() 方法中被阻塞。换句话说,调用线程在进入 awaitNanos() 之前已经持有锁。我们知道锁是在这里明确获取的。然而,等待完成后,它却无法重新获取锁。总的来说,这就是我们对线程转储分析的结果。

这里有5个虚拟线程(VT)和1个常规线程在等待锁。在这5个虚拟线程中,有4个被绑定到了fork-join线程池中的操作系统线程上。目前我们还不知道谁持有这个锁。由于线程转储中没有提供更多有用的信息,我们下一步的逻辑步骤是查看堆转储并检查锁的状态。

查看锁

在堆转储中找到锁还算容易。我们使用了优秀的Eclipse MAT工具,检查了AsyncReporter非虚拟线程栈上的对象,以识别出锁对象。推断锁的当前状态可能是我们调查中最难的部分。大多数相关代码可以在AbstractQueuedSynchronizer.java中找到。虽然我们不能说完全理解它的内部机制,但我们也逆向工程了一些部分,以便与我们在堆转储中看到的情况相匹配。这张图展示了我们的发现:

首先,exclusiveOwnerThread 字段为 null(2),表示当前没有线程拥有该锁。我们有一个“空”的 ExclusiveNode 节点位于列表头部(waiternullstatus 已清除),接着是一个 ExclusiveNode,其中 waiter 指向一个正在争夺锁的虚拟线程 #119516(4)。唯一我们发现 exclusiveOwnerThread 字段被清除的地方是在 ReentrantLock.Sync.tryRelease() 方法中(源码链接)。在那里我们还将其 state 设置为 0,这与我们从堆转储中看到的状态相符合(1)。

考虑到这一点,我们追踪了调用 release() 释放锁的 代码路径。 成功调用 tryRelease() 后,持有锁的线程会尝试 唤醒列表中的下一个等待者。 在这个时刻,尽管锁的所有权 实际上已经释放,持有锁的线程仍然在列表的最前面。 列表中的 下一个 节点指向即将拿到锁的线程。

要理解这种信号是如何工作的,让我们来看一下 AbstractQueuedSynchronizer.acquire() 方法中的 锁获取路径。简单来说,它实际上是一个无限循环,线程在这个循环中会不断尝试获取锁,如果失败了就会挂起。更多细节可以在以下链接找到:

    while(true) {  
       if (tryAcquire()) {  
          return; // 退出因获得锁  
       }  
       park(); // 阻塞  
    }

当持有锁的线程释放锁然后通知唤醒下一个等待线程时,唤醒的线程会再次进入这个循环,从而有机会再次尝试获取锁。确实,我们的线程转储显示,所有等待线程都在line 754处挂起。一旦被唤醒后,成功获取锁的线程将会进入this代码段,从而重置链表头部并清除等待线程的引用。

为了更简洁地重述这一点,拥有锁的线程是由列表的头节点引用的。释放锁会唤醒列表中的下一个节点,而获取锁时,列表的头会被重置为当前节点。这意味着,我们在堆转储中看到的情况是,一个线程已经释放了锁,但下一个线程还没有来得及获取锁。这种情况应当是短暂的过渡状态,但我们的JVM却卡在这里。我们知道线程#119516已经被通知并即将获取锁,因为我们在列表的头部识别到了ExclusiveNode状态。然而,线程转储显示线程#119516仍在等待,就像其他竞争同一锁的线程一样。我们如何解释这种在线程转储和堆转储中看到的矛盾?

一个无处可逃的锁

我们知道线程#119516实际上已经被通知了,我们回到线程转储文件中重新检查线程的状态。回想一下,总共有6个线程正在等待锁,其中4个虚拟线程各自绑定到了一个操作系统线程。这4个线程会一直占用操作系统线程,直到它们获取到锁并退出synchronized块。#107 “AsyncReporter <redacted>”是一个普通平台线程,所以如果它获取到锁,不应该有任何东西阻止它继续执行。这就把我们引到了最后一个线程:#119516。它是一个虚拟线程,但没有与操作系统线程绑定。即使它被通知唤醒,由于分叉-合并池已经被其他4个等待获取相同锁的虚拟线程占用,它也无法从等待状态中退出。这里发生的情况正是如此——尽管#119516被通知唤醒,但它仍然无法从等待状态中退出,因为分叉-合并池已经被其他4个虚拟线程占用。这些绑定的虚拟线程无法继续执行,除非它们获取到锁。这实际上是一个类似于经典死锁问题的变种,但与两个锁不同,这里只有一个锁和一个由分叉-合并池表示的具有4个许可的信号量。

现在我们知道确切发生了什么情况,很容易就能提出一个可重复的测试用例

结论

虚拟线程有望通过减少与线程创建和上下文切换相关的开销来提高性能。虽然 Java 21 中还有一些不成熟的地方,虚拟线程基本上实现了它们的承诺。在我们追求更高效的 Java 应用的过程中,我们认为进一步采用虚拟线程是实现这一目标的关键。我们期待 Java 23 及以后的版本,这些版本将带来大量改进和更新,希望这些问题能在后续版本中得到解决,包括虚拟线程与锁定原语之间的集成。

这里只是展示了奈飞性能工程师解决的问题之一。我们希望这种方法的简短展示对未来其他人的工作有所帮助。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消