监控Java虚拟线程
监控 Java 虚拟线程
开发便利性 与 运行高效性
简介
在我之前的文章中,我们已经讨论了什么是虚拟线程(VTs),他们与物理线程(PTs)之间的区别,以及如何使用 Java 虚拟线程(JVTs)来创建反应式微服务(Helidon 4)。
简单来说,VTs(虚拟线程)引入了一种新的并发模型。与使用可能会被阻塞的PTs(物理线程)不同,我们仅使用少量的VTs,且几乎不会被阻塞。阻塞一个PT是昂贵的,可能会限制我们的扩展能力。另一方面,VTs即使遭遇阻塞,亦不会增加额外成本。
有了VTs(虚拟线程),我们可以以平铺直叙的方式直接编码,而不必使用复杂的响应式编程,与此同时仍然享受代码高效运行好处。换句话说,我们不再需要在“开发便利性 与 运行高效性”之间做出两难选择。
为了进一步探讨这个主题,我邀请您观看我在2023年EclipseCon上关于Java Loom的开发者视角的演讲。
虚拟线程监控的具体细节
有一个值得考虑的重要问题被VTs(虚拟线程)引入了,那就是监控:我们如何确保我们的应用程序以最佳方式使用虚拟线程?在涉及虚拟线程时,我们到底需要监控哪些具体内容?
为了回答这些问题,我们需要理解VTs(虚拟线程)在底层是如何工作的,特别是如何管理阻塞操作:
在JVM 内部一切都围绕着一个名为Continuation的类展开。Continuation类是Java的内部一个类,作为开发者,我们不必直接与Continuation交互。像是Java内部的NIO库和垃圾回收器一样被很好的封装。
Continuation类暴露了两个主要方法:
- run 方法用于启动(或重启)一个任务
- yield 方法用于暂停正在运行的任务。
在内部,虚拟线程(VT)将其任务执行委托给一个Continuation类的示例。
从本质上讲,虚拟线程(VT)是一种轻量级并发构造器,它没有自己的执行能力。当它运行时,被挂载到一个物理线程(PT)上运行。更确切地说,这个物理线程(PT)是专门提供给虚拟线程(VT)执行的ForkJoinPool。这些专门定制的物理线程(PT)又被成为载荷线程Carrier Threads(CTs)。
虚拟线程(VT),物理线程(PT),载荷线程(CT),这么多词汇。别迷糊,一切都会变得清晰。
当虚拟线程(VT)被阻塞,例如等待IO时,会调用Continuation类的yield方法,并且虚拟线程会被卸载。此时载荷线程(CT)不会被阻塞(这就是神奇之处!),载荷线程持有任务资源将会被返还,此时会执行其他虚拟线程(VT)任务。
当IO读写完毕,Continuation的run方法将会被调用(由JVM内部的读取轮询线程调度),并且该虚拟线程会被重新挂载到一个载荷线程(CT)上(并不一定仍然是之前的线程)重新继续运行。
为了启用这种挂载/卸载机制,我们需要保存和恢复 Java 堆栈,即任务的执行上下文。它被保存在 Java 堆中,所有对象都存储在那里。这意味着虚拟线程的使用会增加内存占用,并给垃圾收集器(GC)增加压力。作为 Java 开发人员,我们知道当 GC 开始进行full collections时,延迟会提升、可伸缩性会变差。对于虚拟线程来说更是如此!
显然,使用虚拟线程加强了对内存占用和垃圾收集器活动进行监控的必要性。
我们可以通过使用以下经典方法来监控虚拟线程的使用:
- Java 启动参数:-Xlog:gc 或 -XX:NativeMemoryTracking
- Java 飞行记录器(JFR)事件。
跟踪牵制线程(pinned threads)
所以,多亏了Continuation的魔力,载荷线程(CTs)永远不会被阻塞...真是这样吗?实际上,它们几乎不会阻塞。但在某些情况下,虚拟线程(VT)不能被卸载,从而阻止其关联的载荷线程(CT)。
当Java堆栈地址被非Java代码引用时会发生这种情况。当从堆中还原时,Java地址会被重新定位,使非Java引用不再有效。
为了防止这种错误发生,将载荷线程(CT)设置为“牵制”状态。(这意味着它永远不会被卸载。)
这种情况发生在以下情况下:
- 调用了同步(synchronized)块或方法
- 调用了本地代码:即来自 JVM 本身的内部代码,或使用 JNI 或外部调用代码的外部调用函数。
这是一个问题吗?这取决于…… 当牵制线程背后的处理过程缓慢且频繁调用时,这可能会限制性能,例如在 JDBC 驱动程序中。 另一方面,当处理速度快或调用不频繁时,这就不是问题了。
这意味着我们必须跟踪牵制线程,以识别那些可能影响性能的线程。
我们可以利用以下方式跟踪:
- -Djdk.tracePinnedThreads Java 启动选项:它能够确定代码中发生牵制线程的位置。
- JFR 事件 jdk.VirtualThreadPinned:它能够识别可能影响性能的牵制线程。此事件默认启用,阈值为 20 毫秒(可配置):
例如,这种配置可以设置跟踪牵制线程阈值为 5 毫秒:
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">5 ms</setting>
</event>
在这种情况下,只有持续时间超过 5 毫秒的牵制线程才会被监视。
我的框架如何使用虚拟线程?
没有使用虚拟线程(VTs)的唯一标准方式。每个框架都根据其架构和继续需求进行取舍适配。
正如我们在之前的文章中看到的那样,Helidon 4提出了一种激进的方案,即业务代码系统性且有条理地在虚拟线程(VTs)的上下文中运行。
这需要注意的是,Helidon 4 在内部利用物理线程(PTs)来满足其连接管理需求。业务代码是系统性且有条理地在虚拟线程(VTs)中运行。
由于其架构的原因,Quarkus 提供了一种不同的方案。按Quarkus的设计,其使用两种类型的专门化物理线程(PTs):
- IO 线程,执行 IO 和非阻塞反应式代码
- Worker 线程,执行阻塞代码。
作为一种可选项,可以通过使用特性的注解,在虚拟线程(VTs)中运行阻塞代码:
这是一种混合方案,允许开发者逐步迁移到虚拟线程(VTs)中。
那么,Helidon 的激进方法还是 Quarkus 的混合方法更好?这并没有绝对的答案。这取决于业务背景上下文:
- 激进方法的优点在于简单易行。然而,在处理CPU密集型任务或固定线程影响性能时,它并不是最佳选择。
- 混合方法允许逐步、可控地采用虚拟线程。然而,由于导致IO线程和虚拟线程工作线程之间的上下文切换,它并不理想。Quarkus 架构可能会进化出跟优秀的方案以更好地利用虚拟线程(VTs)。
以上两个示例表明,我们需要了解我们喜爱的框架如何使用虚拟线程(VTs),并监控虚拟线程(VTs)的创建和删除方式
。
这可以通过启用和跟踪 JFR 事件来完成:jdk.VirtualThreadStart
和 jdk.VirtualThreadEnd
。这些事件默认是禁用的,要启用它们,必须使用特定的 JFR 配置:
<event name="jdk.VirtualThreadStart">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
</event>
<event name="jdk.VirtualThreadEnd">
<setting name="enabled">true</setting>
</event>
并且引入对应配置:
java -XX:StartFlightRecording,settings=/path/to/jfr-config ...
监控 ForkJoinPool
正如我们所知,ForkJoinPool 扮演了一个至关重要的角色在 Java 虚拟线程(VTs)中。如果池子太小,调度将会减慢并且降低性能。
可以使用以下系统属性配置 ForkJoinPool:
jdk.virtualThreadScheduler.parallelism
:池大小(有多少个载荷线程(CT)),默认为 CPU 核心数jdk.virtualThreadScheduler.maxPoolSize
:池的最大大小,默认为 256。当载荷线程(CT)被阻塞时(由于操作系统或 JVM 的限制),载荷线程(CT)的数量可能会暂时超过并行值设置的数量。请注意,调度程序不会通过扩大并行性来补偿牵制jdk.virtualThreadScheduler.minRunnable
:在池中保持可运行的最小线程数。
在大多数情况下,默认值是合适的,不需要更改它们。
在这个阶段,我还没有找到监测专用于虚拟线程(VTs)的 ForkJoinPool 的方法。例如,拥有确定虚拟线程(VTs)调度延迟的指标(虚拟线程(VTs)装载到载荷线程(CT)上需要多长时间)并相应地调整配置会很有趣。
结论
在本文中,我们确定了使用虚拟线程(VTs)时需要监控的主要技术要素:
- 内存堆大小和 GC 活动:这些是需要用虚拟线程(VTs)加强的经典监控元素
- 牵制线程,尤其是那些可能影响性能的线程
- 虚拟线程(VTs)的创建:每个框架都有自己的虚拟线程(VTs)使用策略,需要加以理解
- ForkJoinPool 调度程序大小调整:不适当的大小调整可能导致性能下降,即使(据我所知)没有真正的监控方法,我们也可以对配置进行一些处理总的来说,以下是我推荐的 Java 选项:
java \
-Djdk.tracePinnedThreads=short \
-XX:NativeMemoryTracking=summary \
-Xlog:gc:/path/to/gc-log \
-XX:StartFlightRecording,settings=...,name=...,filename=... \
-jar /path/to/app-jar-file
我们将在下一篇文章中研究使用 Helidon 和 Quarkus 的具体方法。
参考
Networking I/O with Virtual Threads – Under the hood
The Ultimate Guide to Java Virtual Threads
浮生潦草闲愁广,一听啤酒一口尽