问题的产生及原因

这几天在复习Java的基础知识时发现了一个虚拟线程和平台线程的知识点:平台线程是严格的1:1模型创建的线程,即一个平台线程对应一个内核线程,而虚拟线程是M:N模型,即大量的虚拟线程被映射到少量的载体平台线程上运行。并且还有虚拟线程适用于I/O密集型任务。这里笔者就有一个疑问:为什么虚拟线程更适用于I/O密集型任务

先介绍一下问题产生的原因:在操作系统,线程分为两种:内核线程和用户线程,其中内核线程是对操作性可见的,可以接受操作系统调度的;用户线程则在用户态运行,不能直接接受操作系统调度。这里就涉及到用户线程的一个缺点:当用户线程被阻塞的时候,会导致用户态线程的运行载体也被阻塞。而虚拟线程,很明显是作为用户态线程来运行的,I/O操作又是一个阻塞操作,因此说它适用于I/O密集型任务有点反常理,也就有了这个问题。

问题的解释

针对这个问题,笔者查阅了资料后,发现JDK21的虚拟线程的解决方法是采用了一种挂载和卸载的机制:当虚拟线程执行阻塞操作时,JVM的调度器会执行以下步骤:

  1. 挂起(Suspend):将当前虚拟线程的状态和栈帧冻结并保存到堆内存中。

  2. 卸载(Unmount):将该虚拟线程从其所在的平台线程(即载体线程)上分离。

  3. 释放与复用(Release & Reuse):平台线程获得释放,立即被调度去执行另一个处于就绪状态的虚拟线程。

这里就还有一个问题:既然虚拟线程已经被卸载,那么是谁在等待?当虚拟线程被卸载后,JVM 会将底层的 NIO 选择器(Selector) 注册到操作系统内核。也就是说,现在是操作系统在等待,虽然需要这个操作的虚拟线程已经被释放了。

继续查阅资料后,我又发现导致虚拟线程钉住的两种主要情况:

  • 在 synchronized 块或方法内执行阻塞操作:这是最常见的原因。在 JDK 21 到 23 中,为了维护对象监视器(ObjectMonitor)的语义,JVM 禁止在 synchronized 代码块内卸载虚拟线程。

  • 执行 Native 方法(如 JNI)或 Foreign Function 调用:因为这些代码运行在 JVM 无法管理的本地栈(C栈)上,所以无法进行卸载。

验证代码

这里,你可能会想,这些操作也会使平台线程被“钉住”,有什么差异呢?这里,就要用到先前提到的平台线程是1:1模型,而虚拟线程是M:N模型了,有没有可能根据这个差异,设计一种能通过虚拟线程“钉住”其所有平台线程,从而导致整个虚拟线程停摆的操作呢?当然是有的,代码如下:

import java.util.concurrent.*;
import java.util.*;

public class PurePinningTest {

    public static void main(String[] args) throws Exception {
        int threadCount = 60;          // > CPU核心数
        int sleepMs = 1000;

        System.out.println("CPU核心数: " + Runtime.getRuntime().availableProcessors());
        System.out.println("线程数: " + threadCount + ",每人持独有锁 sleep " + sleepMs + "ms");
        System.out.println("----------------------------------------------");

        // ----- 虚拟线程 + synchronized (独有锁) -> 预期钉住 -----
        long start1 = System.currentTimeMillis();
        List<Thread> vts = new ArrayList<>();
        for (int i = 0; i < threadCount; i++) {
            Object myLock = new Object(); // 每个线程独有锁!
            vts.add(Thread.startVirtualThread(() -> {
                synchronized (myLock) {
                    try { Thread.sleep(sleepMs); } catch (InterruptedException e) {}
                }
            }));
        }
        for (Thread t : vts) t.join();
        long dur1 = System.currentTimeMillis() - start1;


        // ----- 平台线程池 (固定线程) + synchronized (独有锁) -----
        long start3 = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(threadCount)) {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < threadCount; i++) {
                Object myLock = new Object();
                futures.add(executor.submit(() -> {
                    synchronized (myLock) {
                        try { Thread.sleep(sleepMs); } catch (InterruptedException e) {}
                    }
                }));
            }
            for (var f : futures) f.get();
        }
        long dur3 = System.currentTimeMillis() - start3;

        // ----- 结果输出 -----
        System.out.println("[虚拟线程 + synchronized]  耗时: " + dur1 + " ms  ← 载体被钉住,分批执行");
        
        System.out.println("[平台线程 + synchronized]  耗时: " + dur3 + " ms  ← OS线程并行执行");
        System.out.println("----------------------------------------------");
        
    }
}

介绍一下代码:首先,线程数量threadCount要大于cpu的核心数量(因为载体平台线程的数量一般等于cpu的核心数量),其次,每个线程创建一个锁,这里如果使用一个全局锁的话平台线程的运行就和虚拟线程没有任何区别了。然后再synchronized代码块中让每个线程休眠,计算运行时间。最终结果如下:

CPU核心数: 32
线程数: 60,每人持独有锁 sleep 1000ms
----------------------------------------------
[虚拟线程 + synchronized]  耗时: 2030 ms  ← 载体被钉住,分批执行
[平台线程 + synchronized]  耗时: 1019 ms  ← OS线程并行执行
----------------------------------------------

虚拟线程的运行时间长于平台线程,你可能又会觉得在实际情况下怎么可能每个线程都持有一个不一样的锁,但其实这是可能的。来看下面的一种情况:

在用户修改自己的信息时,通过会给这个用户加锁,而这个锁一般使用的是用户对象或者是用户的id,那么,当几十个或者几百个用户并发修改自己的信息的时候,是不是就相当于每个线程持有一个不一样的锁,也就和上面代码的验证场景相同了。

所以,在使用虚拟线程是,最好使用ReentrantLock这类锁在进行手动的加锁解锁,避免线程被“钉住”的情况导致性能下降。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐