JEP 491虚拟线程和synchronized

Java24解决了虚拟线程同步使用问题 - 极道

调度方式

调度方式分为协作式调度和抢占式调度

协作式调度:

线程不让出执行权会导致其他线程饿死。

抢占式调度:

操作系统或运行时强制中断正在运行的线程,将 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()机制的更改,允许虚拟线程在等待和重新获取监视器时挂起和恢复,而不会锁定其载体平台线程。

本提案识别了一些虚拟线程仍然会固定的情况,例如在类初始化器内阻塞、等待其他线程初始化类以及在类加载期间解析符号引用时阻塞。

Last modification:December 18, 2025
如果觉得我的文章对你有用,请随意赞赏