Java 21 虚拟线程原理与应用
从线程与请求的映射关系出发,理解虚拟线程的调度、阻塞 I/O、资源限流和迁移边界。
虚拟线程(Virtual Thread)不是“更快的线程”,而是让阻塞式代码在大量等待 I/O 时不必长期占用同等数量的操作系统线程。它改变的是并发任务的承载成本,不会让 CPU 变多、数据库连接变多,也不会让一个慢下游突然变快。理解这个边界,比把线程池参数替换成 newVirtualThreadPerTaskExecutor() 更重要。
线程与请求的映射
传统平台线程通常由 OS 线程执行,一个平台线程在阻塞 I/O 时仍被占用。虚拟线程由 JVM 调度到少量 carrier platform threads 上运行;遇到支持的阻塞操作时,虚拟线程可以挂起并释放 carrier,让其他虚拟线程继续运行。
flowchart TB
subgraph requests[大量请求任务]
v1[虚拟线程 A 等待 HTTP]
v2[虚拟线程 B 处理返回值]
v3[虚拟线程 C 等待 JDBC]
v4[虚拟线程 D 执行代码]
end
scheduler[JVM 虚拟线程调度器]
carriers[少量平台线程 / Carrier]
cpu[CPU 核心]
v1 --> scheduler
v2 --> scheduler
v3 --> scheduler
v4 --> scheduler
scheduler --> carriers --> cpu
v1 -.阻塞 I/O 时卸载.-> scheduler
用一句话概括:虚拟线程让“每个请求一个线程”的编程模型能承载更多 I/O 并发,但它仍然共享 CPU、连接池、文件描述符、下游配额等有限资源。
从任务开始使用
JDK 21 中可以使用 Executors.newVirtualThreadPerTaskExecutor(),每个提交任务创建一个虚拟线程。它不是线程池,不应该用来复用线程:虚拟线程本来就轻量,重复利用会让任务的生命周期和取消边界更难理解。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Customer> customer = executor.submit(() -> customerClient.fetch(id));
Future<List<Order>> orders = executor.submit(() -> orderClient.fetchByCustomer(id));
return new CustomerDashboard(
customer.get(800, TimeUnit.MILLISECONDS),
orders.get(800, TimeUnit.MILLISECONDS));
}
在 Spring Boot 中可以用虚拟线程承载 @Async 等任务:
spring:
threads:
virtual:
enabled: true
启用前要核对 Spring Boot 版本、任务执行器 bean、Web 容器线程策略和项目里的自定义 TaskExecutor。如果项目自己声明了 executor,自动配置可能退让;不要因为配置开关成功启动,就假设每条异步路径都改成了虚拟线程。
用信号量限制真正稀缺的资源
虚拟线程不会替代数据库连接池。假如数据库最多支持 80 个业务连接,应用若接受几千个同时执行 JDBC 的请求,线程可能不再是瓶颈,连接池排队和超时反而会成为放大器。限流要落在数据库、HTTP 下游或租户配额等资源边界。
final class PartnerGateway {
private final Semaphore permits = new Semaphore(64);
private final HttpClient httpClient;
PartnerGateway(HttpClient httpClient) {
this.httpClient = httpClient;
}
PartnerResult call(URI uri) throws Exception {
if (!permits.tryAcquire(150, TimeUnit.MILLISECONDS)) {
throw new RejectedExecutionException("partner concurrency limit reached");
}
try {
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(2))
.GET().build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString())
.statusCode() == 200
? PartnerResult.ok()
: PartnerResult.failed();
} finally {
permits.release();
}
}
}
生产代码还要区分超时、网络错误、业务拒绝和 5xx,设置重试预算与熔断,并确保中断不会被吞掉。对外部请求,不要在限流超时之后无界排队等待。
哪些任务收益明显
- 阻塞式 I/O 服务:大量请求等待数据库、HTTP、文件等操作,且调用栈和 ThreadLocal 传统代码较多。
- 代码简化:原本为了省线程而层层改造成回调/响应式链路,迁移后可以恢复顺序化的任务代码。
- 吞吐提升但不保证延迟下降:减少线程占用有机会提高整体并发吞吐;单个请求的网络、SQL 和 CPU 时间没有自动缩短。
虚拟线程不适合用作 CPU 密集计算的无界并行池。大量压缩、加密或图像运算仍然受 CPU 核心数量限制,应使用有界 CPU executor,并用压测确定并发度。
常见踩坑
- 把虚拟线程当作资源池:线程便宜不代表连接、内存和下游容量无限。对每个受限资源建立明确的并发上限。
- 对虚拟线程做固定大小池化:每个任务新建虚拟线程,由下游 semaphore 或连接池执行资源限流;固定线程池会重复引入排队瓶颈。
- Java 21 中长时间在
synchronized内阻塞:虚拟线程被 monitor pin 住时 carrier 无法释放。缩短临界区,避免持锁执行网络或数据库 I/O;可用 JFRjdk.VirtualThreadPinned事件定位。 - 误以为所有阻塞都能卸载:native 调用、某些文件 I/O 和版本相关的 monitor 行为需要实测;升级 JDK 后也要重新看实际行为。
- ThreadLocal 持有大对象:虚拟线程数量上来后,每线程副本会造成明显内存压力。检查 MDC、安全上下文、请求缓存和事务上下文的生命周期。
- 迁移后忘记观测:记录活跃请求、连接池等待、下游并发、超时率、carrier 利用率和 pinned 事件;线程数变化不能单独说明成功。
迁移可以先从一个阻塞 I/O 比例高、边界清楚的服务开始。用同一组流量与依赖条件对照吞吐、P95/P99、堆占用、下游连接数与错误率,再决定是否扩大范围。