什么是虚拟线程Pinning/固定?
在我们理解什么是线程固定之前,我们需要一些关于虚拟线程在Java中如何工作的上下文。直到最近,Java中只有一种线程——平台线程。对于Java中的每个平台线程,都有一个由操作系统管理的相应线程。这些制作成本相对较高,而且数量有限。由于这些限制,应用程序池或重用平台线程是很常见的。
输入虚拟线程。这些线程不是一对一地绑定到平台线程的。相反,它们是从我们称之为“载体线程”的线程“挂载”和“卸载”的,由JVM决定。例如,如果我们创建了一个虚拟线程并让它为我们做一些工作,它可能会被挂载到一个载体线程上,然后在JVM检测到虚拟线程正在等待某件事发生时卸载。当虚拟线程准备好再次工作时,JVM可以将其挂载到完全不同的载体线程。正是这种调度灵活性使我们能够更充分地使用平台/运营商线程。任何时候我们通常都会等待,我们不需要垄断载体线程。JVM可以在我们的虚拟线程等待时安排其他工作。
由于虚拟线程的创建成本低廉且不占用大量内存,我们可以创建任意数量的虚拟线程,并在它们完成任务后将其丢弃。我们不需要像使用平台线程那样将虚拟线程池化。
这很棒,虚拟线程将使我们能够从Java和JVM中获得更高的性能。然而,我们需要小心并注意虚拟线程固定。当虚拟线程被挂载到载体线程,但JVM无法卸载它时,就会发生这种情况。
当发生下列情况时,虚拟线程可能会被固定到其载体线程上:
- 它在同步块或方法中运行代码
- 它运行一个本机方法或外部函数
展示虚拟线程固定的应用程序并不坏或不正确,但可能不是最佳的。如果我们的代码用固定的虚拟线程垄断了一个载体线程,那么该载体线程就无法为可能正在等待的其他虚拟线程执行工作。在最坏的情况下,如果我们同时这样做足够多次,而你回到了起点,根本没有虚拟线程。
虚拟线程固定可能不是世界末日,但如果我们有能力的话,值得关注并可能预防。
通过日志记录检测固定线程
在开发和测试过程中,了解我们的代码是否固定了虚拟线程是有帮助的。有几种方法可以做到这一点。首先,我们可以在运行java程序时将-Djdk.tracePinnedThreads=full
添加到命令行中。这将导致JVM在检测到虚拟线程固定到标准输出时记录日志。
例如,当运行doSomethingSynchronized
方法的虚拟线程被固定时,会打印出以下内容。
Thread[#42,ForkJoinPool-1-worker-2,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:621)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:793)
java.base/java.lang.Thread.sleep(Thread.java:507)
com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(Main.java:15) <== monitors:1
java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
指定full会给我们提供完整的堆栈跟踪,这可能会使我们很难找到有用的行(我已经强调了这一点,日志消息本身也包含一个<==
指针)。如果你想要一个更简洁的版本,你可以指定-Djdk.tracePinnedThreads=short
来获取可能与你相关的部分。
Thread[#32,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(Main.java:15) <== monitors:1
在我的实验中,它似乎只记录了我们第一次在每个位置固定线程的时间。我怀疑这是出于设计,我没有运行足够长的代码来确定这是在每个JVM生命周期只记录一次,还是重置并最终记录多次。
通过JFR检测固定线程
如果登录到标准输出不是你的风格,JFR(Java飞行记录器)有一些新的事件来覆盖虚拟线程的生命周期。VirtualThreadPinned JFR事件可能适合您。
try (final RecordingStream eventStream = new RecordingStream()) {
eventStream.enable("jdk.VirtualThreadPinned").withStackTrace();
eventStream.onEvent("jdk.VirtualThreadPinned", System.out::println);
eventStream.start();
}
随着RecordingStream的启动(可能是在另一个平台线程中?),每次将虚拟线程固定到载体线程时,我们都会获得更丰富的事件信息。
jdk.VirtualThreadPinned {
startTime = 08:15:07.505 (2024-01-09)
duration = 999 ms
eventThread = "synch-1" (javaThreadId = 41, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 677
java.lang.VirtualThread.parkNanos(long) line: 636
java.lang.VirtualThread.sleepNanos(long) line: 793
java.lang.Thread.sleep(long) line: 507
com.ginsberg.example.pinning.Main.lambda$doSomethingSynchronized$0(int) line: 15
...
]
}
正如你所看到的,jdk.VirtualThreadPinned
事件将告诉您pin的持续时间(在我们的例子中为999毫秒),它是在哪个eventThread上执行的(“synch-1”)
,以及显示调用位置的stackTrace(如果启用)。在这个例子中,我只是按原样打印了事件,但你可以将其写入一个结构化日志,你的监控系统可以接收到它,将其交给你的遥测代码,通过JMX公开它,或者任何你想要的东西。JFR流式传输方法具有很大的灵活性。
请注意,有很多不同的方法可以使用JFR来收集这些信息,我刚刚说明了其中一种。
如何避免虚拟线程固定
一旦我们在代码中发现了一些虚拟线程固定,我们该怎么办?如上所述,将虚拟线程固定到载体线程的一种方法是使用同步方法或块。例如,doSomethingSynchronized
方法有一个同步块,如果在虚拟线程中运行,该块可能会被固定。
public class Main {
private Object lock = new Object();
// NO: Synchronized ay pin virtual thread to a carrier thread
public void doSomethingSynchronized() {
synchronized(lock) {
someLongRunningWork();
}
}
}
为了解决这个问题,我们可以用ReentrantLock
替换同步块。
public class Main {
private Lock lock = new ReentrantLock();
// Yes: Using ReentrantLock instead of synchronized
public void doSomethingLocked() {
try {
lock.lock();
someLongRunningWork();
} finally {
lock.unlock();
}
}
}
从本质上讲,我们正在做与同步时相同的事情——获取锁,做一些工作,释放锁。与此方法的最大区别在于,我们这里没有同步块(ReentrantLock的实现也没有),因此我们不会将此线程固定到载体线程。
另一种发生固定的方式(通过本机方法调用或外部函数调用)并不容易解决。在这些情况下,很难做出改变,所以你必须逐案处理。要么学会接受一些虚拟线程固定(同样,我们的代码没有损坏,只是不是最优的),要么把你的依赖关系切换到没有这种行为的东西上。
感谢您的阅读!我希望这有帮助。
文章来源: https://todd.ginsberg.com/post/java/virtual-thread-pinning
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/3018.html
暂无评论