Java 21虚拟线程(Virtual Threads)深度解析
引言
2023年9月,Java 21正式发布,虚拟线程(Virtual Threads)作为JEP 444的正式特性落地。这不是一次普通的API更新,而是Java并发模型自1996年以来最重大的变革。
先看一个真实场景。某电商平台的订单查询服务,采用经典的"一请求一线程"模型,部署在8核16G的容器中,Tomcat最大线程数设置为200。日常QPS在3000左右运行良好,但每逢大促,流量涨到8000时,请求队列迅速堆积,大量请求超时。运维的第一反应是调大线程数到500,结果CPU上下文切换开销剧增,吞吐量不升反降,P99延迟从200ms飙到3秒。
这个困境的根源在于:平台线程(Platform Thread)是操作系统线程的一对一映射。每个线程占用约1MB栈空间,创建和切换需要陷入内核态,成本高昂。你无法在有限的物理资源上运行大量阻塞型任务。
传统的解法有两种:一是用异步编程(CompletableFuture、Reactor),把阻塞调用改造成回调链,但代码可读性断崖式下降,异常栈难以追踪;二是用协程框架(如Quasar、Kotlin Coroutines),但需要侵入式改造或更换语言。
虚拟线程给出了第三条路:保持同步阻塞的编程模型,却获得异步编程的吞吐量。这背后的实现机制,正是本文要深挖的核心。
核心概念:从餐厅后厨到虚拟线程
生活化类比
想象一家餐厅的后厨。
传统线程池模型:餐厅雇了200名厨师(平台线程),每人负责一道菜从头做到尾。当厨师需要等烤箱里的牛排(I/O阻塞)时,他就站在烤箱前干等,什么也做不了。高峰期订单暴增,200名厨师全都在等烤箱,新订单只能排队。你不能无限雇厨师,因为每个厨师都要占一个工位(内存),厨房站不下。
虚拟线程模型:餐厅只雇8名厨师(载体线程,等于CPU核数),但配备了10000个"订单牌"(虚拟线程)。当某个订单牌对应的菜需要等烤箱时,厨师把订单牌往墙上一挂(挂起虚拟线程),转手去做下一道能立刻推进的菜。烤箱"叮"的一声响(I/O完成),厨师取回订单牌继续做。厨师永远不闲着,订单牌的成本又极低(初始几百字节)。
关键洞察:厨师是稀缺资源(CPU),订单牌是廉价资源(内存)。虚拟线程的本质,就是让稀缺的厨师只在真正需要CPU时才工作,等待时间全部让给别人。
技术定义
虚拟线程是java.lang.Thread的一个实例,但它不绑定到操作系统线程。它由JVM调度,运行在少量的载体线程(Carrier Thread)上。当虚拟线程执行阻塞操作(如sleep、Socket读写、LockSupport.park)时,JVM会将其卸载(unmount),释放载体线程去执行其他虚拟线程;阻塞结束后再重新挂载(remount)。
// 创建虚拟线程的三种方式
Thread vThread1 = Thread.ofVirtual().start(() -> System.out.println("虚拟线程1"));
Thread vThread2 = Thread.ofVirtual()
.name("order-query-", 0)
.unstarted(() -> System.out.println("虚拟线程2"));
vThread2.start();
// 工厂方式
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vThread3 = factory.newThread(() -> System.out.println("虚拟线程3"));
vThread3.start();源码级原理深度分析
1. 虚拟线程的类层次结构
打开JDK 21的源码,Thread类中有一个关键字段:
// java.lang.Thread (JDK 21)
public class Thread implements Runnable {
// 虚拟线程的实现在这里
private final VirtualThread vthread;
// 平台线程的实现在这里
private final PlatformThread platformThread;
// 判断是否为虚拟线程
public final boolean isVirtual() {
return vthread != null;
}
}Thread成为了一个"壳",真正的执行逻辑委托给VirtualThread或PlatformThread。这是JDK 19以来的重构,目的是让虚拟线程和平台线程共享Thread这个API入口,保证生态兼容。
2. 虚拟线程的调度核心:Continuation
虚拟线程的挂起/恢复能力,建立在jdk.internal.vm.Continuation之上。Continuation是"可暂停的计算单元",它封装了一段可以随时中断和恢复的代码执行状态。
// jdk.internal.vm.Continuation 简化示意
public class Continuation {
private final Runnable target; // 要执行的代码
private StackChunk stackChunk; // 保存的栈帧
private boolean mounted; // 是否正在运行
// 挂起:把当前栈帧拷贝到堆上的stackChunk,然后让出载体线程
public static void yield(ContinuationScope scope) {
Continuation cont = currentContinuation(scope);
if (cont != null) {
cont.yield0(); // 底层是native方法,触发栈帧拷贝
}
}
// 恢复:从stackChunk恢复栈帧,继续执行
public void run() {
// ...
}
}关键机制:栈帧的堆化(Stack Chunk)
平台线程的栈在操作系统的栈内存里,JVM无法搬动它。虚拟线程的栈是存储在Java堆上的StackChunk对象。当虚拟线程挂起时,JVM执行一次"栈帧拷贝"——把当前栈上的帧冻结到堆上的StackChunk中。恢复时反向操作。
这个操作听起来昂贵,但实际非常快,因为:
- 栈帧拷贝是内存到内存的连续拷贝,不涉及内核态切换
- 栈帧采用分片(chunk)结构,按需分配,初始只有几百字节
- 相比线程上下文切换(微秒级),虚拟线程挂起是纳秒级
3. 调度器:ForkJoinPool的定制
虚拟线程的调度器是一个专用的ForkJoinPool,默认并行度等于CPU核数:
// java.lang.VirtualThread 中的调度器设置
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
private static ForkJoinPool createDefaultScheduler() {
int parallelism = Runtime.getRuntime().availableProcessors();
// 关键配置:异步模式,不等待任务完成
return new ForkJoinPool(parallelism,
ForkJoinPool.defaultForkJoinWorkerThreadFactory,
null, true); // asyncMode = true
}载体线程就是ForkJoinPool的工作线程。当虚拟线程挂起时,它通知调度器"我让出了",调度器从任务队列取出下一个虚拟线程交给这个载体线程。
4. 阻塞操作的拦截:谁在触发卸载?
虚拟线程能在阻塞时自动卸载,关键在于JDK核心库对阻塞点做了改造。以Thread.sleep为例:
// java.lang.Thread (JDK 21)
public static void sleep(long millis) throws InterruptedException {
if (millis < 0) throw new IllegalArgumentException("timeout value is negative");
if (currentThread() instanceof VirtualThread vthread) {
// 虚拟线程路径:调用VirtualThread的sleep
vthread.sleep(millis);
} else {
// 平台线程路径:原有实现
sleep0(millis);
}
}进入VirtualThread.sleep后:
// java.lang.VirtualThread
void sleep(long millis) throws InterruptedException {
// 使用JUC的定时器挂起,而不是OS的sleep
if (Thread.currentThread() != this) throw new IllegalCallerException();
if (millis < 0) throw new IllegalArgumentException();
if (millis == 0) {
// Thread.yield()的语义
Thread.yield();
return;
}
long nanos = MILLISECONDS.toNanos(millis);
// 关键:通过park/unpark机制挂起
try {
ThreadSleepEvent event = ...;
// 底层调用Continuation.yield,卸载虚拟线程
parkNanos(nanos);
} finally {
// ...
}
}类似的改造遍布JDK:
| 阻塞操作 | 改造后的行为 |
|---|---|
Thread.sleep |
挂起虚拟线程,定时器唤醒 |
Socket 读写 |
NIO Poller 注册事件,挂起等待 |
LockSupport.park |
挂起虚拟线程,unpark时恢复 |
synchronized |
不挂起(见避坑章节) |
Object.wait |
挂起虚拟线程 |
File I/O |
不挂起(JDK 21限制) |
5. 状态机:虚拟线程的生命周期
虚拟线程有比平台线程更复杂的状态机:
// java.lang.VirtualThread 内部状态
private static final int NEW = 0;
private static final int STARTED = 1;
private static final int RUNNING = 2; // 已挂载到载体线程
private static final int PARKING = 3; // 正在挂起
private static final int PARKED = 4; // 已挂起
private static final int PINNED = 5; // 被固定,无法卸载
private static final int YIELDING = 6; // 主动让出
private static final int TERMINATED = 7;PINNED状态是理解虚拟线程性能陷阱的关键:当虚拟线程在synchronized块内或执行native方法时,它无法被卸载,只能"钉"在载体线程上,此时载体线程被独占,失去了调度其他虚拟线程的能力。
实战代码
示例1:虚拟线程 vs 平台线程的吞吐量对比
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 对比虚拟线程与平台线程在处理阻塞任务时的吞吐量差异
* 场景:模拟10000个任务,每个任务阻塞100ms(类似调用下游API)
*/
public class ThroughputComparison {
private static final int TASK_COUNT = 10_000;
private static final long BLOCK_MILLIS = 100;
public static void main(String[] args) throws Exception {
System.out.println("CPU核心数: " + Runtime.getRuntime().availableProcessors());
// 方案一:固定大小平台线程池(模拟Tomcat 200线程)
runWithPlatformThreads(200);
// 方案二:虚拟线程(每任务一线程)
runWithVirtualThreads();
// 方案三:虚拟线程 + 信号量限制并发(防止下游被打爆)
runWithVirtualThreadsAndSemaphore(500);
}
private static void runWithPlatformThreads(int poolSize) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(poolSize);
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
for (int i = 0; i < TASK_COUNT; i++) {
pool.submit(() -> {
try {
Thread.sleep(BLOCK_MILLIS); // 模拟I/O阻塞
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsed = System.currentTimeMillis() - start;
pool.shutdown();
System.out.printf("[平台线程池 size=%d] 耗时: %d ms, 吞吐: %.0f task/s%n",
poolSize, elapsed, TASK_COUNT * 1000.0 / elapsed);
}
private static void runWithVirtualThreads() throws Exception {
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
// 每个任务一个虚拟线程,无需池化
for (int i = 0; i < TASK_COUNT; i++) {
Thread.ofVirtual().start(() -> {
try {
Thread.sleep(BLOCK_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("[虚拟线程 无限制] 耗时: %d ms, 吞吐: %.0f task/s%n",
elapsed, TASK_COUNT * 1000.0 / elapsed);
}
private static void runWithVirtualThreadsAndSemaphore(int permits) throws Exception {
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
// 信号量限制并发,保护下游服务
Semaphore semaphore = new Semaphore(permits);
for (int i = 0; i < TASK_COUNT; i++) {
Thread.ofVirtual().start(() -> {
try {
semaphore.acquire(); // 超过并发上限时挂起虚拟线程
try {
Thread.sleep(BLOCK_MILLIS);
} finally {
semaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("[虚拟线程+信号量=%d] 耗时: %d ms, 吞吐: %.0f task/s%n",
permits, elapsed, TASK_COUNT * 1000.0 / elapsed);
}
}典型输出(8核机器):
CPU核心数: 8
[平台线程池 size=200] 耗时: 5012 ms, 吞吐: 1995 task/s
[虚拟线程 无限制] 耗时: 145 ms, 吞吐: 68965 task/s
[虚拟线程+信号量=500] 耗时: 2031 ms, 吞吐: 4923 task/s注意第三个方案的取舍:虚拟线程让吞吐量提升,但如果不加限制,可能瞬间打爆下游。信号量是虚拟线程时代替代线程池做限流的标准做法。
示例2:结构化并发(Structured Concurrency)实战
Java 21引入了StructuredTaskScope(预览特性)作为虚拟线程的黄金搭档。它解决了传统ExecutorService的一个顽疾:任务的生命周期管理混乱,父任务取消时子任务可能仍在跑。
import java.util.concurrent.*;
import jdk.incubator.concurrent.StructuredTaskScope;
/**
* 场景:聚合用户信息,需要并发调用三个下游服务
* 任一失败则整体失败,且必须取消其他正在执行的任务
* 编译需加: --enable-preview --release 21
*/
public class StructuredConcurrencyDemo {
record UserProfile(String basic, String orders, String recommendations) {}
public static void main(String[] args) throws Exception {
// 正常场景
System.out.println(fetchProfile(1001L, false));
// 失败场景:orders服务异常
System.out.println(fetchProfile(1002L, true));
}
static UserProfile fetchProfile(long userId, boolean failOrders) throws Exception {
// ShutdownOnFailure: 任一子任务失败,取消其他所有子任务
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> basic = scope.fork(() -> fetchBasic(userId));
Subtask<String> orders = scope.fork(() -> fetchOrders(userId, failOrders));
Subtask<String> recs = scope.fork(() -> fetchRecommendations(userId));
// 等待所有子任务完成,或任一失败
scope.join();
// 如果有失败,抛出异常(会包含所有失败信息)
scope.throwIfFailed();
// 到这里说明全部成功,get()不会阻塞
return new UserProfile(basic.get(), orders.get(), recs.get());
}
// try-with-resources 自动关闭scope,确保所有子任务已结束
}
static String fetchBasic(long userId) throws InterruptedException {
Thread.sleep(50);
return "basic-" + userId;
}
static String fetchOrders(long userId, boolean fail) throws InterruptedException {
Thread.sleep(100);
if (fail) throw new RuntimeException("订单服务不可用");
return "orders-" + userId;
}
static String fetchRecommendations(long userId) throws InterruptedException {
Thread.sleep(80);
return "recs-" + userId;
}
}结构化并发的价值在于:代码的块结构(block structure)与任务的生命周期结构一一对应。try块结束,所有子任务保证已终止。这消除了传统并发代码中"泄漏的线程"和"孤儿任务"的问题。
示例3:Spring Boot 3.2 + 虚拟线程集成
Spring Boot 3.2(Spring Framework 6.1)正式支持虚拟线程。只需一行配置:
# application.yml
spring:
threads:
virtual:
enabled: true但生产环境需要更精细的控制。下面是一个自定义配置:
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadConfig {
/**
* Tomcat 使用虚拟线程处理请求
* 注意:Tomcat 仍需少量平台线程做 accept/poll,但请求处理走虚拟线程
*/
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
/**
* @Async 注解使用的执行器也切换到虚拟线程
*/
@Bean(name = "applicationTaskExecutor")
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}配套的Service层代码:
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.concurrent.*;
@Service
public class OrderQueryService {
private final RestClient restClient;
// 关键:用信号量限制对下游的并发,替代线程池的限流作用
private final Semaphore downstreamLimit = new Semaphore(200);
public OrderQueryService(RestClient.Builder builder) {
this.restClient = builder.baseUrl("https://api.downstream.com").build();
}
public OrderDetail query(Long orderId) throws Exception {
// 虚拟线程中,Semaphore.acquire() 会挂起而非阻塞载体线程
downstreamLimit.acquire();
try {
// 同步阻塞式代码,但底层走虚拟线程的挂起机制
Order order = restClient.get()
.uri("/orders/{id}", orderId)
.retrieve()
.body(Order.class);
// 并发调用两个独立下游,用结构化并发聚合
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() ->
restClient.get().uri("/users/{id}", order.getUserId())
.retrieve().body(User.class));
Subtask<Logistics> logistics = scope.fork(() ->
restClient.get().uri("/logistics/{id}", orderId)
.retrieve().body(Logistics.class));
scope.join();
scope.throwIfFailed();
return new OrderDetail(order, user.get(), logistics.get());
}
} finally {
downstreamLimit.release();
}
}
record Order(Long id, Long userId) {}
record User(Long id, String name) {}
record Logistics(String status) {}
record OrderDetail(Order order, User user, Logistics logistics) {}
}方案对比:虚拟线程 vs 响应式 vs 传统线程池
| 维度 | 传统线程池 | 响应式(Reactor/WebFlux) | 虚拟线程 |
|---|---|---|---|
| 编程模型 | 同步阻塞 | 异步回调/声明式 | 同步阻塞 |
| 代码可读性 | 高 | 低(链式操作符) | 高 |
| 调试体验 | 好(栈完整) | 差(栈割裂) | 好(栈完整) |
| 吞吐量(I/O密集) | 受线程数限制 | 极高 | 极高 |
| 吞吐量(CPU密集) | 好 | 好 | 好(无额外收益) |
| 内存占用/并发单元 | ~1MB | ~几百字节 | ~几百字节起 |
| 背压支持 | 需手动 | 原生支持 | 需手动(信号量) |
| 生态成熟度 | 极高 | 高 | 快速增长中 |
| 学习曲线 | 低 | 高 | 低 |
| 适用场景 | 传统应用 | 高并发网关、流处理 | 高并发I/O密集型应用 |
核心结论:
- 虚拟线程不是银弹。CPU密集型任务用虚拟线程没有收益,甚至因为调度开销略慢于平台线程。
- 虚拟线程不是响应式的替代品。响应式在流处理、背压、事件驱动场景仍有独特优势。虚拟线程的价值是"让普通开发者用同步代码获得异步性能"。
- 两者可以共存。R2DBC等响应式驱动可以与虚拟线程配合,但要注意不要在虚拟线程内做响应式的阻塞等待,会造成载体线程固定。
最佳实践与避坑指南
坑1:synchronized 导致载体线程固定(Pinning)
这是虚拟线程最经典的陷阱。
// ❌ 危险:synchronized 块内阻塞,会固定载体线程
public synchronized String badQuery(Long id) throws InterruptedException {
Thread.sleep(1000); // 虚拟线程被钉在载体线程上,无法卸载
return "result";
}
// ✅ 正确:用 ReentrantLock 替代
private final ReentrantLock lock = new ReentrantLock();
public String goodQuery(Long id) throws InterruptedException {
lock.lock();
try {
Thread.sleep(1000); // 可以正常卸载
return "result";
} finally {
lock.unlock();
}
}检测方法:启动时加-Djdk.tracePinnedThreads=full,JVM会打印所有固定事件及栈信息。JDK 24(JEP 491)已解决synchronized的固定问题,但在JDK 21上仍需警惕。
坑2:ThreadLocal 的内存放大
虚拟线程数量可能是平台线程的千倍,如果每个虚拟线程都持有ThreadLocal,内存会爆炸。
// ❌ 危险:每个虚拟线程都缓存一个大对象
private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB
// ✅ 推荐:使用 ScopedValue(JDK 21预览)
private static final ScopedValue<UserContext> USER_CTX = ScopedValue.newInstance();
public void handle(Request req) {
ScopedValue.where(USER_CTX, loadUser(req)).run(() -> {
// 在作用域内,USER_CTX.get() 可用
processOrder();
});
// 作用域结束,自动清理,无内存泄漏
}坑3:线程池思维惯性
虚拟线程不应该池化。池化平台线程是因为创建成本高;虚拟线程创建成本极低(纳秒级),池化反而增加复杂度且丧失弹性。
// ❌ 错误:给虚拟线程建池
ExecutorService pool = Executors.newFixedThreadPool(1000,
Thread.ofVirtual().factory());
// ✅ 正确:每任务一个虚拟线程
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();坑4:文件I/O不会卸载
JDK 21中,FileInputStream、FileOutputStream的读写是阻塞的,且不会卸载虚拟线程。原因在于文件I/O在多数OS上没有异步接口。
// ❌ 虚拟线程中做文件I/O会固定载体线程
try (var fis = new FileInputStream("large.txt")) {
fis.read(buffer); // 固定
}
// ✅ 用异步文件通道
try (var channel = AsynchronousFileChannel.open(Path.of("large.txt"))) {
// 通过 Future 或 CompletionHandler 异步读取
}最佳实践清单
- 限流用信号量,不用线程池:
Semaphore是虚拟线程时代的标准限流工具。 - 优先使用结构化并发:
StructuredTaskScope让并发代码的异常传播和取消语义清晰可控。 - 监控载体线程池:通过
jdk.virtualThreadScheduler.parallelism和jdk.virtualThreadScheduler.maxPoolSize调优,默认等于CPU核数。 - 迁移前做固定检测:用
-Djdk.tracePinnedThreads扫描存量代码。 - 不要在虚拟线程里做CPU密集计算:这不解决问题,反而增加调度开销。
- 注意第三方库的兼容性:老的连接池(如HikariCP早期版本)、Netty的某些用法可能与虚拟线程不兼容。
总结
回顾全文的核心要点:
- 本质:虚拟线程是JVM调度的轻量级执行单元,通过Continuation的栈帧堆化实现挂起/恢复,通过ForkJoinPool调度到少量载体线程上运行。
- 价值:让开发者用同步阻塞的编程模型,获得接近异步编程的吞吐量。它不改变Java的并发语义,只是让"一请求一线程"重新变得可行。
- 代价:
synchronized固定、ThreadLocal内存放大、文件I/O不卸载——这些是JDK 21上的现实约束,部分已在后续版本修复。
- 定位:虚拟线程不是响应式的替代,而是补充。它降低了高并发编程的门槛,让普通业务开发者也能写出高吞吐的服务。
延伸思考:
- JEP 491(JDK 24)解决了
synchronized固定问题,意味着存量代码迁移的最大障碍被移除。
- ScopedValue将在未来替代ThreadLocal,成为虚拟线程时代的上下文传递标准。
- 结构化并发仍在预览,一旦转正,将与虚拟线程共同构成Java并发的"新双核"。
- 当线程变得廉价,"线程池"这个模式本身可能逐步退出历史舞台,取而代之的是"信号量+虚拟线程"的组合。
Java的并发模型正在经历一次范式转移。掌握虚拟线程,不仅是学习一个新API,更是理解JVM如何重新思考"什么是线程"这个根本问题。