Java 虚拟线程与并发新特性实战

Java 21 正式发布的虚拟线程(Virtual Thread,Project Loom)是 Java 并发模型近二十年来最大的变革。它让你用同步写法的代码获得异步的吞吐量,不再需要纠结线程池大小、不再被回调地狱折磨。本文从虚拟线程的核心原理讲起,对比传统平台线程,给出生产级迁移路径与避坑指南。

Java 虚拟线程配图:蓝色渐变背景上多条细流从顶部汇合成少股宽流,波形展现流动分布,扁平科技插画
虚拟线程:成千上万个轻量级用户线程挂载在少量平台线程上运行

一、平台线程 vs 虚拟线程

传统 Java 的 Thread 是平台线程(Platform Thread),它直接映射到操作系统内核线程。创建一个平台线程大约消耗 1MB 栈内存,切换开销也不小。所以实际开发中我们用线程池复用线程,池大小通常设为 CPU 核数的 1 到几倍。

虚拟线程完全不同:它是 JVM 管理的轻量级线程,不映射到操作系统线程。成千上万个虚拟线程可以跑在少量平台线程(称为载体线程)上,由 JVM 负责调度。你可以把它理解为用户态协程,但写代码时感觉就像普通线程一样。

对比维度平台线程虚拟线程
内存开销约 1MB 栈几十到几百字节
数量上限几百到几千几百万
调度方式操作系统内核调度JVM 用户态调度
编程模型线程池复用每个任务一个新线程
适合场景CPU 密集型I/O 密集型

二、第一个虚拟线程程序

启动一个虚拟线程就像启动普通线程一样简单:

public class VirtualThreadDemo {
    public static void main(String[] args) throws Exception {
        // 启动一个虚拟线程
        Thread.startVirtualThread(() -> {
            System.out.println("虚拟线程运行在:" + Thread.currentThread());
        });

        // 创建 10000 个虚拟线程
        var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor();
        for (int i = 0; i < 10_000; i++) {
            final int id = i;
            executor.submit(() -> {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                if (id % 1000 == 0) {
                    System.out.println("任务 " + id + " 完成");
                }
            });
        }
        executor.close(); // 等待所有任务完成
    }
}

注意 newVirtualThreadPerTaskExecutor():每个任务都创建一个新的虚拟线程,而不是复用线程池。这听起来浪费,但虚拟线程创建成本极低,完全可以这样做。

关键区别:虚拟线程不要池化。传统线程池的目的是复用昂贵的平台线程,而虚拟线程本身极轻量,每次新建一个反而更简单、更不容易出 bug。

三、为什么虚拟线程能提升吞吐量

虚拟线程的核心优势在于 I/O 等待时不占载体线程。当一个虚拟线程执行到 Thread.sleep() 或 socket 读写时,JVM 会自动把它从载体线程上卸载(unmount),让载体线程去运行其他虚拟线程。等 I/O 就绪后,JVM 再把虚拟线程挂回某个载体线程继续执行。

这意味着:在等待数据库响应的那 100 毫秒里,你的服务器没有闲着,而是在处理其他请求。传统线程池在这种情况下,工作线程会被阻塞住,新请求只能排队。

// 传统线程池:阻塞时线程被占住
ExecutorService pool = Executors.newFixedThreadPool(200);
// 200 个请求同时查数据库,第 201 个开始排队

// 虚拟线程:每个请求一个虚拟线程,阻塞时不占载体
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> queryDatabase()); // 同时跑一万个,不排队
    }
}

四、生产迁移策略

把现有 Spring Boot 应用迁移到虚拟线程非常简单,只需要改一个配置:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

这一行配置会让 Spring Boot 的 Tomcat 服务器、@Async 方法、定时任务全部改用虚拟线程。你的业务代码完全不用改——因为虚拟线程的 API 和普通线程一模一样。

但有几个坑需要注意:

坑一:synchronized 块会钉住载体线程。当虚拟线程在 synchronized 块里执行 I/O 操作时,JVM 暂时无法把它卸载,因为同步监视器是和载体线程绑定的。在高频 I/O 场景下,建议改用 ReentrantLock。

// 不太推荐:synchronized 里做 I/O
public synchronized String bad() throws Exception {
    Thread.sleep(100); // 这 100ms 载体线程被钉住
    return "result";
}

// 推荐:ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public String good() throws Exception {
    lock.lock();
    try {
        Thread.sleep(100);
        return "result";
    } finally {
        lock.unlock();
    }
}

坑二:不要突然创建几十万个虚拟线程做 CPU 密集型任务。虚拟线程解决的是 I/O 等待问题。如果任务是纯计算,虚拟线程不会比平台线程更快,反而增加调度开销。

五、结构化并发

Java 21 同时引入了结构化并发(Structured Concurrency,预览特性)。它的核心思想是:把一组相关的并发任务当作一个工作单元来管理,要么全部成功,要么一起取消。

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> fetchUser());
    Future<Order> order = scope.fork(() -> fetchOrder());
    scope.join();           // 等待所有任务
    scope.throwIfFailed();   // 任何一个失败就抛异常
    System.out.println(user.resultNow() + order.resultNow());
}

相比传统的 CompletableFuture 链式调用,结构化并发的代码更直观,错误处理也更统一。

六、性能监控与诊断

虚拟线程数量动辄几万,传统的线程 dump 会输出海量信息。JDK 提供了专门的诊断工具:

# 查看虚拟线程统计
jcmd <pid> Thread.dump_to_file -virtual=true threads.txt

# JDK 21+ 的内置指标
# 可以通过 JConsole 或 Micrometer 查看 virtual.threads.count

建议在生产环境监控三个指标:活跃虚拟线程数、载体线程数、虚拟线程被卸载后重新挂载的频率。如果载体线程数接近 CPU 核数的几倍,说明你的任务偏 CPU 密集,可能不适合全量虚拟线程。

七、总结

虚拟线程是 Java 并发编程的范式转移:不再需要精心调优线程池大小,不再需要用响应式框架的复杂 API,用最朴素的同步写法就能获得高吞吐。对于 I/O 密集型的 Web 应用、微服务后端、数据抓取任务,迁移到虚拟线程是提升性能和简化代码的双赢选择。记住三条原则:虚拟线程不要池化、synchronized 里避免 I/O、CPU 密集型任务仍然用平台线程。