一个接口要请求外部模型、查询数据库、读取远程文件,大部分时间可能都在等。为了同时处理更多请求,传统做法往往是调大线程池;但平台线程不是没有成本,线程数量也不能无限增加。
虚拟线程为这种“每个任务按顺序写代码、执行中经常阻塞等待”的场景提供了另一种选择。它在 JDK 21 成为正式特性,目标重点是提高大量等待型任务的并发承载能力,而不是让单个计算任务自动变快。1
线程轻了,真正的资源没有变多
可以把虚拟线程理解为由 JVM 调度的轻量线程。适用的阻塞操作发生时,运行它的平台线程可以释放出来执行其他虚拟线程。具体行为还取决于操作、库实现和 JDK 版本,不能把“阻塞都没有成本”当成保证。
假设数据库连接池只有 20 个连接,即使创建 10,000 个虚拟线程,数据库也不会自动变成能同时处理 10,000 个查询。等待只是从平台线程池队列转移到了获取连接的地方。如果每个任务还持有大对象,内存仍然可能耗尽。
因此需要分清三件事:线程用于承载执行;并发限制用于保护有限资源;入口限流或有界队列用于控制系统接收多少工作。只改变第一件事,后两件事仍要设计。

一个带并发上限的完整示例
下面的程序面向 Java 21 及以上,使用 Thread.sleep 模拟等待,不访问外部服务。它演示同一时间最多八个任务进入受保护区域,不是性能基准。将代码保存为 VirtualThreadBulkhead.java 即可按后面的命令运行。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class VirtualThreadBulkhead {
private static final Semaphore GATE = new Semaphore(8);
private static final AtomicInteger ACTIVE = new AtomicInteger();
private static final AtomicInteger PEAK = new AtomicInteger();
private static String callDownstream(int id) throws InterruptedException {
// 只在成功拿到许可后进入 finally,避免误释放许可。
if (!GATE.tryAcquire(2, TimeUnit.SECONDS)) {
return "REJECTED:" + id;
}
try {
int active = ACTIVE.incrementAndGet();
PEAK.accumulateAndGet(active, Math::max);
Thread.sleep(40); // 模拟可中断的等待,不代表真实接口耗时。
return "OK:" + id;
} finally {
ACTIVE.decrementAndGet();
GATE.release();
}
}
public static void main(String[] args) throws Exception {
List<Future<String>> futures = new ArrayList<>();
int succeeded = 0;
int rejected = 0;
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
final int id = i;
futures.add(executor.submit(() -> callDownstream(id)));
}
for (Future<String> future : futures) {
String result = future.get();
if (result.startsWith("OK:")) {
succeeded++;
} else {
rejected++;
}
}
}
if (PEAK.get() > 8) {
throw new IllegalStateException("并发上限被突破");
}
System.out.printf("ok=%d rejected=%d peak=%d%n",
succeeded, rejected, PEAK.get());
}
}
运行方式:
javac --release 21 VirtualThreadBulkhead.java
java VirtualThreadBulkhead
这里的 200 是有限的演示输入。生产系统不应先把任意多请求全部提交,再认为一个 Semaphore 已经解决过载。入口仍应设置接收上限、排队时限或拒绝策略。
tryAcquire 只限制等待许可的时间,不限制真正的远程请求时间。替换成 HTTP 或数据库调用后,还要配置调用超时、连接池获取超时,并按业务需要管理整体截止时间。Future.get() 超时也不等于远程动作一定停止;取消通常只是请求中断,底层客户端是否响应必须验证。
以上 Java 21 示例经过代码审阅,但未附编译运行验证;这里不提供运行结果或吞吐量数字。它适合用来观察任务与许可的关系,真实服务还需要在对应 JDK 和依赖环境中验证。
并发上限和每秒请求数不是一回事
八个许可意味着最多八个调用同时进行。若接口响应很快,一秒内仍可能发出很多次调用。因此,模型服务的“每分钟请求数”限制,需要速率限制器或请求预算;连接数和并发数限制,则由信号量、连接池等机制处理。
如果不同下游容量不同,最好分别控制。给数据库查询、模型推理、文件上传共用一个许可池,可能让慢上传阻塞本来可以快速完成的查询。反过来,每个请求自己创建一个新的信号量,也无法形成应用级总限制。
哪些任务不适合指望虚拟线程提速
CPU 密集型计算仍然受处理器资源限制。给同一段图像计算开更多虚拟线程,不会增加物理计算能力,还可能增加调度成本。此时更应考虑算法、数据布局和合理的计算并行度。
还要注意每任务资源。平台线程池中某个 ThreadLocal 缓存原本只创建几十份,改成每任务一个虚拟线程后,可能出现更多实例。适合共享且线程安全的资源应以受控方式共享,不能机械照搬“每线程一个大对象”。2
关于 synchronized,要写清 JDK 版本
在 JDK 21 中,虚拟线程在某些 synchronized 相关场景阻塞时可能固定住承载线程,即 pinning。JDK 24 的相关改进消除了由 Java monitor 导致的一类固定问题,但这不表示所有 native/foreign 等场景都不再有类似限制。[2]3
因此,不能在所有版本上笼统写“虚拟线程绝对不能用 synchronized”,也不能说升级之后无需再分析阻塞。应结合实际 JDK、依赖库、线程转储或 JFR 等工具查明瓶颈。
虚拟线程最有价值的地方,是保留直观的顺序代码,同时承载更多等待中的任务。上线前真正需要验证的,是下游容量、排队时间、内存和取消行为,而不是只数创建了多少线程。
参考资料
- 1 OpenJDK:JEP 444 — 虚拟线程的目标与正式引入。
- 2 Oracle:Java 21 Virtual Threads — 使用方式与限制。
- 3 Oracle:Java 24 Virtual Threads — 新版本中的行为说明;另见 JEP 491。
本文不报告性能实测。资料核对日期:2026-10-08。
