跳到主要内容

虚拟线程(Virtual Threads)大概是 Java 21 里讨论度最高的特性。宣传语很诱人:几行改动,吞吐量翻倍。

它确实很强,但"免费午餐"这个说法有误导性——它免费的是代码复杂度,不是资源。这篇文章讲清楚三件事:它解决了什么、什么时候没用、上线前要检查什么。

它解决什么问题 ​

传统 Java 的线程是平台线程,一个 Java 线程对应一个操作系统线程。代价很实在:

项目数量级
单线程栈内存默认 1MB(Xss)
上下文切换成本微秒级,需陷入内核
单机可承载线程数数千

于是 Web 服务遇到一个尴尬的局面:一个请求 95% 的时间在等数据库、等 Redis、等 HTTP 调用,线程却一直被占着。

java
// 一个典型的阻塞式接口
@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 只用了几毫秒
}

面对这个问题以前只有两条路:

  1. 加大线程池——内存和上下文切换扛不住
  2. 改响应式(WebFlux / CompletableFuture)——能扛,但代码变成回调地狱,调试和栈信息都很痛苦

虚拟线程给了第三条路:保持上面这种直白的阻塞式写法,但让阻塞几乎不占资源。因为它在阻塞时会自动卸载(unmount),底层的载体线程可以去跑别的虚拟线程。

text
请求 A 阻塞在 DB  ──卸载──>  载体线程去执行 请求 B
DB 返回结果       ──挂载──>  请求 A 继续执行

用法 ​

创建 ​

java
// 方式一:直接启动
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 一行:

yaml
spring:
  threads:
    virtual:
      enabled: true

这样 Tomcat 的请求处理线程就变成了虚拟线程。到此为止,业务代码一个字都不用改——这是它最大的价值。

别再池化虚拟线程

虚拟线程创建成本极低(几百字节),不要用固定大小的线程池去限制它。需要限流请用 Semaphore,那才是语义正确的做法。

不适合虚拟线程的场景 ​

1. CPU 密集型任务 ​

虚拟线程只在阻塞时才有收益。纯计算任务挂着不放,反而多一层调度开销。

java
// ❌ 虚拟线程在这里毫无帮助
executor.submit(() -> heavyImageProcessing(bytes));

这类任务应该用 newFixedThreadPool(Runtime.getRuntime().availableProcessors())。

2. synchronized 导致的线程固定(pinning) ​

这是一个真实的坑:在 synchronized 块中发生阻塞时,虚拟线程会被钉在载体线程上无法卸载,阻塞了底层线程。

java
synchronized (lock) {
    db.query();   // ⚠️ 阻塞会导致 pinning,吞吐量直接退化
}

解法很简单——换成 ReentrantLock:

java
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    db.query();   // ✅ 不会 pinning
} finally {
    lock.unlock();
}

怎么发现 pinning

启动时加 -Djdk.tracePinnedThreads=short,发生 pinning 时会打印完整栈。改完代码后务必再跑一次确认清零。

3. ThreadLocal 泛滥 ​

虚拟线程可以成千上万,每个都带一份 ThreadLocal,内存会跟着线性膨胀:

java
// 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 里一行配置开启,业务代码零改动
  • 两个必查项:synchronized pinning、连接池瓶颈
  • 它是更简单的并发模型,不是更高性能的执行引擎

最后更新于:

本站内容采用 CC BY-NC 4.0 许可