JEP 491虚拟线程和synchronized

调度方式
调度方式分为协作式调度和抢占式调度
协作式调度:
线程不让出执行权会导致其他线程饿死。
抢占式调度:
操作系统或运行时强制中断正在运行的线程,将 CPU 分配给其他线程。
是否切断线程由调度器决定。即使你的 Java 代码没有调用 yield(),操作系统仍然可以在任何时刻抢占线程。
在java中虚拟线程就是协作式调度,平台线程就是抢占式调度。
Java 虚拟线程(Virtual Thread)只有在发生“可挂起(park)”的点时,才会与当前平台线程(carrier / platform thread)解绑,平台线程才有机会去执行其他虚拟线程。
如阻塞,或者线程礼让。
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
public class Main {
public static void main(String[] args) throws InterruptedException {
// 使用虚拟线程创建一个固定数量的 carrier threads
ExecutorService executor = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().factory());
//平台线程小于50
int busyThreads = 50;
int newThreadIndex = 0;
// 提交“死循环虚拟线程”
for (int i = 0; i < busyThreads; i++) {
int id = i;
executor.submit(() -> {
System.out.println("Busy virtual thread " + id + " started");
while (true) {
// 纯 CPU 循环,不阻塞
}
});
}
// 等待一会儿,让死循环线程占满平台线程
Thread.sleep(1000);
// 提交一个新虚拟线程尝试执行
executor.submit(() -> {
System.out.println("New virtual thread " + newThreadIndex + " started");
});
// 注意:你可能看不到 New virtual thread 的打印
// 因为所有平台线程都被占满
Thread.sleep(10000);
}
}
上述发生的原因是java的协程是协作式调度的
go1.13之前是协作式调度为主,1.14之后支持抢占式调度。
现在 Go 采用的是混合调度策略,兼具两种调度的优势:运行时向 M(OS线程)发送信号实现强制抢占,防止 goroutine 长时间占用 CPU
package main
import (
"fmt"
"time"
)
func main() {
busyThreads := 50
newThreadIndex := 0
// 启动"死循环"goroutine
for i := 0; i < busyThreads; i++ {
id := i
go func() {
fmt.Printf("Busy goroutine %d started\n", id)
// 纯 CPU 循环,不阻塞
for {
}
}()
}
// 等待一会儿,让死循环 goroutine 开始执行
time.Sleep(2 * time.Second)
// 提交新 goroutine
go func() {
fmt.Printf("New goroutine %d started\n", newThreadIndex)
}()
// 等待 10 秒
time.Sleep(10 * time.Second)
}
因此go就不会出现这样的问题。但在1.13中就会有问题。java21和25都存在这个问题。
背景
简单来说就是synchronized阻塞的时候,无法切换到其他协程,导致当前平台线程被阻塞,不能运行其他虚拟线程。
理论上可以使用ReentrantLock来解决
虚拟线程是Java 21通过JEP 444引入的轻量级线程,由JDK而非操作系统提供。虚拟线程显著减少了开发、维护和观察高吞吐量并发应用程序的工作量,使应用程序能够使用大量线程。
- 线程必须被_调度_才能做有用的工作,即被分配到处理器核心上执行。对于平台线程,JDK依赖于操作系统的调度器。对于虚拟线程,JDK有自己的调度器,它将虚拟线程分配给平台线程,然后由操作系统像往常一样调度。
- 虚拟线程在执行阻塞操作(如I/O)时会卸载,当阻塞操作准备完成时,操作将虚拟线程重新提交给JDK的调度器,调度器将虚拟线程重新安装到平台线程上以继续运行代码。
为何虚拟线程被固定到平台线程
在Java中,虚拟线程被固定(pinned)到平台线程的原因主要与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关键字是基于监视器实现的,每个对象都与一个监视器关联,该监视器可以被获取(锁定)、持有一段时间,然后释放(解锁)。一次只能有一个线程持有对象的监视器。
在JVM中,哪个线程持有对象紧绳器的信息是基于平台线程(即操作系统线程)来跟踪的,而不是基于虚拟线程。当虚拟线程执行synchronized实例方法并获取与实例关联的监视器时,JVM是虚拟线程的载体平台线程持有监视器,而不是虚拟线程本身。
如果虚拟线程在synchronized方法中卸载,JDK的调度器可能会将另一个虚拟线程安装到刚刚释放的平台线程上。
由于这个新虚拟线程的载体,JVM会认为它持有与实例关联的监视器,这将破坏互斥性,因此JVM积极组织虚拟现场在synchronized方法中卸载。
如果虚拟线程在执行synchronized方法时调用了Object.wait(), 它会在JVM中阻塞知道被Object.notify()唤醒,并且载体重新获取监视器。由于它在synchronized方法中执行,以及它的载体在JVM中阻塞,所以虚拟线程也被固定
危害
频繁的固定(尤其是长时间固定)可能会损害可扩展性,导致饥饿甚至死锁,因为没有虚拟线程可以运行,因为所有平台线程要么被虚拟线程固定,要么在JVM中被阻塞。
JEP 491旨在改变JVM对synchronized关键字的实现,允许虚拟线程在synchronized方法或语句中获取、持有和释放监视器,独立于它们的载体。这将允许虚拟线程在被阻塞时卸载,释放其载体平台线程给JDK调度器,从而提高应用程序的可扩展性
通过允许在synchronized方法和语句中阻塞的虚拟线程释放其底层平台线程,供其他虚拟线程使用,从而提高Java代码在使用synchronized时的可扩展性。
解决了虚拟线程在synchronized方法中不能卸载的问题,这个问题限制了能够用于处理应用程序工作负载的虚拟线程数量。
同时,改进了诊断工具,以识别虚拟线程未能释放平台线程的情况,通过JDK Flight Recorder (JFR)记录jdk.VirtualThreadPinned事件来识别代码中的问题区域。
由于synchronized关键字不再固定虚拟线程,因此不再需要jdk.tracePinnedThreads系统属性,该属性会在虚拟线程在synchronized方法中阻塞时打印堆栈跟踪,但可能会引起性能问题。
解决了开发者在选择synchronized和java.util.concurrent.locks包中的API时的困惑,现在可以根据实际需要选择,而不必因为虚拟线程固定问题而被迫使用ReentrantLock。
JEP 491提议重新实现synchronized关键字,允许虚拟线程获取、持有和释放监视器,而不需要将这些操作与它们的载体线程绑定,解决了虚拟线程与监视器交互的问题。
本提案涉及对Object.wait()和Object.notify()机制的更改,允许虚拟线程在等待和重新获取监视器时挂起和恢复,而不会锁定其载体平台线程。
本提案识别了一些虚拟线程仍然会固定的情况,例如在类初始化器内阻塞、等待其他线程初始化类以及在类加载期间解析符号引用时阻塞。