JEP 491: Synchronize Virtual Threads without Pinning | 虚拟线程同步而不锁定
摘要
通过安排在诸如 synchronized
方法和语句这样的结构中阻塞的虚拟线程释放其底层 平台线程,以便其他虚拟线程使用,从而提高使用这些结构的 Java 代码的可扩展性。这将消除几乎所有虚拟线程被 固定 到平台线程的情况,这种固定严重限制了可用于处理应用程序工作负载的虚拟线程数量。
目标
使现有的 Java 库能够在不修改为不使用
synchronized
方法和语句的情况下,与虚拟线程一起良好地扩展。改进识别剩余情况下虚拟线程未能释放平台线程的诊断信息。
动机
虚拟线程是通过 JEP 444 在 Java 21 中引入的,它们是由 JDK 而非操作系统(OS)提供的轻量级线程。虚拟线程通过允许应用程序使用大量线程极大地减少了开发、维护和观察高吞吐量并发应用程序的工作。虚拟线程的基本模型如下:
为了执行有用的工作,线程必须被 调度,即分配给处理器核心执行。对于作为 OS 线程实现的平台线程,JDK 依赖于 OS 中的调度器。相比之下,对于虚拟线程,JDK 拥有自己的调度器。JDK 的调度器不是直接将虚拟线程分配给处理器核心,而是将虚拟线程分配给平台线程,然后像往常一样由 OS 调度这些平台线程。
为了在虚拟线程中运行代码,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
的虚拟线程 固定 到它的载体上。固定阻止了虚拟线程卸载。因此,不仅虚拟线程,而且它的载体以及底层的 OS 线程都会被 read
方法阻塞,直到有字节可供读取。
固定的原因
Java 编程语言中的 synchronized
关键字是根据 监视器 定义的:每个对象都与一个监视器相关联,该监视器可以被获取(即锁定),持有一段时间,然后释放(即解锁)。一次只有一个线程可以持有对象的监视器。为了使线程运行一个 synchronized
实例方法,线程首先获取与实例关联的监视器;当方法结束时,线程释放监视器。
为了实现 synchronized
关键字,JVM 跟踪当前哪个线程持有对象的监视器。不幸的是,它跟踪的是哪个平台线程持有监视器,而不是哪个虚拟线程。当一个虚拟线程运行一个 synchronized
实例方法并获取与实例关联的监视器时,JVM 记录虚拟线程的载体平台线程作为持有监视器——而不是虚拟线程本身。
如果虚拟线程在 synchronized
实例方法内部卸载,JDK 的调度器很快会在现在空闲的平台线程上装载一些其他的虚拟线程。由于其载体的关系,那个其他虚拟线程会被 JVM 视为持有与实例关联的监视器。运行在那个线程中的代码能够调用实例上的其他 synchronized
方法,或释放与实例关联的监视器。互斥就会丢失。因此,JVM 主动防止虚拟线程在 synchronized
方法内部卸载。
更多关于固定
如果一个虚拟线程调用了一个 synchronized
实例方法,并且该实例关联的监视器被另一个线程持有,那么这个虚拟线程必须阻塞,因为一次只能有一个线程持有该监视器。我们希望虚拟线程能从其载体上卸载并释放该平台线程给 JDK 调度器。不幸的是,如果监视器已经被其他线程持有,那么虚拟线程将在 JVM 中阻塞直到其载体获取到监视器。
此外,当一个虚拟线程在一个 synchronized
实例方法内部并且在对象上调用了 Object.wait()
时,虚拟线程会在 JVM 中阻塞直到通过 Object.notify()
唤醒并且其载体重新获取监视器。虚拟线程由于正在执行一个同步方法而被固定,而且由于其载体在 JVM 中阻塞而进一步被固定。
上述讨论,经过适当修改后,也适用于同步静态方法,它们对方法所属类的 Class
对象关联的监视器进行同步,以及适用于对指定对象关联的监视器进行同步的同步语句。
克服固定问题
长时间频繁地固定会影响可扩展性。这可能导致饥饿甚至死锁,因为没有虚拟线程能够运行,因为所有提供给 JDK 调度器的平台线程要么被虚拟线程固定,要么在 JVM 中阻塞。为了避免这些问题,许多库的维护者已经修改了他们的代码,以使用不会固定虚拟线程的 java.util.concurrent
锁 来代替同步方法和语句。
然而,为了享受虚拟线程带来的可扩展性好处,不应该必须放弃同步方法和语句。JVM 对关键字 synchronized
的实现应该允许虚拟线程在其内执行同步方法或语句时,或者在等待监视器时,可以卸载。这将使得虚拟线程得到更广泛的应用。
描述
我们将改变 JVM 对关键字 synchronized
的实现,以便虚拟线程能够独立于其载体获取、持有和释放监视器。挂载和卸载操作将做必要的簿记工作,以允许虚拟线程在执行同步方法或语句时,或者在等待监视器时卸载和重新挂载。
为了获取监视器而阻塞将使虚拟线程卸载,并将其载体释放回 JDK 的调度器。当监视器被释放,并且 JVM 选择继续执行该虚拟线程时,JVM 会将虚拟线程提交给调度器。调度器将在可能不同的载体上挂载虚拟线程,以恢复执行并尝试再次获取监视器。
Object.wait()
方法及其定时等待变体,在等待和为重新获取监视器而阻塞时也将类似地卸载虚拟线程。当通过 Object.notify()
唤醒,并且监视器被释放,且 JVM 选择继续执行该虚拟线程时,JVM 会将虚拟线程提交给调度器以恢复执行。
诊断剩余的固定情况
每当虚拟线程在同步方法内阻塞时,JDK 飞行记录器(JFR)会记录一个 jdk.VirtualThreadPinned
事件。此事件对于识别那些受益于更改的代码非常有用,这些更改包括减少对同步方法和语句的使用,避免在这样的结构内阻塞,或用 java.util.concurrent
锁替换这些结构。
一旦关键字 synchronized
不再固定虚拟线程,这个 JFR 事件将不再需要用于那个目的,但我们仍会保留它用于其他固定情形。特别是,如果虚拟线程通过本地方法或 外部函数与内存 API 调用原生代码,并且该原生代码回调至执行阻塞操作或在监视器上阻塞的 Java 代码,那么虚拟线程将被固定。因此,我们将更改 JVM 在这些情况下发出 jdk.VirtualThreadPinned
事件,并增强该事件本身以传达虚拟线程被固定的缘由及其载体线程的身份。
系统属性 jdk.tracePinnedThreads
不再需要
系统属性 jdk.tracePinnedThreads
由 JEP 444 引入,导致在虚拟线程在同步方法内阻塞时打印堆栈跟踪,尽管在虚拟线程为获取监视器或在 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 Concurrency in Practice §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 中不再支持返回有关由虚拟线程拥有的监视器的信息。这样做将需要大量的簿记工作来查找未挂载的虚拟线程所拥有的监视器。