并发08:虚拟线程

基于 Java 21 稳定特性,系统理解虚拟线程的设计思想、调度模型、适用场景和实践边界,包括 pinning、限流和资源保护。

字数 1596 阅读时长 ≈ 5 分钟 2026-5-28 2026-7-12
并发08:虚拟线程

虚拟线程解决什么问题

虚拟线程在 Java 21 成为稳定特性。它要解决的核心问题不是”让 CPU 算得更快”,而是”让大量阻塞等待的线程更便宜”。

传统平台线程和操作系统线程关系很近,创建和切换成本都不低。Web 服务里很多线程其实不是一直在计算,而是在等数据库、HTTP、Redis、文件 IO。等的时候还占着平台线程,线程池一满,请求就开始排队。

虚拟线程的思路是:业务代码仍然可以写阻塞风格,但线程阻塞时,JDK 可以把虚拟线程从承载它的平台线程上卸下来,让平台线程去跑别的虚拟线程。

虚拟线程 vs 平台线程

特性平台线程虚拟线程
与 OS 线程关系一对一映射多对一映射
创建和切换成本
数量上限几千到几万几十万甚至更多
阻塞时资源占用占用平台线程可卸载,释放平台线程
适用场景CPU 密集型IO 密集型

调度模型

虚拟线程不是操作系统直接调度的线程。它由 JDK 调度,运行时会挂载到平台线程上,平台线程也常被叫作 carrier thread。

虚拟线程与 carrier 调度关系

当虚拟线程执行普通 Java 代码时,它占用 carrier;当它遇到支持卸载的阻塞操作时,JDK 可以把它从 carrier 上卸下来,carrier 去跑其他虚拟线程。等阻塞操作就绪,再把虚拟线程继续调度回来。

这就是”等待变便宜”的来源。以前一个请求等数据库时,占住一个平台线程;虚拟线程下,这个等待有机会不再占住 carrier。

但如果代码一直在做 CPU 计算,它就不会频繁卸载。此时虚拟线程仍然需要 carrier 执行,CPU 核数还是硬上限。创建 10 万个虚拟线程做计算,只会让调度更忙,不会让 CPU 更多。

典型用法

虚拟线程让”每个任务一个线程”重新变得可行:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<User> user = executor.submit(() -> userClient.get(id));
    Future<List<Order>> orders = executor.submit(() -> orderClient.list(id));

    return new Profile(user.get(), orders.get());
}

这段代码仍然是阻塞式 get(),但每个任务可以放在独立虚拟线程里执行。对大量 IO 等待的聚合接口来说,它比维护复杂回调链更容易理解。

容量上限仍然存在

虚拟线程很轻,但下游资源不轻。数据库连接池、Redis 连接、第三方接口限额、CPU、内存都还是有限的。

如果以前线程池大小是 200,虚拟线程改造后一口气放进 2 万个请求,下游数据库连接池还是只有 100 个。结果可能不是服务更快,而是更多请求同时压到连接池、超时和重试一起放大。

所以虚拟线程通常要和限流、连接池、信号量一起使用:

Semaphore permits = new Semaphore(100);

try {
    permits.acquire();
    return queryDatabase();
} finally {
    permits.release();
}

ThreadLocal 和上下文

虚拟线程数量可以非常大,这会放大 ThreadLocal 滥用的问题。虚拟线程通常是任务级创建,用完结束,污染问题缓解了一些。

但如果每个虚拟线程都塞入很大的上下文对象,内存仍然会被放大。日志 traceId、用户上下文、租户信息这些可以传,但不要把大对象、缓存、连接这类资源放进 ThreadLocal。

引入虚拟线程前,要压测数据库驱动、HTTP 客户端、连接池、监控探针和日志上下文传递,别只看业务代码能不能编译。

pinning 问题

虚拟线程阻塞时,理想情况下可以从平台线程卸载。但有些情况会发生 pinning,也就是虚拟线程被固定在承载它的平台线程上,平台线程不能被释放出来。

pinning 场景说明
synchronized 代码块在 synchronized 块里执行长时间阻塞操作
native / foreign 方法调用本地方法
某些 IO 操作不支持卸载的阻塞 IO

工程上要避免把慢 IO 放进大锁里。如果一个虚拟线程在持有 synchronized 锁时调用慢接口,它不仅占着业务锁,也可能让承载线程无法释放。更好的做法是缩小锁范围,把阻塞 IO 移到锁外。

和线程池不是替代关系

虚拟线程让”每个任务一个线程”重新变得可行,但它不意味着所有线程池都没用了:

  • CPU 密集任务:仍然受 CPU 核数限制,需要控制并行度
  • 资源隔离:订单查询、报表导出、第三方同步应该有各自的容量保护
  • 容量限制:仍然要用限流、信号量、连接池、队列和超时表达

常见误区

误区一:虚拟线程能提升所有接口性能

它主要改善高并发阻塞 IO 的资源占用。如果瓶颈是 CPU、数据库慢 SQL 或下游限额,虚拟线程不能直接解决。

误区二:有了虚拟线程就不需要异步编程

虚拟线程让同步风格更有吸引力,但事件流、响应式背压、复杂任务编排仍然有自己的适用场景。

误区三:不再需要监控线程

虚拟线程数量可能非常大,更要看请求耗时、下游连接池等待、超时、错误率和 JDK Flight Recorder 里的虚拟线程事件。

项目里怎么试点

优先在这些地方试:

  1. 阻塞 IO 多、代码回调复杂的聚合查询
  2. 下游容量明确,有超时和连接池保护的接口
  3. 业务逻辑比较直线,不依赖复杂 ThreadLocal 上下文的链路
  4. 能压测对比平台线程池和虚拟线程表现的独立模块

暂时不建议一上来改造核心支付、强事务链路和大量依赖老旧库的复杂服务。先选一个边界清楚的读接口,把超时、限流、日志上下文和压测指标补齐,再判断收益。

一句话:虚拟线程的价值,是让阻塞等待不再轻易吃光昂贵的平台线程。它让 Java 同步代码在高并发 IO 场景里重新有了空间,但容量控制、状态保护和下游治理仍然要认真做。