Java虚拟线程的使用及思考

163 阅读6分钟

关于Java虚拟线程, 其实从jdk21正式上线以来,这个feature出来已经有一段时间了,最开始我也很好奇这个黑科技究竟有多神奇,也想把虚拟线程用进自己的项目里,在虚拟线程还没有GA的时候我就已经自己玩起来了。这里就记录一下自己研究的一些经过以及学到的知识。

我当时了解这个特性是从这个视频通向Java21-02-Java虚拟线程_哔哩哔哩_bilibili,个人认为讲的还是挺详细的。

为什么要使用虚拟线程

这个问题在网上已经有很多回答了,传统线程(平台线程)的问题:每个平台线程直接对应一个操作系统(OS)线程,创建和切换成本高(内存开销约 1MB/线程,上下文切换由 OS 内核调度)。虚拟线程由 JVM 管理,而非 OS,生命周期更轻量(内存开销仅几百字节)。 可以轻松创建 数百万个虚拟线程,而不会导致系统资源耗尽。

虚拟线程的轻量可以减轻编码时的负担,程序员就可以不需要考虑线程池多大,任务阻塞了线程被占用该怎么办。对于需要并发开启线程的场景,不需要考虑系统会不会开启太多线程导致资源耗尽,直接一行Thread.ofVirtual().start()就可以直接开始编写异步代码。虚拟线程的调度由 JVM 优化(工作窃取算法),避免 OS 线程切换的开销,ForkJoinPool的设计也非常的好,性能也很不错。

隐藏的坑

上面介绍了好处,虚拟线程也有一定的坏处。Java的虚拟线程是由Carrier Thread(承载线程,即平台线程) 来进行调度的,ForkJoinPool实际运行的线程就是Carrier Thread,默认的线程数为CPU逻辑核心数。因此虚拟线程最大的问题就是Carrier Thread被pin导致死锁的问题,这里也有一篇文章我个人读了感觉很不错。虚拟线程目前不推荐上生产的个人思考 - 知乎

在 Java 的 Virtual Threads(虚拟线程) 中,Carrier Thread(承载线程,即平台线程)被 "Pin"(固定) 是指虚拟线程在特定场景下无法被挂起(yield),导致它必须独占一个平台线程运行,无法释放该平台线程去执行其他虚拟线程。这会降低虚拟线程的调度效率,尤其是在高并发场景下。

1. 执行 synchronized 块/方法

  • 原因
    synchronized 关键字是 Java 内置的同步机制,其锁操作直接绑定到平台线程(OS 线程)。
    若虚拟线程在 synchronized 块内发生阻塞(如 I/O、锁等待),JVM 无法挂起该虚拟线程,因为它需要保持平台线程对监视器锁(Monitor)的持有。

  • synchronized在jvm的实现是依赖于对象的监视器,当方法进入synchronized函数后,jvm将会记录持有对象监视器对应线程。

    由于记录的是平台线程,所以如果在虚拟线程A进入对象obj的synchronized函数后,如果没有Pin住Carrier Thread,此时另一个虚拟线程B也被调度到了同样的Carrier Thread上执行对象obj的其他synchronized函数,此时jvm会认为虚拟线程B已经获取了对象的监视器,从而不阻塞直接进入函数内部,导致并发问题。

synchronized (lock) {  // 进入 synchronized 块,Carrier Thread 被 Pin
    // 阻塞操作(如 I/O)
    Files.readString(Path.of("data.txt")); 
}

此时由于进入synchronzied块,该虚拟线程对应的Carrier Thread就无法被释放,该Carrier Thread无法再被用于调度其他Virtual Thread,造成了资源的浪费。

最极端的情况,如果所有的Carrier Thread都被Pin住,而synchronized外仍然有其他线程与之抢占资源,则会发生死锁。

  • 解决方案

Jdk 21-23:使用JUC中的锁(比如ReentrantLock)来替换掉synchronized。

private final ReentrantLock lock = new ReentrantLock();

void safeMethod() {
    lock.lock();  // 非 synchronized,不会固定 Carrier Thread
    try {
        Files.readString(Path.of("data.txt"));
    } finally {
        lock.unlock();
    }
}

该问题只存在于jdk 21-23,jdk24对底层synchronized,Object.wait()以及notify()函数进行了大量的重写,经过jdk开发团队的努力,synchronized已经不会导致Carrier Thread被Pin了JEP 491: Synchronize Virtual Threads without Pinning

2. 调用 Native 方法或 JNI 代码

  • 原因
    Native 方法(通过 JNI 调用的 C/C++ 代码)可能直接操作平台线程的状态,或执行无法被 JVM 控制的阻塞操作。
    JVM 无法感知 Native 方法中的阻塞行为,因此必须固定 Carrier Thread。
public native void blockingJNIOperation();  // 调用阻塞型 JNI 方法

和上面的情况类似,如果JNI函数非常的耗时,直接阻塞导致Carrier Thread被用尽,也会发生死锁的问题。

因此使用JNI调用原生方法时,应该尽量避免耗时较长的操作。或者直接单开一个平台线程,不要挤占虚拟线程的资源。

Thread.ofPlatform().start(() -> {
    blockingJNIOperation();
});

虚拟线程并不适合CPU计算密集型任务

虚拟线程设计指出就是为了吞吐量考虑,特别是对于IO密集型任务。所以默认是采用非抢占的设计。虚拟线程并不像平台线程那样,操作系统轮番切换,互相进行抢占,而只会在遇到了阻塞,比如IO操作,网络请求导致阻塞,或者锁以及synchronized块等等才会进行切换。

因此,如果你的任务是CPU密集型,那么进行任务分配的时候,虚拟线程就会挨个挨个执行。

比如你的CPU是8核,同时启动16个或者以上的计算任务。普通的平台线程池就会同时启动,16个任务都在差不多的进度,最后几乎一起完成。但是如果使用虚拟线程,由于计算任务并不会遇到资源抢占,因此虚拟线程不会切换,先跑前8个任务,再跑后8个任务。

如果任务执行时间超长还可能会导致某个任务长时间得不到调度。

总结,对于虚拟线程的使用还是应该看好自己的使用场景。对于已经设计的很好的一些并发库,从平台线程切换到虚拟线程,有可能不仅不会得到性能提升,反而可能性能下降。