Java21虚拟线程的锁

哥们看看码农 2024-08-01 19:12:47
介绍

Netflix 在其庞大的微服务舰队中广泛使用 Java 作为主要编程语言。随着我们采用更新的 Java 版本,我们的 JVM 生态系统团队寻找新的语言特性,以提高我们系统的人体工程学和性能。在最近的一篇文章中,我们详细介绍了在迁移到 Java 21 时,将世代 ZGC 作为默认垃圾收集器如何使我们的工作负载受益。作为此次迁移的一部分,我们也很高兴采用虚拟线程这一特性。

对于那些不熟悉虚拟线程的人来说,它们被描述为“轻量级线程,可以显著减少编写、维护和观察高吞吐量并发应用程序的工作量”。它们的力量来自于在发生阻塞操作时能够通过延续自动挂起和恢复,从而释放底层操作系统线程以用于其他操作。在适当的上下文中利用虚拟线程可以解锁更高的性能。

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

问题

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

收集的诊断

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

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

分析

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

#119821 "" virtual#119820 "" virtual#119823 "" virtual#120847 "" virtual#119822 "" virtual

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

虚拟线程不是一对一地映射到专用的操作系统级线程。相反,我们可以将其视为一个任务,该任务被调度到一个 fork-join 线程池中。当虚拟线程进入阻塞调用(例如等待 Future)时,它会放弃它占用的操作系统线程,并简单地保持在内存中,直到准备好恢复。在此期间,操作系统线程可以重新分配以执行同一 fork-join 池中的其他 VT。这使我们能够将大量 VT 复用到少数几个底层操作系统线程上。在 JVM 术语中,底层操作系统线程被称为“载体线程”,虚拟线程在执行时可以“挂载”到载体线程上,在等待时可以“卸载”。JEP 444 中有对虚拟线程的深入描述。

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

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

Tomcat 为什么会卡住?

我们的操作系统线程发生了什么,它们在忙什么?如这里所述,如果 VT 在同步块或方法内执行阻塞操作,它将被固定到底层操作系统线程上。这正是这里发生的情况。以下是从卡住实例获得的线程转储中的相关片段:

#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 个 VT 处于这种确切状态,另一个被标识为“<redacted> @DefaultExecutor – 46542”的 VT 也遵循相同的代码路径。这 4 个虚拟线程在等待获取锁时被固定。因为应用程序部署在一个具有 4 个 vCPU 的实例上,所以支撑 VT 执行的 fork-join 池也包含 4 个操作系统线程。现在我们已经用尽了所有这些线程,其他虚拟线程无法取得任何进展。这解释了为什么 Tomcat 停止处理请求,以及为什么 closeWait 状态的套接字数量不断攀升。实际上,Tomcat 在一个套接字上接受连接,创建一个请求以及一个虚拟线程,并将这个请求/线程传递给执行器进行处理。然而,新创建的 VT 无法被调度,因为 fork-join 池中的所有操作系统线程都被固定且从未释放。因此,这些新创建的 VT 卡在队列中,同时仍然持有套接字。

谁持有锁?

现在我们知道 VT 在等待获取锁,下一个问题是:谁持有锁?回答这个问题是理解最初触发这种状态的关键。通常线程转储会用“- locked <0x…> (at …)”或“Locked ownable synchronizers”来指示谁持有锁,但这些在我们的线程转储中都没有显示。事实上,jcmd 生成的线程转储中不包含任何锁定/停放/等待信息。这是 Java 21 的一个限制,将在未来的版本中解决。仔细梳理线程转储发现,总共有 6 个线程在争夺同一个 ReentrantLock 和相关的 Condition。这 6 个线程中的 4 个在前一节中详细介绍过。以下是另一个线程:

#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) ...

请注意,虽然这个线程似乎通过相同的代码路径来完成一个 span,但它并没有经过同步块。最后是第 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)

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

有 5 个虚拟线程和一个正常线程在等待锁。在这 5 个 VT 中,其中 4 个被固定在 fork-join 池中的操作系统线程上。仍然没有关于谁拥有锁的信息。由于从线程转储中无法获得更多信息,我们的下一个合乎逻辑的步骤是查看堆转储并检查锁的状态。

检查锁

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

首先,exclusiveOwnerThread 字段为 null(2),表示没有人拥有锁。我们在列表头部有一个“空”的 ExclusiveNode(3)(waiter 为 null 且状态已清除),后面跟着另一个 ExclusiveNode,其 waiter 指向一个正在争夺锁的虚拟线程——#119516(4)。我们唯一发现清除 exclusiveOwnerThread 字段的地方是在 ReentrantLock.Sync.tryRelease() 方法中(源链接)。在那里,我们还设置了 state = 0,这与我们在堆转储中看到的状态相匹配(1)。

考虑到这一点,我们追踪了释放锁的代码路径。在成功调用 tryRelease() 后,持有锁的线程尝试通知列表中的下一个 waiter。此时,持有锁的线程仍在列表头部,尽管锁的所有权已有效释放。列表中的下一个节点指向即将获取锁的线程。

为了理解这个信号是如何工作的,让我们看看 AbstractQueuedSynchronizer.acquire() 方法中的锁获取路径。粗略地简化一下,这是一个无限循环,线程尝试获取锁,如果尝试不成功则停放:

while(true) { if (tryAcquire()) { return; // lock acquired } park();}

当持有锁的线程释放锁并通知下一个 waiter 线程解除停放时,解除停放的线程再次遍历这个循环,给它另一个获取锁的机会。确实,我们的线程转储显示所有 waiter 线程都在第 754 行停放。一旦解除停放,成功获取锁的线程应该进入这个代码块,有效地重置列表头部并清除对 waiter 的引用。

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

没有地方运行的锁

知道线程 #119516 实际上已被通知,我们回到线程转储重新检查线程的状态。回想一下,我们有 6 个线程在等待锁,其中 4 个虚拟线程各自固定在一个操作系统线程上。这 4 个线程在获取锁并离开同步块之前不会放弃它们的操作系统线程。#107 “AsyncReporter <redacted>” 是一个正常的平台线程,所以如果它获取锁,没有什么应该阻止它继续。这给我们留下了最后一个线程:#119516。它是一个 VT,但它没有固定在一个操作系统线程上。即使它被通知要解除停放,它也无法继续,因为没有剩余的操作系统线程在 fork-join 池中供它调度。这正是这里发生的情况——尽管 #119516 被信号通知要解除停放,但它无法离开停放状态,因为 fork-join 池被 4 个其他等待获取同一锁的 VT 占用。这些固定的 VT 在获取锁之前都无法继续。这是经典死锁问题的一个变种,但我们不是有两个锁,而是一个锁和一个有 4 个许可的信号量,由 fork-join 池表示。

现在我们知道发生了什么,很容易编写一个可复现的测试用例。

结论

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

这次探索突显了 Netflix 性能工程师解决的一种问题类型。我们希望这种窥探我们的问题解决方法对其他人在未来的调查中证明是有价值的。

来源:https://spring4all.com/forum-post/7307.html

1 阅读:1

哥们看看码农

简介:感谢大家的关注