JEP 491:无需固定即可同步虚拟线程
概括
synchronized
通过安排在此类构造中阻塞的虚拟线程释放其底层平台线程以供其他虚拟线程使用,提高使用方法和语句的 Java 代码的可扩展性。这将消除几乎所有虚拟线程被固定到平台线程的情况,这严重限制了可用于处理应用程序工作负载的虚拟线程数量。
目标
-
使现有的 Java 库能够通过虚拟线程很好地扩展,而无需将其更改为不使用
synchronized
方法和语句。 -
改进诊断功能,识别虚拟线程无法释放平台线程的其余情况。
动机
虚拟线程是在Java 21中通过JEP 444引入的,是 JDK 而不是操作系统 (OS) 提供的轻量级线程。虚拟线程允许应用程序使用大量线程,从而显著减少了开发、维护和观察高吞吐量并发应用程序的工作量。虚拟线程的基本模型如下:
-
要执行有用的工作,必须对线程进行_调度_,即分配线程在处理器核心上执行。对于作为操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度程序。相比之下,对于虚拟线程,JDK 有自己的调度程序。JDK 的调度程序不会直接将虚拟线程分配给处理器核心,而是将虚拟线程分配给平台线程,然后操作系统会像往常一样对这些线程进行调度。
-
要在虚拟线程中运行代码,JDK 的调度程序会通过将虚拟线程_挂载到平台线程上来分配_虚拟线程在平台线程上执行。这使平台线程成为虚拟线程的_载体。稍后,在运行一些代码后,虚拟线程可以从其载体上__卸载_。此时平台线程被释放,以便 JDK 的调度程序可以在其上挂载不同的虚拟线程,从而再次使其成为载体。
-
执行阻塞操作(例如 I/O)时,虚拟线程将被卸载。稍后,当阻塞操作准备完成(例如,在套接字上接收到字节)时,该操作会将虚拟线程提交回 JDK 的调度程序。调度程序将虚拟线程挂载到平台线程上以恢复运行代码。
虚拟线程频繁且透明地安装和卸载,不会阻塞任何平台线程。
虚拟线程固定在synchronized
方法中
不幸的是,虚拟线程在synchronized
方法内部运行代码时无法卸载。考虑以下synchronized
从套接字读取字节的方法:
synchronized byte[] getData() {
byte[] buf = ...;
int nread = socket.getInputStream().read(buf); // Can block here
...
}
如果该read
方法由于没有可用字节而阻塞,我们希望正在运行的虚拟线程getData
从其载体上卸载。这将释放一个平台线程,以便 JDK 的调度程序可以在其上安装不同的虚拟线程。不幸的是,由于getData
是synchronized
,JVM将正在运行的虚拟线程_固定_getData
到其载体上。固定可防止虚拟线程卸载。因此,该read
方法不仅会阻塞虚拟线程,还会阻塞其载体,从而阻塞底层操作系统线程,直到有字节可供读取。
置顶原因
Java 编程语言中的关键字synchronized
是根据监视器_来定义的:_每个对象都与一个监视器相关联,该监视器可以被获取(即锁定)、持有一段时间,然后释放(即解锁)。一次只有一个线程可以持有对象的监视器。对于要运行synchronized
实例方法的线程,该线程首先获取与该实例相关联的监视器;当该方法完成时,该线程将释放监视器。
为了实现该synchronized
关键字,JVM 会跟踪当前哪个线程持有对象的监视器。不幸的是,它跟踪的是哪个平台线程持有监视器,而不是哪个虚拟线程。当虚拟线程运行synchronized
实例方法并获取与该实例关联的监视器时,JVM 会记录虚拟线程的载体平台线程持有监视器 — 而不是虚拟线程本身。
如果虚拟线程在实例方法内卸载synchronized
,JDK 的调度程序将很快在现在空闲的平台线程上安装其他虚拟线程。由于其载体,JVM 会将该其他虚拟线程视为持有与该实例关联的监视器。在该线程中运行的代码将能够调用synchronized
实例上的其他方法,或释放与该实例关联的监视器。互斥将丢失。因此,JVM 会主动阻止虚拟线程在方法内卸载synchronized
。
更多固定
如果虚拟线程调用synchronized
实例方法,并且与该实例关联的监视器由另一个线程持有,则虚拟线程必须阻塞,因为一次只有一个线程可以持有该监视器。我们希望虚拟线程从其载体上卸载并将该平台线程释放给 JDK 调度程序。不幸的是,如果监视器已被另一个线程持有,则虚拟线程将在 JVM 中阻塞,直到载体获取监视器。
此外,当虚拟线程位于synchronized
实例方法中并调用Object.wait()
对象时,虚拟线程将在 JVM 中阻塞,直到被唤醒Object.notify()
并且载体重新获取监视器。虚拟线程被固定是因为它正在synchronized
方法中执行,并且进一步被固定是因为它载体在 JVM 中被阻塞。
前述讨论经过适当的修改后适用于在与该方法的类的对象synchronized
static
相关联的监视器上进行同步的方法,以及在与指定对象相关联的监视器上进行同步的语句。Class``synchronized
克服钉扎
频繁长时间锁定会损害可扩展性。当 JDK 调度程序可用的所有平台线程要么被虚拟线程锁定,要么在 JVM 中被阻止,导致虚拟线程无法运行,这可能会导致资源匮乏甚至死锁。为了避免这些问题,许多库的维护者修改了他们的代码,使用java.util.concurrent
锁(不锁定虚拟线程)来代替synchronized
方法和语句。
但是,不必synchronized
为了享受虚拟线程的可扩展性优势而放弃方法和语句。JVM 的关键字实现应该允许在方法或语句中或在监视器上阻塞时synchronized
卸载虚拟线程。这将使虚拟线程得到更广泛的采用。synchronized
描述
我们将更改 JVM 的synchronized
关键字实现,以便虚拟线程可以独立于其载体获取、保留和释放监视器。挂载和卸载操作将执行必要的簿记,以允许虚拟线程在方法synchronized
或语句中或在等待监视器时卸载和重新挂载。
阻塞以获取监视器将卸载虚拟线程并将其载体释放给 JDK 的调度程序。当监视器被释放并且 JVM 选择虚拟线程继续时,JVM 会将虚拟线程提交给调度程序。调度程序将挂载虚拟线程(可能在不同的载体上)以恢复执行并再次尝试获取监视器。
该Object.wait()
方法及其定时等待变体在等待并阻塞以重新获取监视器时将类似地卸载虚拟线程。当使用 唤醒时Object.notify()
,监视器被释放,并且 JVM 选择虚拟线程继续,JVM 会将虚拟线程提交给调度程序以恢复执行。
诊断剩余的钉扎病例
jdk.VirtualThreadPinned
每当虚拟线程在方法内阻塞时,JDK Flight Recorder (JFR) 都会记录一个事件synchronized
。此事件有助于识别哪些代码可以通过更改受益,以减少使用synchronized
方法和语句、在此类构造内不阻塞或用锁替换此类构造java.util.concurrent
。
synchronized
一旦关键字不再固定虚拟线程,此 JFR 事件将不再需要用于此目的,但我们会将其保留用于其他固定情况。具体来说,如果虚拟线程通过native
方法或外部函数和内存 API调用本机代码,并且该本机代码回调执行阻塞操作或在监视器上阻塞的 Java 代码,则虚拟线程将被固定。因此,我们将更改 JVM 以jdk.VirtualThreadPinned
在这些情况下发出事件,并且我们将增强事件本身以传达虚拟线程被固定的原因和载体线程的身份。
jdk.tracePinnedThreads
不再需要系统属性
JEP 444jdk.tracePinnedThreads
引入的系统属性会导致在方法内部虚拟线程阻塞时打印堆栈跟踪,但虚拟线程阻塞以获取监视器或在等待时则不会打印堆栈跟踪。synchronized``Object.wait()
synchronized
一旦关键字不再固定虚拟线程,将不再需要此系统属性。此外,事实证明,这是有问题的,因为在执行关键代码时会打印堆栈跟踪。因此,我们将删除此系统属性;在命令行上设置它将不起作用。
synchronized
在和之间进行选择java.util.concurrent.locks
一旦synchronized
关键字不再固定虚拟线程,您就可以synchronized
在包中的 API之间进行选择java.util.concurrent.locks
,仅基于哪个可以最好地解决手头的问题。
作为背景,该包定义了用于锁定和等待的 API,这些 API 与内置关键字java.util.concurrent.locks
不同,并且比内置关键字更灵活。该API 的行为与 相同。该API 相当于和方法。包中的其他 API 为需要公平性、使用读写锁并发访问共享数据、定时或可中断锁获取或乐观读取的高级情况提供了更强大的功能和更精细的控制。synchronized
ReentrantLock
synchronized
Condition
Object.wait()``Object.notify()
API的灵活性java.util.concurrent.locks
是以更难用的语法为代价的。API 通常应与 构造一起使用try-finally
,以确保正确释放锁;当然,这对于 来说不是必需的synchronized
。API还具有与方法或语句java.util.concurrent.locks
不同的性能特征。synchronized
我们之前建议synchronized
通过将代码从 using 迁移到 using来解决频繁且长期存在的固定问题ReentrantLock
。一旦synchronized
关键字不再固定虚拟线程,就不再需要进行此类迁移。您无需将已迁移到 use 的代码恢复ReentrantLock
回 using synchronized
。
如果您正在编写新代码,我们同意《Java 并发实践》第 13.4 节中的建议:synchronized
在切实可行的情况下使用,因为它更方便且更不容易出错,并ReentrantLock
在需要更多灵活性时使用和其他 API java.util.concurrent.locks
。无论哪种方式,都应通过缩小锁定范围来减少争用的可能性,并尽可能避免在持有锁定时执行 I/O 或其他阻塞操作。
未来工作
还有一些与synchronized
关键字无关的情况,其中虚拟线程在阻塞时无法卸载:
-
将符号引用 ( JVMS §5.4.3 ) 解析为类或接口时,虚拟线程在加载类时阻塞。这是由于堆栈上的本机框架导致虚拟线程固定载体的情况。
-
在类初始化程序内部阻塞时。这也是虚拟线程由于堆栈上的本机框架而固定载体的情况。
-
等待另一个线程初始化类时(JVMS §5.5)。这是虚拟线程在 JVM 中阻塞的特殊情况,从而固定了载体。
这些情况很少会引起问题,但如果它们被证明是有问题的,我们将重新审视它们。
替代方案
-
通过临时扩展虚拟线程调度程序的并行度来补偿固定。调度程序已经为 执行了此操作
Object.wait()
,方法是确保在虚拟线程等待时有备用平台线程可用。在某些情况下,增加并行度会有所帮助,但无法扩展。调度程序可用的最大平台线程数是有限的,默认限制为 256 个线程。如果许多虚拟线程在
synchronized
方法内阻塞,那么并行度的任何值都无济于事。 -
在 JVM 加载每个类时,重写其字节码,以便将每次使用 替换
synchronized
为 的等效使用ReentrantLock
。该
synchronized
语句可与任何对象一起使用,因此这需要维护从对象到锁的映射,这是一个很大的开销。在某些情况下,转换并不完全透明,特别是对于
synchronized
方法,因为JVMS §2.11.10要求在调用方法之前获取监视器。这种方法在很多方面也面临挑战,例如 JNI 锁定、JVM TI 的几个特性以及 JVMS 要求在所有情况下自动释放监视器。这种方法还需要重新实现许多可服务性功能。
风险和假设
当使用虚拟线程代替平台线程时,某些代码的性能可能会有所不同。当线程退出监视器时,它可能必须将虚拟线程排队到调度程序。目前,这种方式的效率不如退出监视器取消停放平台线程的情况。
依赖项
我们在此提出的更改取决于Java 23中JVM TI 函数规范的更改。此函数不再支持返回有关虚拟线程拥有的监视器的信息。这样做需要大量的簿记才能找到未挂载的虚拟线程拥有的监视器。GetObjectMonitorUsage