Java 21 虚拟线程:高并发编程范式变革

Java 21 虚拟线程(Virtual Threads,源自 Project Loom)是近年来 JVM 并发模型最大的一次范式变革。过去写高并发服务,线程数受限于操作系统平台线程,动辄要为几百并发维护一个重量级线程池;虚拟线程把「线程」变成由 JVM 调度的轻量对象,用 M:N 调度让单机承载百万级并发成为可能。本文从原理到落地,帮你把线程池平滑迁移到虚拟线程。

一、为什么需要虚拟线程

传统 Java 并发建立在平台线程(Platform Thread)之上:一个 Java 线程直接映射到一个操作系统内核线程,创建成本约 1MB 栈内存,且上下文切换由内核完成。当并发请求数上升到几千、上万时,线程池要么被占满导致请求排队,要么无限膨胀拖垮内存。结果是开发者被迫用「异步回调 / Reactive」把代码拆得支离破碎,只为省线程。

虚拟线程的出现,让「一个请求一个线程」的直观写法重新变得可行——而且成本极低。它不改变你熟悉的 Thread、Runnable、Executor API,却把底层调度交给了 JVM,从而彻底解耦「并发度」与「内核线程数」。

二、虚拟线程 vs 平台线程:核心差异

2.1 调度模型:M:N 而非 1:1

平台线程是 1:1 映射内核线程;虚拟线程是 M:N 调度:大量虚拟线程挂载在少量「载体线程(Carrier Thread,本质是平台线程)」上。当虚拟线程遇到 I/O 阻塞(如网络读写、数据库连接),JVM 会自动把它从载体线程上「卸载」,让载体去跑别的虚拟线程;I/O 就绪后再挂载回来。正因为阻塞不再占用内核线程,吞吐量随 I/O 等待时间近似线性提升。

2.2 内存与创建成本

平台线程默认栈约 1MB,虚拟线程初始栈仅几百字节,并按需增长。这意味着你可以为每个任务新建一个虚拟线程,而不必复用线程池——「池化」这一在平台线程时代必须的性能技巧,在虚拟线程下反而成了反模式。

维度平台线程虚拟线程
与内核线程映射1:1M:N(多对少载体线程)
初始内存约 1 MB/线程几百字节/线程
默认并发上限数千级百万级
阻塞 I/O 时占用内核线程自动卸载,释放载体
典型用法线程池复用随用随建,无需池化

三、快速上手:从创建到 Executor

虚拟线程的 API 刻意保持和老线程一致。最简单的创建方式是 Thread.startVirtualThread

// Java 21:随用随建一个虚拟线程
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("running in " + Thread.currentThread());
});
vt.join(); // 等待结束

如果你有一批任务要并发执行,用 Executors.newVirtualThreadPerTaskExecutor() 替代原来的固定大小线程池即可。注意它没有线程数上限——这正是虚拟线程的设计意图,不要再给它套一个容量上限。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> fetch(url)))
        .toList();
    for (Future<String> f : futures) {
        System.out.println(f.get()); // 阻塞的是虚拟线程,不影响载体线程
    }
}

四、实战:高并发聚合多个下游接口

一个典型场景:一个接口要并行调用商品、库存、价格三个下游服务再聚合。用虚拟线程写,逻辑是直白的「顺序 + 并发 submit」,可读性远好于 CompletableFuture 的回调链:

public AggregatedResult aggregate(Long skuId) throws Exception {
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        var product = exec.submit(() -> productClient.get(skuId));
        var stock   = exec.submit(() -> stockClient.get(skuId));
        var price   = exec.submit(() -> priceClient.get(skuId));
        return new AggregatedResult(product.get(), stock.get(), price.get());
    }
}

这里每个 get() 阻塞的都是虚拟线程本身,而底层载体线程会被 JVM 拿去执行别的就绪虚拟线程。即使下游偶发慢调用,也不会像传统线程池那样把整个池堵死。若你的服务跑在 Spring Boot 3 上,可结合我们整理的Spring Boot 3 升级踩坑实录把 JDK 一并升到 21,再切换并发模型。

五、避坑清单:别让虚拟线程「被钉住」

虚拟线程最大的隐性陷阱是 Pinning(钉住):当虚拟线程在 synchronized 块内发生阻塞 I/O 时,它无法被卸载,会一直占用载体线程,退化为「平台线程式」阻塞,失去虚拟线程的优势。

// 反例:synchronized 内阻塞 I/O 会导致 Pinning
synchronized (lock) {              // 钉住区间
    String r = httpClient.get(url); // 阻塞 I/O 无法卸载
}

// 正例:改用 ReentrantLock,阻塞点在锁外
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区只放纯内存操作
} finally {
    lock.unlock();
}
String r = httpClient.get(url); // I/O 在锁外,可正常卸载

可用 -Djdk.tracePinnedThreads=full 启动参数在钉住发生时打印栈,定位需要改造的 synchronized 段。多数情况下换成 ReentrantLock 即可解决。

适合虚拟线程不适合 / 需谨慎
大量 I/O 密集型任务(HTTP、DB、RPC)CPU 密集型长计算(无 I/O 让出)
请求级「一个任务一线程」模型需要固定平台线程的本地变量(ThreadLocal 滥用)
替代回调式 / Reactive 编排synchronized 临界区内含阻塞 I/O(会 Pinning)

六、迁移策略:从线程池到虚拟线程

对存量服务,最安全的迁移是「先替换 99% 的 Executors.newFixedThreadPoolnewVirtualThreadPerTaskExecutor」,而保留少量确实需要平台线程的专用池(如监听信号、处理 CPU 密集批任务)。在 CI 中配合类型与测试校验,可参考我们的GitHub Actions 实战把 JDK 版本门禁一并纳管。

// 旧:固定线程池(并发受内核线程约束)
ExecutorService pool = Executors.newFixedThreadPool(200);

// 新:虚拟线程执行器(百万级并发,几乎无感)
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

若是 Spring Boot 应用,还可通过 Tomcat 的虚拟线程连接器(需 Spring Boot 3.2+、JDK 21)让每个 HTTP 请求都跑在虚拟线程上,无需改动业务代码即可获得吞吐收益。这与在 K8s 上做弹性扩缩(见K8s 入门:Pod/Deployment/Service)是互补的两层优化。

七、性能基准与适用边界

在「10k 并发 HTTP 聚合」的实测中,虚拟线程版的尾延迟(p99)与吞吐显著优于 200 固定线程池——因为后者在并发超过池容量时开始排队。但也要清醒:虚拟线程不提升单核算力,对纯 CPU 密集任务没有帮助,甚至可能因为调度开销略降。它解决的是「因等待 I/O 而浪费线程」的问题。

场景固定线程池(200)虚拟线程
并发 < 池容量持平持平
并发远超池容量排队、尾延迟飙升吞吐稳定、尾延迟低
纯 CPU 密集持平略降(调度开销)

八、总结

Java 21 虚拟线程不是银弹,却是并发编程的范式回归:它让「一个请求一个线程」的直观模型重新高效,把开发者从线程池调参与 Reactive 回调地狱中解放出来。落地三步走——升 JDK 21、把 I/O 阻塞点从 synchronized 迁到 ReentrantLock、用 newVirtualThreadPerTaskExecutor 替换固定池,再用 Pinning 追踪参数兜底。对绝大多数 I/O 密集型 Java 服务,这是一次值得立即做的低成本升级。关于工程里「该不该为新技术买单」的权衡,也可以读读我们写的技术债与工程决策

上一篇 PostgreSQL 备份与 PITR 时间点恢复实战
下一篇 Git rebase 与 cherry-pick 实战踩坑