JDK 21 虚拟线程:Java 并发编程的“降维打击”

首页 编程分享 PHP丨JAVA丨OTHER 正文

sumAll 转载 编程分享 2025-12-13 22:00:31

简介 深入 Java 虚拟线程源码揭秘挂载机制,实测 43 倍性能提升。告别回调地狱,用同步代码轻松驾驭百万并发。掌握这一核心技术,彻底重构你的高并发编程范式。


前言:如果你还在为线程池参数调优头秃,或者被 CompletableFuture 的回调地狱折磨,那么 JDK 21 带来的虚拟线程(Virtual Threads)绝对是你必须掌握的“救命稻草”。它不仅是 Project Loom 的核心成果,更是 Java 诞生以来并发模型最大的一次变革。


1. 为什么我们需要虚拟线程?(The Why)

在深入技术细节之前,我们先聊聊“痛点”。

长期以来,Java 的并发模型一直是 “一请求一线程”(Thread-per-Request)。这意味着,每当有一个新的 HTTP 请求进来,Web 服务器(如 Tomcat)就会分配一个独立的线程去处理。

痛点一:昂贵的平台线程

在 JDK 21 之前,Java 的 Thread 直接映射到操作系统的内核线程(Platform Thread)。

  • 内存贵:一个平台线程默认占用约 1MB - 2MB 的栈内存。
  • 切换慢:线程上下文切换涉及内核态与用户态的转换,开销巨大。
  • 数量受限:你很难在一台普通服务器上启动几万个线程,机器会直接卡死。

痛点二:异步编程的“妥协”

为了解决高并发问题,我们被迫引入了 NIO、Netty、WebFlux 等异步框架。虽然吞吐量上去了,但代价是惨痛的:

  • 代码难懂:逻辑被拆得支离破碎,回调地狱(Callback Hell)随处可见。
  • 调试噩梦:一旦报错,堆栈追踪(Stack Trace)里全是框架代码,根本找不到业务逻辑的源头。

JDK 21 虚拟线程的出现,就是为了打破这个僵局:

它允许你用最熟悉的“同步阻塞”代码风格,写出媲美异步框架的“高并发性能”。


2. 什么是虚拟线程?(The What)

虚拟线程(Virtual Threads) 是一种由 JVM 自身管理的轻量级线程,它不再与操作系统的内核线程 1:1 绑定。

我们可以做一个简单的对比:

特性 平台线程 (Platform Thread) 虚拟线程 (Virtual Thread)
管理者 操作系统内核 JVM 虚拟机
映射关系 1:1 (一个 Java 线程对应一个 OS 线程) M:N (大量虚拟线程复用少量 OS 线程)
创建成本 昂贵 (MB 级内存,系统调用) 极低 (几百字节,普通 Java 对象)
数量上限 几千个 几百万个 (仅受堆内存限制)

简单来说,虚拟线程就像是 Java 中的“协程”(Go 语言的 Goroutine),但它完全融入了现有的 java.lang.Thread API,老代码几乎不用改就能享受红利。


3. 核心原理:它是如何工作的?(Under the Hood)

这是理解虚拟线程最关键的部分。为了讲清楚,我们用一个"出租车模型"来打比方。

  • 出租车公司(操作系统 OS):只有 10 辆出租车(这是你的 CPU 核心数/载体线程 Carrier Threads)。
  • 乘客(任务 Task):有 10,000 名乘客(这是你的并发请求)。

传统模式(平台线程)

每辆车一次只能拉一个乘客。如果乘客半路说:“师傅,我要去取个快递(数据库查询/网络请求),你等我 5 分钟。” 这时候,出租车就真的停在路边死等(线程阻塞)。因为车只有 10 辆,一旦都在等人,后面 9990 个乘客只能排队喝西北风。这就是为什么传统线程池并发上不去。

虚拟线程模式

在 JDK 21 中,情况变了:

  1. 挂载(Mount):乘客坐上出租车,开始计算任务。
  2. 卸载(Unmount):当乘客需要等红灯或取快递(遇到阻塞 I/O,如 Thread.sleep 或 DB 查询)时,JVM 会立刻让乘客下车,把他的状态(Continuation)存在路边。
  3. 复用:出租车(载体线程)立刻去拉下一个乘客,一刻也不闲着。
  4. 恢复:当原来的乘客快递取好了(I/O 完成),他会重新排队,等任意一辆空闲的出租车过来,带上之前的状态继续走。

结果:仅用 10 辆车,就让 10,000 个乘客感觉自己都在“同时”前进。CPU 利用率被榨干到了极致。

源码视角:JVM 是如何实现“魔法”的?

为了更深入地理解,我们稍微看一眼 JDK 的底层实现(基于 JDK 21 OpenJDK 源码)。

1. 虚拟线程的数据结构 虚拟线程在源码中对应的类是 java.lang.VirtualThread。它不再像平台线程那样持有巨大的栈内存,而是持有一个轻量级的 Continuation(续体) 对象。

// java.lang.VirtualThread 源码简略版
final class VirtualThread extends BaseVirtualThread {
    // 调度器,默认是 ForkJoinPool
    private final Executor scheduler;
    // 续体,用于保存和恢复栈状态
    private final Continuation cont;
    // 实际要执行的任务
    private final Runnable runContinuation;
    
    // ...
}

2. Mount(挂载) 当你的虚拟线程需要执行 CPU 运算时,JVM 会把它“挂载”到一个载体线程(Carrier Thread)上。这个载体线程本质上就是一个 ForkJoinPool 中的 Worker 线程。

以下是 JDK 21 中 mount() 方法的核心逻辑:

// VirtualThread.java
private void mount() {
    // 1. 绑定:获取当前的平台线程(Carrier),并建立关联
    Thread carrier = Thread.currentCarrierThread(); 
    setCarrierThread(carrier); 

    // 2. 同步状态:处理中断状态(Interrupt Status)
    // 必须确保载体线程的中断状态与虚拟线程一致
    // 如果虚拟线程被中断了,载体线程也必须标记为中断,以便 IO 操作能感知到
    if (interrupted) { 
        carrier.setInterrupt(); 
    } else if (carrier.isInterrupted()) { 
        // 反之,清除载体线程可能残留的“脏”中断状态
        synchronized (interruptLock) { 
            if (!interrupted) carrier.clearInterrupt(); 
        } 
    } 

    // 3. 偷天换日:修改当前线程的身份
    // 这行代码执行后,Thread.currentThread() 返回的不再是载体线程,而是当前这个虚拟线程
    // 这是虚拟线程兼容老代码(如 Log4j, ThreadLocal)的关键
    carrier.setCurrentThread(this); 
}

3. Unmount(卸载)与 Yield 当你的代码调用了 Thread.sleep() 或者进行网络 I/O 时,底层会调用 VirtualThread.park()

这是整个虚拟线程调度的核心枢纽,我们来看看它究竟做了什么:

// VirtualThread.java 源码核心逻辑简化版
void park() {
    // 1. 检查许可(Permit):如果当前线程已经有了“通行证”(比如刚被 unpark),则无需挂起,直接返回继续跑
    if (getAndSetParkPermit(false) || interrupted)
        return;

    // 2. 尝试挂起(Yield)
    setState(PARKING); // 标记状态为 PARKING
    try {
        // 关键调用:尝试交出控制权
        // 如果成功(返回 true),说明虚拟线程已经成功卸载(Unmount)并暂停了
        // 如果失败(返回 false),通常是因为被 Pinning(钉住)了
        yielded = yieldContinuation(); 
    } finally {
        // 如果没挂起成功,把状态改回 RUNNING
        if (!yielded) { 
            setState(RUNNING); 
        }
    }

    // 3. 处理 Pinning(钉住)的情况
    // 如果 yieldContinuation() 返回 false,说明没能从 Carrier Thread 上下来
    // 这时候只能无奈地阻塞底层的 Carrier Thread(这正是我们极力想避免的)
    if (!yielded) { 
        parkOnCarrierThread(false, 0); 
    }
}

紧接着,我们深入 yieldContinuation(),这才是魔法真正发生的地方:

private boolean yieldContinuation() {
    // 1. Unmount:解绑载体线程,清理中断状态
    // (这步操作其实隐含在 Continuation.yield 的前后逻辑中,或者是通过 JVMTI 通知触发)
    notifyJvmtiUnmount(/*hide*/true); 
    try {
        // 2. 真正的魔法:调用 Continuation.yield
        // 这行代码会“冻结”当前栈帧,把堆栈信息保存到堆内存中
        // 然后,执行流会“跳回”到 Carrier Thread 的 run() 方法中,去执行下一个任务
        return Continuation.yield(VTHREAD_SCOPE); 
    } finally {
        // 3. 恢复:当这个虚拟线程被唤醒(unpark)并重新 Mount 后,代码会从这里继续执行
        notifyJvmtiMount(/*hide*/false); 
    }
}

通过这段源码,我们清晰地看到了 Pinning(钉住) 是如何发生的:当 yieldContinuation() 失败(通常是因为当前栈帧中有 synchronizednative 方法)时,代码会回退到 parkOnCarrierThread(),导致底层平台线程被阻塞。

4. 恢复执行 当 I/O 操作完成(例如网卡收到了数据),操作系统会通知 JVM。调度器(Scheduler)会再次把这个挂起的虚拟线程放入执行队列,等待空闲的载体线程将它“复活”(Mount 并恢复栈帧),从上次暂停的地方继续运行。

5. 实战调用链揭秘 为了彻底解开疑惑,我们来看看最常用的 Thread.sleep 和网络 I/O 到底发生了什么。

场景 A:Thread.sleep(1000)

  1. JDK 21 修改了 Thread.sleep:它会检查当前线程是否是虚拟线程。
  2. 如果是,调用 VirtualThread.sleepNanos(nanos)
  3. 分支处理
    • 情况 1:nanos == 0 (即 Thread.sleep(0))
      • 直接调用 tryYield()
      • 状态标记为 YIELDING
      • 执行 yieldContinuation() 尝试让出 CPU。这是一种“礼让”行为,允许调度器立刻切换到其他等待的虚拟线程。
    • 情况 2:nanos > 0 (即 Thread.sleep(1000))
      • 调用 parkNanos(nanos)
      • 注册唤醒任务Future unparker = scheduleUnpark(nanos)。JVM 会在内部的调度队列中注册一个 1 秒后的定时事件。
      • 状态标记setState(TIMED_PARKING)
      • 尝试挂起:调用 yieldContinuation()
        • 成功:虚拟线程卸载,ForkJoinPool Worker 线程释放去干别的事。
        • 失败(Pinning):如果被钉住,则执行 parkOnCarrierThread(true, remainingNanos),导致载体线程在 OS 层面阻塞等待剩余时间。
  4. 唤醒:定时器触发或礼让结束后,调度器将该虚拟线程 unpark,它重新排队等待执行。

场景 B:网络 I/O (如 socket.read())

  1. JDK 21 重写了 NioSocketImpl 等底层类。
  2. 当调用 read() 时,如果底层 Socket 缓冲区为空(数据未到)。
  3. 注册 Poller:将当前 Socket 的文件描述符(FD)注册到 JVM 全局的 Poller(Linux 上对应 epoll,Mac 上对应 kqueue)。
  4. 调用 park():虚拟线程卸载。
  5. 等待事件:当网卡收到数据,OS 通知 Poller。
  6. 唤醒:Poller 找到对应的虚拟线程,将其 unpark,虚拟线程恢复执行并成功读取数据。

这正是“同步代码,异步执行”的本质!


4. 实战:如何使用?(Code Examples)

在 JDK 21 中,使用虚拟线程非常简单。

4.1 创建虚拟线程

不需要引入任何第三方包,标准库原生支持:

// 方式 1: 直接启动一个虚拟线程
Thread.startVirtualThread(() -> {
    System.out.println("Hello from Virtual Thread: " + Thread.currentThread());
});

// 方式 2: 使用 Builder 构建更详细的配置(如名称)
Thread vThread = Thread.ofVirtual()
        .name("my-vthread")
        .start(() -> {
            // 业务逻辑
        });

4.2 替代线程池(重点)

这是最大的思维转变。过去我们用 Executors.newFixedThreadPool(200) 来限制线程数量。 现在,请忘掉线程池! 虚拟线程是用完即毁的,不需要池化。

JDK 21 提供了新的执行器:

// 使用 try-with-resources 自动关闭结构
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            // 模拟耗时 I/O 操作,例如调用第三方 API
            Thread.sleep(Duration.ofSeconds(1)); 
            return i;
        });
    });
} 
// 代码运行到这里,说明 10,000 个任务全部执行完毕。
// 在传统线程池下,这可能需要几十秒;而在虚拟线程下,耗时仅约 1 秒多一点。

5. 性能实验:用数据说话(Benchmarks)

光说不练假把式。我们设计一个简单的基准测试,直观感受虚拟线程在高并发 I/O 密集型场景下的威力。

实验目标

模拟 10,000 个并发任务,每个任务“假装”执行 1 秒钟的 I/O 操作(使用 Thread.sleep(1000) 模拟)。

实验代码

你可以直接复制这段代码在本地运行(需 JDK 21+)。

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadBenchmark {
    public static void main(String[] args) {
        int taskCount = 10_000;
        
        System.out.println("=== 开始基准测试 ===");
        System.out.println("任务数量: " + taskCount);

        // 1. 测试平台线程(模拟传统线程池)
        // 为了公平,我们给平台线程池一个相对较大的核心数,比如 200
        long startPlatform = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(200)) {
            IntStream.range(0, taskCount).forEach(i -> {
                executor.submit(() -> {
                    try {
                        Thread.sleep(Duration.ofSeconds(1));
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                });
            });
        } // try-with-resources 会自动等待所有任务完成
        long endPlatform = System.currentTimeMillis();
        System.out.println("平台线程池耗时: " + (endPlatform - startPlatform) + " ms");

        // 手动 GC 一次,尽量减少对下一轮的影响
        System.gc();
        try { Thread.sleep(1000); } catch (Exception e) {}

        // 2. 测试虚拟线程
        long startVirtual = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, taskCount).forEach(i -> {
                executor.submit(() -> {
                    try {
                        Thread.sleep(Duration.ofSeconds(1));
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                });
            });
        }
        long endVirtual = System.currentTimeMillis();
        System.out.println("虚拟线程耗时:   " + (endVirtual - startVirtual) + " ms");
    }

}

实验结果分析(参考数据)

在普通的 8 核笔记本上运行,结果通常如下:

指标 平台线程池 (Fixed-200) 虚拟线程 (Virtual Threads) 提升倍数
总耗时 ~50,601 ms ~1,149 ms ~43 倍
内存占用 较高(每个线程栈独立占用 OS 内存) 极低(几乎只增加堆内存,且很少) 数量级差异
原理分析 线程池只有 200 个线程,1 万个任务只能排队执行。
50 轮 x 1秒 = 50 秒。
1 万个虚拟线程几乎同时启动,同时挂起。
底层 Carrier 线程不断复用,几乎没有排队等待。
碾压级优势

6. 性能对比与适用场景

是不是所有场景都应该无脑上虚拟线程?并不是。

适用场景:I/O 密集型 (I/O Bound)

这是虚拟线程的主场。

  • 典型场景:Web 服务器、微服务网关、大量数据库调用、RPC 调用。
  • 效果吞吐量(Throughput) 将呈指数级提升。原本能抗 1000 QPS 的服务,可能轻松抗住 10000 QPS。

不适用场景:CPU 密集型 (CPU Bound)

  • 典型场景:视频转码、复杂的加密解密、科学计算。
  • 原因:CPU 只有那么多核心。如果任务全是计算,没有任何等待时间,切换虚拟线程只会增加额外的调度开销,反而可能变慢。

误区提示:虚拟线程不会降低单个请求的延迟(Latency)。它解决的是“由于线程不够用导致的排队等待”,提升的是系统的并发容量


6. 避坑指南(The Gotchas)

作为架构师,在落地虚拟线程时,必须警惕以下两个坑:

坑一:Pinning(线程钉住)

当虚拟线程在执行以下代码时,无法从载体线程上卸载(Unmount),导致它退化成普通的阻塞线程:

  1. synchronized 块或方法内部。
  2. 执行 native 方法或外部函数接口(FFI)时。

反例(不要这么写):

synchronized (lock) {
    // 这里的 sleep 会导致底层的 OS 线程也被阻塞,无法服务其他虚拟线程
    Thread.sleep(1000); 
}

解决方案:将 synchronized 替换为 ReentrantLock。JDK 核心库(如 ConcurrentHashMap)内部已经完成了这种改造。

lock.lock();
try {
    Thread.sleep(1000); // 此时虚拟线程可以正常卸载,释放 OS 线程
} finally {
    lock.unlock();
}

坑二:ThreadLocal 膨胀

以前线程池只有 200 个线程,ThreadLocal 里的缓存数据不会占用太多内存。 现在虚拟线程可能有 100 万个,如果每个线程都存 1MB 数据,内存瞬间爆炸。 建议:尽量减少 ThreadLocal 的使用,或者使用 JDK 21 预览特性 Scoped Values(范围值)作为替代。


7. 总结与展望

JDK 21 虚拟线程的正式发布,标志着 Java 并发编程进入了一个全新的“降维打击”时代。

核心结论:

  1. JDK 21 正式特性:虚拟线程不再是预览版(Preview),而是正式版(Finalized),已具备生产环境落地的能力。
  2. 适用场景明确:它不是万能药,仅适用于 I/O 密集型任务(Web 服务、RPC 调用)。对于 CPU 密集型任务,传统平台线程依然是王者。
  3. 编程范式回归:我们不再需要为了性能去写晦涩难懂的异步回调代码。“同步的代码结构,异步的执行性能”,这才是虚拟线程带给开发者的最大红利。

展望未来: 虽然虚拟线程已经由 JEP 444 定稿,但与其黄金搭档——结构化并发(Structured Concurrency)(目前在 JDK 21 仍处于预览阶段)的配合才是完全体。随着未来 JDK 版本的迭代,Java 在高并发领域的统治力将不可撼动。

现在,是时候升级你的 JDK,去重构那些陈旧的线程池代码了!

转载链接:https://juejin.cn/post/7582073642869473295


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云