虚拟线程(Virtual Threads)大概是 Java 21 里讨论度最高的特性。宣传语很诱人:几行改动,吞吐量翻倍。
它确实很强,但"免费午餐"这个说法有误导性——它免费的是代码复杂度,不是资源。这篇文章讲清楚三件事:它解决了什么、什么时候没用、上线前要检查什么。
它解决什么问题
传统 Java 的线程是平台线程,一个 Java 线程对应一个操作系统线程。代价很实在:
| 项目 | 数量级 |
|---|---|
| 单线程栈内存 | 默认 1MB(Xss) |
| 上下文切换成本 | 微秒级,需陷入内核 |
| 单机可承载线程数 | 数千 |
于是 Web 服务遇到一个尴尬的局面:一个请求 95% 的时间在等数据库、等 Redis、等 HTTP 调用,线程却一直被占着。
// 一个典型的阻塞式接口
@GetMapping("/order/{id}")
public OrderDTO detail(@PathVariable Long id) {
Order order = orderMapper.selectById(id); // 等 DB
User user = userClient.get(order.userId()); // 等 HTTP
return assemble(order, user); // CPU 只用了几毫秒
}面对这个问题以前只有两条路:
- 加大线程池——内存和上下文切换扛不住
- 改响应式(WebFlux / CompletableFuture)——能扛,但代码变成回调地狱,调试和栈信息都很痛苦
虚拟线程给了第三条路:保持上面这种直白的阻塞式写法,但让阻塞几乎不占资源。因为它在阻塞时会自动卸载(unmount),底层的载体线程可以去跑别的虚拟线程。
请求 A 阻塞在 DB ──卸载──> 载体线程去执行 请求 B
DB 返回结果 ──挂载──> 请求 A 继续执行用法
创建
// 方式一:直接启动
Thread.startVirtualThread(() -> handle(req));
// 方式二:手动控制
Thread vt = Thread.ofVirtual().name("order-", 0).unstarted(() -> handle(req));
vt.start();
// 方式三:推荐,用 Executor(自动实现 AutoCloseable)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> handle(requests.get(i)))
);
} // close() 会等待所有任务结束在 Spring Boot 中开启
application.yml 一行:
spring:
threads:
virtual:
enabled: true这样 Tomcat 的请求处理线程就变成了虚拟线程。到此为止,业务代码一个字都不用改——这是它最大的价值。
别再池化虚拟线程
虚拟线程创建成本极低(几百字节),不要用固定大小的线程池去限制它。需要限流请用 Semaphore,那才是语义正确的做法。
不适合虚拟线程的场景
1. CPU 密集型任务
虚拟线程只在阻塞时才有收益。纯计算任务挂着不放,反而多一层调度开销。
// ❌ 虚拟线程在这里毫无帮助
executor.submit(() -> heavyImageProcessing(bytes));这类任务应该用 newFixedThreadPool(Runtime.getRuntime().availableProcessors())。
2. synchronized 导致的线程固定(pinning)
这是一个真实的坑:在 synchronized 块中发生阻塞时,虚拟线程会被钉在载体线程上无法卸载,阻塞了底层线程。
synchronized (lock) {
db.query(); // ⚠️ 阻塞会导致 pinning,吞吐量直接退化
}解法很简单——换成 ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
db.query(); // ✅ 不会 pinning
} finally {
lock.unlock();
}怎么发现 pinning
启动时加 -Djdk.tracePinnedThreads=short,发生 pinning 时会打印完整栈。改完代码后务必再跑一次确认清零。
3. ThreadLocal 泛滥
虚拟线程可以成千上万,每个都带一份 ThreadLocal,内存会跟着线性膨胀:
// 10 万个虚拟线程 × ThreadLocal 里 1MB 的缓存 = 灾难
private static final ThreadLocal<byte[]> buffer = ThreadLocal.withInitial(() -> new byte[1024 * 1024]);要么改成方法内局部变量,要么确认这里存的确实是小对象。
上线检查清单
按这个顺序过一遍,能避开绝大多数线上问题:
- [ ] JDK 版本确认是 21+(LTS),且基础镜像同步升级
- [ ] 连接池没成为新瓶颈——虚拟线程让并发请求量暴涨,DB 连接池(默认 HikariCP 10 个)会先被打满。按需调大,但别超过数据库
max_connections - [ ] 全局排查
synchronized——尤其是第三方库里的,用-Djdk.tracePinnedThreads=short实测 - [ ] 压测对比——同一个接口,虚拟线程 vs 固定线程池,看 P99 和错误率,别只看吞吐
- [ ] 监控对齐——线程数指标失去意义(本来就该是几万),改看请求队列长度和连接等待时间
- [ ] 下游限流——你这边能扛 10 万并发了,下游服务不一定扛得住
小结
- 虚拟线程解决的是"阻塞占用线程",IO 密集场景收益最大,CPU 密集无收益
- Spring Boot 里一行配置开启,业务代码零改动
- 两个必查项:
synchronizedpinning、连接池瓶颈 - 它是更简单的并发模型,不是更高性能的执行引擎