传统 Web 服务大多构建在“一请求一线程”模型上:每个 HTTP 请求占用一个工作线程,线程在等待数据库、RPC 或下游 HTTP 响应时被挂起,线程资源被白白占用。为了支撑更多并发,我们不断调大线程池、引入异步框架,代码复杂度随之飙升。JDK 21 中虚拟线程正式转正,配合 Thread.ofVirtual() 这个入口 API,可以让每个请求独占一个“线程”,同时保持最朴素的同步阻塞写法,是重构这类架构的低成本方案。

一、为什么平台线程模型撑不住高并发
平台线程的本质是一个对操作系统线程的轻量封装,创建和销毁都要经过内核,调度也完全依赖操作系统。一个平台线程默认预留约 1MB 的栈空间,加上内核态的资源开销,单机最多创建几千个就会触碰内存和调度瓶颈。所以 Tomcat 默认 200 个工作线程、Dubbo 默认 200 个处理线程,并非随意设定,而是平台线程成本决定的合理上限。
问题在于,Web 请求的大部分时间并不是在计算,而是在等待。一次典型的下单请求,可能 80% 的时间耗在等待数据库返回、等待第三方支付接口响应上。这段时间里线程处于 BLOCKED 或 WAITING 状态,什么都做不了,却依然占着一个昂贵的平台线程名额。并发量上来后,线程池耗尽,请求开始排队,响应时间雪崩式增长。
过去解决这个问题的主流方案是异步化,比如 CompletableFuture、Reactor、Vert.x。这类方案确实能提升吞吐,但代价是回调地狱、调试困难、异常栈断裂,业务代码的可读性大幅下降。虚拟线程的思路则完全不同:不改代码风格,只改线程的实现方式。
二、Thread.ofVirtual() 的基本用法与底层原理
JDK 21 提供了三种创建虚拟线程的方式,最直接的就是 Thread.ofVirtual() 构建器。下面是一段完整的示例代码:
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// 方式一:使用构建器显式创建并启动
Thread vt = Thread.ofVirtual()
.name("order-worker-", 0) // 指定线程名前缀,从 0 开始编号
.start(() -> {
System.out.println(Thread.currentThread()
+ " 处理订单任务");
});
// 方式二:使用 unstarted,稍后再启动
Thread unstarted = Thread.ofVirtual()
.name("lazy-task")
.unstarted(() -> System.out.println("延迟启动的任务"));
unstarted.start();
// 方式三:工厂方式,配合 ExecutorService 使用
try (var executor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("pool-", 0).factory())) {
executor.submit(() -> {
Thread.sleep(1000); // 阻塞时虚拟线程会自动让出载体线程
return "任务完成";
});
}
vt.join();
unstarted.join();
}
}虚拟线程的关键差异在于调度方式。平台线程由操作系统内核调度,而虚拟线程由 JVM 内部的 ForkJoinPool 调度,默认并行度等于 CPU 核心数。虚拟线程的栈帧存储在堆上,按需增长和收缩,一个虚拟线程初始占用只有几百字节,创建百万级虚拟线程也毫无压力。
更精妙的是“挂载与卸载”机制。当虚拟线程执行到阻塞操作(如 Thread.sleep、网络 IO、Lock.lockInterruptibly)时,JDK 会自动把它的执行状态保存到堆上,释放底层的载体线程去运行其他虚拟线程。等到阻塞结束,再找机会恢复执行。整个过程对业务代码完全透明,你写的是同步代码,得到的是异步的吞吐能力。
三、重构一请求一线程架构的落地步骤
重构的核心思路是:把原来从平台线程池借线程的逻辑,改成每个请求创建一个虚拟线程。假设原来有一段典型的阻塞式业务代码:
public class OrderService {
private final ExecutorService pool =
Executors.newFixedThreadPool(200); // 传统固定线程池
public void handleOrder(OrderRequest req) {
pool.submit(() -> {
try {
// 三次阻塞调用:数据库、远程接口、消息队列
saveToDb(req);
callPaymentApi(req);
sendMqMessage(req);
} catch (Exception e) {
log.error("订单处理失败", e);
}
});
}
}改造非常简单,只需把线程池换成虚拟线程执行器,业务代码一行都不用动:
public class OrderService {
// 每个任务分配一个虚拟线程,替代固定线程池
private final ExecutorService pool =
Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("order-vt-", 0).factory());
public void handleOrder(OrderRequest req) {
pool.submit(() -> {
try {
saveToDb(req); // 阻塞在 JDBC 上时会自动让出载体线程
callPaymentApi(req); // 使用 HttpClient 时同样友好
sendMqMessage(req);
} catch (Exception e) {
log.error("订单处理失败", e);
}
});
}
}如果是 Spring Boot 3.2 及以上版本,配置更加省事,直接在配置文件中打开开关:spring.threads.virtual.enabled=true,Tomcat 的请求处理线程就会全部切换为虚拟线程,无需修改任何 Java 代码。对于自研网关或 Netty 之外的同步框架,则可以在入口处用 Thread.ofVirtual().start() 直接为每个连接派发一个虚拟线程。
迁移时要注意驱动版本的配套。JDBC 驱动需要较新版本才能在阻塞时正确挂起虚拟线程,MySQL 建议使用 8.0.33 以上,PostgreSQL 建议使用 42.6.0 以上。老版本驱动内部用 synchronized 包裹 IO 操作时,会导致虚拟线程固定在载体线程上无法卸载,这个问题下面单独说。
四、必须警惕的坑点与排查手段
第一个坑是 pinning(固定)。当虚拟线程在阻塞操作期间无法从载体线程卸载时,就称为被固定。典型触发场景有两个:一是在 synchronized 块内执行阻塞 IO,二是调用本地方法。固定本身不会导致错误,但会造成吞吐退化到接近平台线程的水平。JDK 21 中可以通过 JVM 参数 -Djdk.tracePinnedThreads=full 打印固定事件的堆栈,定位问题代码。如果热点路径确实存在 synchronized 内阻塞的情况,改用 ReentrantLock 即可解决。
public class CacheHolder {
private final ReentrantLock lock = new ReentrantLock();
private final Map<String, String> cache = new ConcurrentHashMap<>();
public String get(String key) {
// 用 ReentrantLock 替代 synchronized,避免虚拟线程 pinning
lock.lock();
try {
return cache.computeIfAbsent(key, this::loadFromRemote);
} finally {
lock.unlock();
}
}
private String loadFromRemote(String key) {
// 远程加载,阻塞时虚拟线程可正常卸载
return httpClient.send(key);
}
}第二个坑是 ThreadLocal 滥用。虚拟线程数量动辄百万,如果每个线程都在 ThreadLocal 里塞入几 MB 的缓存对象,堆内存会瞬间爆炸。JDK 21 专门提供了 ScopedValue 作为不可变上下文的替代方案,配合 StructuredTaskScope 使用效果更佳。对于必须使用的场景,务必控制对象大小并及时清理。
第三个坑是连接池容量。虚拟线程把线程瓶颈移除了,压力会转移到下游资源上。原来 200 个线程天然限制了数据库连接的并发获取,换成虚拟线程后,可能有上万个虚拟线程同时争抢连接池。因此迁移时要同步评估数据库连接池、Redis 连接池的最大连接数,避免把下游打挂。
五、总结
虚拟线程不是银弹,它解决的是“阻塞式代码在等待期间浪费线程”这一个问题,对纯计算密集型任务没有收益。但对于绝大多数 IO 密集的 Web 服务来说,Thread.ofVirtual() 提供了一条改造成本极低的路径:业务代码保持同步风格,无需引入响应式框架,即可获得接近异步的吞吐能力。建议的迁移顺序是:先用开关在测试环境启用并压测,用 tracePinnedThreads 排查固定问题,再逐步调整下游连接池参数,最后全量上线。这样既控制了风险,也能让团队在没有学习成本的前提下享受 JDK 21 带来的并发红利。
虚拟线程Thread.ofVirtualJDK 21修改时间:2026-09-03 23:05:20