Java虚拟线程详解

引言

依稀还记得2016年开始学Java的场景,时光的距离是如此简短,十年时间仿佛隔桌而坐。刚学java时用的还是jdk1.6(jdk6),到现在最新的jdk版本已经是25了,在java圈子里有一个诙谐的说法来形容jdk的快速迭代,“新版任你发,我用java8,你升随你升,我用java8”,这既是玩笑,也是真实写照。就我个人而言,使用的最多的也是jdk8,在22年后新项目都升级到了jdk17,今年的一个项目又开始使用jdk21,而jdk21的一个重要特性,便是正式引入了虚拟线程(Virtual Thread)。

为什么要引入虚拟线程?

在jdk21之前,Java 的线程模型(平台线程 Platform Thread)是一对一映射到操作系统线程(OS Thread)的。这带来了几个根本性问题:
OS 线程的代价很昂贵:

  • 每个 OS 线程默认栈大小约 512KB~1MB
  • 线程创建/销毁涉及系统调用,开销大
  • 上下文切换(context switch)由 OS 调度,成本高
  • 一台普通服务器能稳定支撑的 OS 线程数通常只有几千个

这也是网关、RPC、HTTP服务这类IO密集型服务的核心性能瓶颈。

在jdk21中如何创建线程:

//创建平台线程
Thread.ofPlatform().start(() -> System.out.println("platform thread"));

//传统方式 - 创建平台线程
Thread t1 = new Thread(() -> System.out.println("platform thread"));
t1.start();


//创建虚拟线程
Thread vt = Thread.ofVirtual().start(() -> System.out.println("virtual thread"));

虚拟线程的原理

结合Linux中的内核线程(Kernel Thread)和用户线程(User Thread)的关系更容易理解虚拟线程的原理。

Linux 中线程分两个层面:
内核线程(Kernel Thread): 由 OS 内核管理,真正在 CPU 上执行,是 OS 调度的基本单位。
用户线程(User Thread): 在用户空间实现,内核不感知,由用户空间的线程库管理调度。

两者的映射关系历史上有三种模型:

  • 1:1 模型:一个用户线程对应一个内核线程(Linux NPTL,现代Linux的默认实现)
  • N:1 模型:所有用户线程映射到一个内核线程(内核完全不感知多线程)
  • M:N 模型:M个用户线程映射到N个内核线程(两级调度)

jdk21虚拟线程与平台线程的关系与 M:N 模型 类似:

Linux M:N 模型 Java 虚拟线程
用户线程(M个) 虚拟线程(M个)
内核线程(N个) 平台线程/载体线程(N个)
用户空间调度器 JVM调度器(ForkJoinPool)
内核调度器 OS调度器

核心思想都是用少量的“重”线程驱动大量的“轻”线程,调度器把对应的内核线程/载体线程切换去执行其他任务,避免资源浪费。

阻塞时的处理思路也相同:用户线程/虚拟线程阻塞时,调度器把对应的内核线程/载体线程切换去执行其他任务,避免资源浪费。

但是两者又有着重要区别:

  1. 内核感知程度不同
Linux 用户线程(N:1或M:N):内核完全不感知用户线程的存在
                            内核只看到内核线程,不知道上面跑了多少用户线程

Java 虚拟线程:载体线程是真实的 OS 线程,内核完全感知载体线程
              虚拟线程本身确实对内核不可见,但载体线程对内核完全可见
  1. 阻塞处理机制不同

这是最关键的区别:

Linux 用户线程阻塞时的问题:
用户线程发起系统调用(如 read())→ 内核线程被阻塞
→ 整个内核线程卡住
→ 该内核线程上的所有用户线程都无法执行
→ 这是 N:1 模型的致命缺陷

Java 虚拟线程阻塞时:
虚拟线程发起 I/O → JVM 拦截,改写为异步 I/O
→ 虚拟线程从载体线程卸载
→ 载体线程立刻去执行其他虚拟线程
→ I/O 完成后虚拟线程重新挂载

Java 虚拟线程能解决阻塞问题,根本原因是 JVM 在底层把阻塞式 I/O 替换成了异步 I/O(基于 Linux 的 io_uring 或 epoll),这是 Linux 原始用户线程库做不到的事情。

  1. 历史背景不同

Linux 的 M:N 模型(如早期的 NGPT)最终被放弃了,现代 Linux 默认使用 1:1 模型(NPTL),原因是 M:N 实现复杂、调试困难、性能不稳定。
Java 虚拟线程建立在现代 OS 已经非常成熟的异步 I/O 基础上,站在了更高的起点,规避了当年 M:N 模型的主要问题。

  1. 调度器位置不同
Linux M:N:调度器在用户空间的线程库中(如 libpthread)
Java 虚拟线程:调度器在 JVM 内部(ForkJoinPool),且 JVM 可以感知
               所有的 I/O 操作,在恰当时机主动触发卸载

java 虚拟线程与 Linux M:N 用户线程是同一思想的不同实现。核心区别在于:Java 虚拟线程依托 JVM 对 I/O 的全面拦截和异步化改造,真正解决了阻塞穿透问题,而这正是当初 Linux M:N 模型最终失败的根本原因。

用下图可以总结:

Linux 历史模型:
N:1  用户线程A ──┐
     用户线程B ──┼──→ 内核线程1        ← 一个阻塞全部卡死
     用户线程C ──┘

M:N  用户线程A ──→ 内核线程1
     用户线程B ──→ 内核线程2          ← 理想但实现复杂,已被放弃
     用户线程C ──┘

1:1  用户线程A ──→ 内核线程A          ← 现代Linux默认,简单但线程数受限
     用户线程B ──→ 内核线程B


Java 虚拟线程(本质是改良的M:N):
     虚拟线程A ──→ 载体线程1(OS线程)
     虚拟线程B ──┘  ↑ 阻塞时自动卸载,靠JVM拦截I/O实现
     虚拟线程C ──→ 载体线程2(OS线程)
     虚拟线程D ──┘

虚拟线程背后的载体线程是什么?

虚拟线程的载体线程其实也就是平台线程(Platform Thread),平台线程对应真实的 OS 线程。

使用如下代码测试:


     private static Thread getCarrierThread(Thread virtualThread) throws Exception {
            // Java 21 中 VirtualThread 内部有 carrierThread 字段
            Class<?> vtClass = Class.forName("java.lang.VirtualThread");
            Field carrierField = vtClass.getDeclaredField("carrierThread");
            carrierField.setAccessible(true);
            return (Thread) carrierField.get(virtualThread);
     }
 
    private static void createVirtualThread() {
    
        Thread vt = Thread.ofVirtual().start(() -> {
            try {
                Thread carrier = getCarrierThread(Thread.currentThread());
                System.out.println("虚拟线程: " + Thread.currentThread());
                System.out.println("载体线程: " + carrier);
            } catch (Exception e) {
                e.printStackTrace();
            }
    });

通过main方法启动执行createVirtualThread,vm参数中增加--add-opens java.base/java.lang=ALL-UNNAMED,控制台输出日志如下:

虚拟线程: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
载体线程: Thread[#22,ForkJoinPool-1-worker-1,5,CarrierThreads]

可以看出载体线程实际上是ForkJoinPool-1-worker-线程池中的线程1,疯狂创建100个虚拟线程并打印:


for (int i = 0; i < 100; i++) {
    int finalI = i;
    Thread vt2 = Thread.ofVirtual().start(() -> {
        try {
            Thread carrier = getCarrierThread(Thread.currentThread());
            System.out.println("虚拟线程 " + finalI + " : " + Thread.currentThread());
            System.out.println("载体线程 " + finalI + " : " + carrier);
        } catch (Exception e) {
            e.printStackTrace();
        }
    });
}

输出结果如下:

虚拟线程 0 : VirtualThread[#27]/runnable@ForkJoinPool-1-worker-2
载体线程 0 : Thread[#24,ForkJoinPool-1-worker-2,5,CarrierThreads]
虚拟线程 2 : VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3
载体线程 2 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
虚拟线程 6 : VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3
虚拟线程 7 : VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3
载体线程 7 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
虚拟线程 8 : VirtualThread[#35]/runnable@ForkJoinPool-1-worker-3
载体线程 8 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
虚拟线程 9 : VirtualThread[#36]/runnable@ForkJoinPool-1-worker-3
载体线程 9 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
虚拟线程 10 : VirtualThread[#37]/runnable@ForkJoinPool-1-worker-3
载体线程 10 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
虚拟线程 11 : VirtualThread[#38]/runnable@ForkJoinPool-1-worker-3
载体线程 11 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]
...

可以确认虚拟线程默认使用的就是ForkJoinPool的一个线程池实例中的创建的线程。

对java stream 并行流比较熟悉的同学会意识到:这会与stream并行流所使用的线程池有线程复用吗?

答案是不会,实际是两个ForkJoinPool实例。

如下图所示,stream并行流使用的线程池是ForkJoinPool.commonPool-worker-

并行流ForkJoinPool线程

使用虚拟线程应该注意什么?

虚拟线程解决的是 I/O 等待造成的线程资源浪费问题,让你用简单的同步代码写出高并发程序。

什么情况下适合使用虚拟线程

核心判断标准:任务是 I/O 密集型(线程大量时间在等待)

  • 高并发 Web 服务:每个 HTTP 请求一个虚拟线程,支撑万级并发
  • 数据库访问:大量 JDBC 查询等待数据库响应
  • 微服务间 HTTP 调用:调用下游服务时的网络等待
  • 文件 I/O 操作:读写磁盘的等待时间
  • 消息队列消费:等待消息的长轮询
  • 需要从异步代码迁回同步代码:将 reactive 代码简化重写

简单说:线程的大部分时间在 wait / 阻塞,而不是在跑 CPU,就适合用虚拟线程。

什么情况下不适合使用虚拟线程

  1. CPU 密集型任务
    如图像处理、加密计算、机器学习推理、大量数学计算。虚拟线程不会让 CPU 跑得更快,瓶颈是 CPU 核心数,不是线程数。这种场景用 ForkJoinPool 或固定大小线程池反而更合适。
  2. 线程数量本来就很少的场景
    如果你的应用本来只需要几十个线程,传统线程完全够用,引入虚拟线程没有收益。
  3. 存在 synchronized 持有锁时发生阻塞(Pinning 问题)

// ❌ 危险:synchronized 块内有 I/O 阻塞 → pinning
synchronized (lock) {
    result = jdbcConnection.query(...); // 虚拟线程被 pin 住
}

// ✅ 改用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    result = jdbcConnection.query(...); // 虚拟线程可以正常卸载
} finally {
    lock.unlock();
}

这是虚拟线程目前最重要的陷阱。当虚拟线程在 synchronized 块内部发生阻塞,它无法从 carrier thread 卸载(称为 pinning,jdk24中已经解决),退化成平台线程的行为,甚至可能造成 carrier thread 耗尽。

posted @ 2026-03-02 11:36  DeepSky丶  阅读(91)  评论(2)    收藏  举报