JEP 491: 在不锁定的情况下同步虚拟线程
概述
目标
-
使现有的 Java 库能够与虚拟线程一起很好地扩展,而无需更改它们以不使用
synchronized
方法和语句。 -
改进诊断功能,以识别虚拟线程未能释放平台线程的剩余情况。
动机
-
要执行有用的工作,线程必须被调度,即被分配到处理器核心上执行。对于实现为操作系统线程的平台线程,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
语句(这些语句在与指定对象关联的监视器上同步),只需做适当的更改。
克服固定
长时间频繁地固定(pinning)会损害可扩展性。当 JDK 调度程序可用的所有平台线程都被虚拟线程固定或在 JVM 中被阻塞时,这可能导致饥饿甚至死锁,从而无法运行任何虚拟线程。为了避免这些问题,许多库的维护者已经修改了他们的代码,使用 java.util.concurrent
锁 —— 这些锁不会固定虚拟线程 —— 代替 synchronized
方法和语句。
然而,为了享受虚拟线程的可扩展性优势,并不一定需要放弃synchronized
方法和语句。JVM 对 synchronized
关键字的实现应该允许虚拟线程在进入 synchronized
方法或语句时,或者在阻塞于监视器上时卸载。这将有助于更广泛地采用虚拟线程。
描述
我们将更改 JVM 对 synchronized
关键字的实现,以便虚拟线程可以独立于其载体来获取、持有和释放监视器。挂载和卸载操作将执行必要的记账工作,以允许虚拟线程在 synchronized
方法或语句内部,或在等待监视器时,能够卸载和重新挂载。
阻塞以获取监视器会卸载虚拟线程并释放其载体到JDK的调度器。当监视器被释放,并且JVM选择继续执行该虚拟线程时,JVM会将该虚拟线程提交给调度器。调度器将挂载虚拟线程,可能是在不同的载体上,以恢复执行并再次尝试获取监视器。
Object.wait()
方法及其定时等待的变体在等待和阻塞以重新获取监视器时,也会类似地卸载虚拟线程。当使用 Object.notify()
唤醒,并且监视器被释放时,如果 JVM 选择让虚拟线程继续执行,JVM 会将虚拟线程提交给调度程序以恢复执行。
诊断剩余的 pinning 情况
每当虚拟线程在 synchronized
方法内部阻塞时,JDK Flight Recorder (JFR) 会记录一个 jdk.VirtualThreadPinned
事件。此事件有助于识别可以从更改中受益的代码,以减少对 synchronized
方法和语句的使用,在此类结构内部不阻塞,或者用 java.util.concurrent
锁替换此类结构。
一旦 synchronized
关键字不再固定虚拟线程,此 JFR 事件将不再为此目的所需,但我们将保留它以应对其他固定情况。特别是,如果虚拟线程通过 native
方法或 外部函数和内存 API 调用本地代码,并且该本地代码回调到执行阻塞操作或在监视器上阻塞的 Java 代码,则虚拟线程将被固定。因此,我们将更改 JVM 以在这种情况下发出 jdk.VirtualThreadPinned
事件,并将增强事件本身以传达虚拟线程被固定的原因以及载体线程的身份。
系统属性 jdk.tracePinnedThreads
不再需要
系统属性 jdk.tracePinnedThreads
由 JEP 444 引入,当虚拟线程在 synchronized
方法中阻塞时,该属性会导致打印堆栈跟踪,但当虚拟线程阻塞以获取监视器或在 Object.wait()
中等待时不会打印。
此系统属性在 synchronized
关键字不再固定虚拟线程后将不再需要。此外,由于在执行关键代码时打印堆栈跟踪,这已经被证明是有问题的。因此,我们将移除此系统属性;在命令行中设置它将不起作用。
在 synchronized
和 java.util.concurrent.locks
之间选择
一旦 synchronized
关键字不再锁定虚拟线程,你就可以仅根据哪个最能解决问题来选择使用 synchronized
还是 java.util.concurrent.locks
包中的 API。
作为背景,java.util.concurrent.locks
包定义了与内置的 synchronized
关键字不同且更为灵活的锁定和等待 API。ReentrantLock
API 的行为与 synchronized
相同。Condition
API 等同于 Object.wait()
和 Object.notify()
方法。该包中的其他 API 为需要公平性、使用读写锁进行共享数据的并发访问、定时或可中断的锁获取或乐观读取的高级情况提供了更大的功能和更细粒度的控制。
java.util.concurrent.locks
API 的灵活性是以更笨拙的语法为代价的。为了确保锁能够适当地释放,通常应该使用 try-finally
结构来使用这些 API;当然,使用 synchronized
时则不需要这样做。java.util.concurrent.locks
API 的性能特性也与 synchronized
方法或语句不同。
我们先前建议通过将代码从使用 synchronized
迁移到使用 ReentrantLock
来解决频繁和长期存在的锁定问题。一旦 synchronized
关键字不再锁定虚拟线程,这种迁移将不再必要。您不必将已迁移到使用 ReentrantLock
的代码再改回使用 synchronized
。
如果你正在编写新的代码,我们同意《Java 并发编程实战》§13.4 中的建议:在实际可行的情况下使用 synchronized
,因为它更方便且出错的可能性较小;当需要更多灵活性时,则使用 ReentrantLock
和 java.util.concurrent.locks
中的其他 API。无论哪种方式,都要通过缩小锁的作用范围来减少争用的可能性,并尽可能避免在持有锁的同时进行 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 要求在所有情况下自动释放监视器等领域也存在许多挑战。这种方法还需要重新实现许多可维护性特性。
风险和假设
当使用虚拟线程代替平台线程时,某些代码的性能可能会有所不同。当一个线程退出监视器时,可能必须将虚拟线程排队到调度器。目前,这不如退出监视器时唤醒一个平台线程的情况高效。
依赖项
我们在此提出的更改依赖于对 JVM TI 函数 GetObjectMonitorUsage
的规范 进行的变更,该变更在 Java 23 中实现。此函数不再支持返回有关由虚拟线程拥有的监视器的信息。这样做将需要大量的簿记工作来查找未挂载的虚拟线程所拥有的监视器。