一文搞懂 Java 虚拟线程:让阻塞不再昂贵
JDK 21 正式发布虚拟线程(Virtual Threads,JEP 444),标志着 Java 并发模型进入新阶段。本文带你从动机、用法、原理到避坑,一次讲透。
一、为什么需要虚拟线程?
回顾 Java 并发的历史,我们会发现一条清晰的主线:Thread-per-request(每请求一线程) → 线程池 → 异步响应式 → 虚拟线程。
传统的平台线程(Platform Thread)是 1:1 映射到操作系统线程 的。这意味着:
- 每个线程默认占用约 1MB 栈内存;
- 线程上下文切换是内核态操作,成本高;
- 一旦线程阻塞在 IO 上(数据库、HTTP、Redis),这个昂贵的资源就白白闲置。
一个典型的 Web 服务,单个请求可能要经历「查数据库 → 调下游接口 → 写缓存」三次阻塞。线程池只有 200 个线程,QPS 稍高就会排队,而 CPU 利用率却不到 20%。
虚拟线程的答案很直接:把「阻塞」的成本降到几乎为零。
二、虚拟线程是什么?
虚拟线程是 由 JVM 调度、运行在少量载体线程(Carrier Thread)之上的轻量级线程,是一种 M:N 调度模型:
百万级虚拟线程 ──M:N──> 少量平台线程(载体)
- 创建成本:KB 级,百万级创建无压力;
- 阻塞时:JVM 自动把它从载体线程上「卸载」,让载体去跑别的任务;
- 恢复时:再「挂载」回某个载体继续执行。
关键点:虚拟线程没有变快,是变多了。它不是提升单线程吞吐,而是让「一请求一线程」这种简单模型重新变得可行。
三、快速上手
创建虚拟线程有三种方式:
// 1. 直接创建并启动
Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread!");
});
// 2. Builder 模式,可设置名称、异常处理
Thread.ofVirtual()
.name("worker-", 0)
.start(() -> doWork());
// 3. 并发场景推荐:每任务一虚拟线程的 Executor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 阻塞?没关系
return i;
})
);
} // close() 会等待所有任务完成
注意第 3 种写法:10 万个任务各自 sleep 1 秒,传统线程池根本不敢这么写,虚拟线程下轻描淡写。
实战:并发调用 100 个下游接口
旧写法(线程池 + 固定大小):
var executor = Executors.newFixedThreadPool(200);
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> httpClient.get(url)))
.toList();
虚拟线程写法:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> httpClient.get(url)))
.toList();
List<String> results = futures.stream()
.map(this::safeGet)
.toList();
}
业务代码保持了同步的直观写法,却拿到了接近异步框架的并发能力。这就是虚拟线程最大的价值:性能与可读性兼得。
四、原理浅探:一个任务如何「暂停又继续」
虚拟线程的实现核心在 JVM 内部,主要由三部分组成:
- Continuation(延续体):虚拟线程的执行栈被封装成 Continuation,支持「把当前栈冻结、稍后从断点恢复」;
- 调度器:默认是
ForkJoinPool的 work-stealing 池,CPU 核数个载体线程; - 阻塞点拦截:当虚拟线程执行阻塞操作(
Thread.sleep、Socket IO、ReentrantLock.lock等),JDK 把它替换为「挂起 Continuation」,栈数据从载体线程堆栈搬回堆内存。
简单说:阻塞虚拟线程时,真正的平台线程并没有被阻塞,它立刻可以去执行下一个就绪的虚拟线程。
五、避坑指南(重点)
坑 1:synchronized 导致 Pinning(钉死)
虚拟线程在 synchronized 块内发生阻塞时,无法被卸载,会一直占着载体线程,这就是「Pinning」。极端情况下,几个载体线程全被钉死,整个应用吞吐暴跌。
// 反例:synchronized 内做阻塞 IO
synchronized (lock) {
return httpClient.get(url); // 阻塞期间 carrier 被钉住
}
// 正解:改用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
return httpClient.get(url);
} finally {
lock.unlock();
}
JDK 24(JEP 491)已经改进了
synchronized的 pinning 问题,但生产环境若还在 21/22,请务必注意。
排查手段:加 JVM 参数 -Djdk.tracePinnedThreads=full,或用 JFR 事件的 jdk.VirtualThreadPinned。
坑 2:不要池化虚拟线程
线程池存在的意义是「复用昂贵资源」。虚拟线程本身很廉价,池化它不仅无意义,还会引入排队延迟。正确的限流姿势:
- 用
Semaphore控制并发数; - 或依赖下游资源池(连接池)本身作为限流器。
private final Semaphore permits = new Semaphore(100); // 最多 100 并发
permits.acquire();
try {
callDownstream();
} finally {
permits.release();
}
坑 3:ThreadLocal 的内存放大效应
百万级虚拟线程 × 每个线程一份 ThreadLocal 副本 = 内存隐患。重型对象(如缓存、可变配置)请改用共享实例或 Scoped Values(JEP 446,预览特性)。
坑 4:CPU 密集任务不适用
虚拟线程解决的是 阻塞,不是 计算。CPU 密集任务请老老实实用平台线程池(大小 ≈ CPU 核数),混在一起跑还会饿死其他虚拟线程。
六、和响应式框架怎么选?
| 维度 | WebFlux/Reactor | 虚拟线程 |
|---|---|---|
| 写法 | 链式、回调,心智负担高 | 同步直白 |
| 阻塞检测 | 天然非阻塞 | 阻塞即挂起 |
| 生态兼容 | 需要响应式驱动 | 现有同步代码基本零改动 |
| 适用场景 | 高并发流式处理 | 绝大多数 IO 密集型业务 |
我的建议:新项目如果团队心智成本敏感,虚拟线程是更平滑的路线;已经深度使用响应式的团队也不必推翻重来,两者可以共存——用虚拟线程承载同步旧代码,用 Reactor 处理流式场景。
七、总结
虚拟线程没有引入新概念 API,而是让 Java 回到了最自然的并发模型:写同步代码,获得异步的扩展性。
三条黄金法则送给大家:
- 每任务一虚拟线程,不要池化;
- 注意
synchronizedpinning,优先ReentrantLock; - 限流交给
Semaphore或资源池,CPU 任务用平台线程。
从 JDK 21 开始,别再问「线程池开多大」,而是问「这个请求为什么不能拥有自己的线程」。
- 点赞
- 收藏
- 关注作者
评论(0)