Java 21 探讨虚拟线程锁在哪里?

时间:2024-10-09 20:58:29

介绍

Netflix 在广泛的微服务架构中一直将 Java 作为主要编程语言。随着我们使用更新版本的 Java,JVM 生态系统团队会寻找可以改善我们系统的人机工程学和性能的新语言特性。在最近的一篇文章中,我们详细描述了当我们迁移到 Java 21 并将代际 ZGC 作为默认垃圾收集器时,我们的工作负载如何受益。虚拟线程是我们在此次迁移中兴奋采用的另一项功能。

对于虚拟线程的新手,它们被描述为“轻量级线程,大大减少了编写、维护和观察高吞吐量并发应用程序的工作量。”它们的强大之处在于,当发生阻塞操作时,它们能够通过继续执行自动挂起和恢复,从而释放底层操作系统线程以供其他操作使用。在适当的上下文中利用虚拟线程可以解锁更高的性能。

在本文中,我们讨论了在部署 Java 21 上的虚拟线程过程中遇到的一个奇特案例。

问题

Netflix 工程师向性能工程和 JVM 生态系统团队提出了几份独立的报告,报告称出现了间歇性超时和挂起的实例。在仔细检查后,我们注意到了一些共同的特征和症状。在所有情况下,受影响的应用程序都运行在 Java 21、SpringBoot 3 上,并嵌入了 Tomcat 在 REST 端点上服务流量。经历问题的实例虽然 JVM 仍在运行,但却停止了流量服务。这个问题的一个明显症状是 closeWait 状态下的套接字数量持续增加,如下图所示:

收集的诊断信息

处于 closeWait 状态的套接字表明远程对等端关闭了套接字,但本地实例从未关闭,可能是因为应用程序未能这样做。这通常表明应用程序处于异常挂起状态,在这种情况下,应用程序线程转储可能会揭示更多信息。

为了排除这个问题,我们首先利用我们的警报系统来捕捉处于这种状态的实例。由于我们定期收集并持久化所有 JVM 工作负载的线程转储,我们通常可以通过检查这些实例的线程转储来追溯行为。然而,我们惊讶地发现我们所有的线程转储都显示 JVM 完全闲置,没有明显的活动。回顾最近的更改,发现这些受影响的服务启用了虚拟线程,而我们知道虚拟线程调用栈不会出现在 jstack 生成的线程转储中。为了获得包含虚拟线程状态的更完整的线程转储,我们使用了 “jcmd Thread.dump_to_file” 命令。作为检查 JVM 状态的最后一搏,我们还从实例中收集了堆转储。

分析

线程转储显示数千个“空白”虚拟线程:

arduino复制代码#119821 "" virtual
​
#119820 "" virtual
​
#119823 "" virtual
​
#120847 "" virtual
​
#119822 "" virtual
...

这些是为其创建了线程对象但尚未开始运行的 VTs(虚拟线程),因此没有堆栈跟踪。实际上,空白 VTs 的数量与 closeWait 状态下的套接字数量大致相同。要理解我们所看到的内容,我们首先需要了解 VTs 如何运行。

虚拟线程不是与专用的 OS 级线程一对一映射的。相反,我们可以将其视为调度到 fork-join 线程池的任务。当虚拟线程进入阻塞调用(例如等待 Future)时,它会放弃占用的 OS 线程,并在内存中保留,直到准备好恢复。在此期间,OS 线程可以重新分配以执行相同 fork-join 池中的其他 VTs。这使我们能够将大量 VTs 复用到少数几个底层 OS 线程中。在 JVM 术语中,底层 OS 线程被称为“承载线程”,虚拟线程在执行时可以“挂载”到其上,在等待时可以“卸载”下来。关于虚拟线程的详细描述可参见 JEP 444

在我们的环境中,我们使用了 Tomcat 的阻塞模型,这实际上在请求的生命周期内持有一个工作线程。通过启用虚拟线程,Tomcat 切换到虚拟执行。每个传入请求创建一个新的虚拟线程,该线程只是作为任务调度到虚拟线程执行器上。我们可以看到 Tomcat 在此处 创建了一个 VirtualThreadExecutor

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

为什么 Tomcat 会卡住?

我们的 OS 线程发生了什么?它们在忙什么?如此处所述,如果虚拟线程在 synchronized 块或方法内执行阻塞操作,它将固定到底层 OS 线程。这正是这里发生的事情。以下是从卡住实例获取的线程转储中的相关片段:

#119515 "" virtual
      /(Native Method)
      /(:661)
      /(:593)
      /$(:2643)
      /(:54)
      /(:219)
      /(:754)
      /(:990)
      /$(:153)
      /(:322)
      (:54)
      $(:230)
      (:214)
      $(:98)
      (:48)
      (:116)
      (:134)
      (:129)
      (:117)
      (:67)
      (:98)
      (:73)
      (:59)
      /(:103)
      /(:580)
      (:637)
...

谁拥有锁?

既然我们知道VTs(虚拟线程)正在等待获取锁,那么下一个问题是:谁持有锁?回答这个问题是理解最初触发此条件的关键。通常,线程转储会用“- locked <0x…> (at …)”或“Locked ownable synchronizers”来指示谁持有锁,但在我们的线程转储中,这些信息都没有出现。事实上,jcmd生成的线程转储中没有包含任何锁定/停止/等待的信息。这是Java 21中的一个限制,将在未来的版本中解决。仔细梳理线程转储,发现总共有6个线程争夺同一个ReentrantLock和关联的Condition。这6个线程中,有4个在上一节中详细说明。这里是另一个线程:

php复制代码#119516 "" virtual
      /(:582)
      /$(:2643)
      /(:54)
      /(:219)
      /(:754)
      /(:990)
      /$(:153)
      /(:322)
      (:54)
      $(:230)
      (:214)
      $(:98)
      (:48)
      (:116)
      (:64)
      ...

注意,虽然这个线程似乎通过相同的代码路径完成了一个span,但它没有经过一个synchronized块。最后,这里是第6个线程:

php复制代码#107 "AsyncReporter <redacted>"
      /(Native Method)
      /(:221)
      /(:754)
      /$(:1761)
      (:81)
      $(:241)
      $(:352)
      /(:1583)

实际上,这是一个普通的平台线程,不是虚拟线程。特别注意这个堆栈跟踪中的行号,奇怪的是,线程似乎在完成等待之后被阻塞在内部的acquire()方法中。换句话说,调用线程在进入awaitNanos()时拥有锁。我们知道锁在这里被明确获取。然而,在等待完成时,它无法重新获取锁。

有5个虚拟线程和1个常规线程在等待锁。这5个虚拟线程中,有4个被固定到fork-join池中的操作系统线程。仍然没有关于谁拥有锁的信息。由于线程转储中没有更多信息可供我们挖掘,下一步我们将查看堆转储并内省锁的状态。

检查锁

在堆转储中找到锁相对简单。使用优秀的Eclipse MAT工具,我们检查了AsyncReporter非虚拟线程的堆栈对象以识别锁对象。推理锁的当前状态可能是我们调查中最棘手的部分。大多数相关代码可以在中找到。虽然我们不敢说完全理解它的内部工作原理,但我们逆向工程出足够的信息与我们在堆转储中看到的相匹配。这张图展示了我们的发现:

首先,exclusiveOwnerThread字段为null(2),表示没有人拥有锁。我们在列表头部有一个“空”的ExclusiveNode(3)(waiternullstatus被清除),接下来是另一个ExclusiveNodewaiter指向争夺锁的虚拟线程之一——#119516(4)。我们发现唯一清除exclusiveOwnerThread字段的地方是在()方法中(源代码链接)。在这里,我们还将state设置为0,与我们在堆转储中看到的状态(1)匹配。

有了这些,我们追踪到release()锁的代码路径。在成功调用tryRelease()之后,持锁线程试图通知列表中的下一个等待者。在这一点上,持锁线程仍然在列表的头部,尽管锁的拥有权已实际上被释放。列表中的下一个节点指向即将获取锁的线程。

为了理解这种信号机制的工作原理,我们来看一下()方法中的锁获取路径。粗略简化一下,它是一个无限循环,线程尝试获取锁,如果尝试失败则进行停车:

scss复制代码while(true) {
   if (tryAcquire()) {
      return; // 锁已获取
   }
   park();
}

当持锁线程释放锁并通知以解锁下一个等待线程时,被解锁的线程再次遍历此循环,使其有机会重新获取锁。确实,我们的线程转储显示,所有的等待线程都在第754行停车。解锁的线程应进入此代码块,有效地重置列表头并清除对等待者的引用。

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

无处可运行的锁

知道线程 #119516 实际上被通知后,我们重新检查了线程转储中的线程状态。回顾一下,我们有 6 个线程在等待锁,其中 4 个虚拟线程各自固定在操作系统线程上。这 4 个线程在获取锁并退出 synchronized 块之前不会让出它们的操作系统线程。#107 “AsyncReporter <redacted>” 是一个普通平台线程,所以如果它获取锁,理论上不应有任何障碍阻止其继续运行。这让我们剩下最后一个线程:#119516。它是一个虚拟线程,但它没有固定在操作系统线程上。即使它被通知解锁,由于 fork-join 池中没有更多的操作系统线程可供调度,它也无法继续运行。这正是发生的情况——尽管 #119516 被信号解锁,它无法离开停车状态,因为 fork-join 池被 4 个等待获取同一个锁的虚拟线程占据。这些固定的虚拟线程在获取锁之前无法继续。这是经典死锁问题的变种,但不是两个锁,而是一个锁和 fork-join 池代表的 4 个许可证。

结论

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

此次探索仅展示了 Netflix 性能工程师解决的众多问题中的一种。我们希望通过这一对我们问题解决方法的简要介绍,能对他人在未来的调查中有所帮助。