前言:如果你还在为线程池参数调优头秃,或者被
CompletableFuture的回调地狱折磨,那么 JDK 21 带来的虚拟线程(Virtual Threads)绝对是你必须掌握的“救命稻草”。它不仅是 Project Loom 的核心成果,更是 Java 诞生以来并发模型最大的一次变革。
1. 为什么我们需要虚拟线程?(The Why)
在深入技术细节之前,我们先聊聊“痛点”。
长期以来,Java 的并发模型一直是 “一请求一线程”(Thread-per-Request)。这意味着,每当有一个新的 HTTP 请求进来,Web 服务器(如 Tomcat)就会分配一个独立的线程去处理。
痛点一:昂贵的平台线程
在 JDK 21 之前,Java 的 Thread 直接映射到操作系统的内核线程(Platform Thread)。
- 内存贵:一个平台线程默认占用约 1MB - 2MB 的栈内存。
- 切换慢:线程上下文切换涉及内核态与用户态的转换,开销巨大。
- 数量受限:你很难在一台普通服务器上启动几万个线程,机器会直接卡死。
痛点二:异步编程的“妥协”
为了解决高并发问题,我们被迫引入了 NIO、Netty、WebFlux 等异步框架。虽然吞吐量上去了,但代价是惨痛的:
- 代码难懂:逻辑被拆得支离破碎,回调地狱(Callback Hell)随处可见。
- 调试噩梦:一旦报错,堆栈追踪(Stack Trace)里全是框架代码,根本找不到业务逻辑的源头。
JDK 21 虚拟线程的出现,就是为了打破这个僵局:
它允许你用最熟悉的“同步阻塞”代码风格,写出媲美异步框架的“高并发性能”。
2. 什么是虚拟线程?(The What)
虚拟线程(Virtual Threads) 是一种由 JVM 自身管理的轻量级线程,它不再与操作系统的内核线程 1:1 绑定。
我们可以做一个简单的对比:
| 特性 | 平台线程 (Platform Thread) | 虚拟线程 (Virtual Thread) |
|---|---|---|
| 管理者 | 操作系统内核 | JVM 虚拟机 |
| 映射关系 | 1:1 (一个 Java 线程对应一个 OS 线程) | M:N (大量虚拟线程复用少量 OS 线程) |
| 创建成本 | 昂贵 (MB 级内存,系统调用) | 极低 (几百字节,普通 Java 对象) |
| 数量上限 | 几千个 | 几百万个 (仅受堆内存限制) |
简单来说,虚拟线程就像是 Java 中的“协程”(Go 语言的 Goroutine),但它完全融入了现有的 java.lang.Thread API,老代码几乎不用改就能享受红利。
3. 核心原理:它是如何工作的?(Under the Hood)
这是理解虚拟线程最关键的部分。为了讲清楚,我们用一个"出租车模型"来打比方。
- 出租车公司(操作系统 OS):只有 10 辆出租车(这是你的 CPU 核心数/载体线程 Carrier Threads)。
- 乘客(任务 Task):有 10,000 名乘客(这是你的并发请求)。
传统模式(平台线程)
每辆车一次只能拉一个乘客。如果乘客半路说:“师傅,我要去取个快递(数据库查询/网络请求),你等我 5 分钟。” 这时候,出租车就真的停在路边死等(线程阻塞)。因为车只有 10 辆,一旦都在等人,后面 9990 个乘客只能排队喝西北风。这就是为什么传统线程池并发上不去。
虚拟线程模式
在 JDK 21 中,情况变了:
- 挂载(Mount):乘客坐上出租车,开始计算任务。
-
卸载(Unmount):当乘客需要等红灯或取快递(遇到阻塞 I/O,如
Thread.sleep或 DB 查询)时,JVM 会立刻让乘客下车,把他的状态(Continuation)存在路边。 - 复用:出租车(载体线程)立刻去拉下一个乘客,一刻也不闲着。
- 恢复:当原来的乘客快递取好了(I/O 完成),他会重新排队,等任意一辆空闲的出租车过来,带上之前的状态继续走。
结果:仅用 10 辆车,就让 10,000 个乘客感觉自己都在“同时”前进。CPU 利用率被榨干到了极致。
源码视角:JVM 是如何实现“魔法”的?
为了更深入地理解,我们稍微看一眼 JDK 的底层实现(基于 JDK 21 OpenJDK 源码)。
1. 虚拟线程的数据结构
虚拟线程在源码中对应的类是 java.lang.VirtualThread。它不再像平台线程那样持有巨大的栈内存,而是持有一个轻量级的 Continuation(续体) 对象。
// java.lang.VirtualThread 源码简略版
final class VirtualThread extends BaseVirtualThread {
// 调度器,默认是 ForkJoinPool
private final Executor scheduler;
// 续体,用于保存和恢复栈状态
private final Continuation cont;
// 实际要执行的任务
private final Runnable runContinuation;
// ...
}
2. Mount(挂载)
当你的虚拟线程需要执行 CPU 运算时,JVM 会把它“挂载”到一个载体线程(Carrier Thread)上。这个载体线程本质上就是一个 ForkJoinPool 中的 Worker 线程。
以下是 JDK 21 中 mount() 方法的核心逻辑:
// VirtualThread.java
private void mount() {
// 1. 绑定:获取当前的平台线程(Carrier),并建立关联
Thread carrier = Thread.currentCarrierThread();
setCarrierThread(carrier);
// 2. 同步状态:处理中断状态(Interrupt Status)
// 必须确保载体线程的中断状态与虚拟线程一致
// 如果虚拟线程被中断了,载体线程也必须标记为中断,以便 IO 操作能感知到
if (interrupted) {
carrier.setInterrupt();
} else if (carrier.isInterrupted()) {
// 反之,清除载体线程可能残留的“脏”中断状态
synchronized (interruptLock) {
if (!interrupted) carrier.clearInterrupt();
}
}
// 3. 偷天换日:修改当前线程的身份
// 这行代码执行后,Thread.currentThread() 返回的不再是载体线程,而是当前这个虚拟线程
// 这是虚拟线程兼容老代码(如 Log4j, ThreadLocal)的关键
carrier.setCurrentThread(this);
}
3. Unmount(卸载)与 Yield
当你的代码调用了 Thread.sleep() 或者进行网络 I/O 时,底层会调用 VirtualThread.park()。
这是整个虚拟线程调度的核心枢纽,我们来看看它究竟做了什么:
// VirtualThread.java 源码核心逻辑简化版
void park() {
// 1. 检查许可(Permit):如果当前线程已经有了“通行证”(比如刚被 unpark),则无需挂起,直接返回继续跑
if (getAndSetParkPermit(false) || interrupted)
return;
// 2. 尝试挂起(Yield)
setState(PARKING); // 标记状态为 PARKING
try {
// 关键调用:尝试交出控制权
// 如果成功(返回 true),说明虚拟线程已经成功卸载(Unmount)并暂停了
// 如果失败(返回 false),通常是因为被 Pinning(钉住)了
yielded = yieldContinuation();
} finally {
// 如果没挂起成功,把状态改回 RUNNING
if (!yielded) {
setState(RUNNING);
}
}
// 3. 处理 Pinning(钉住)的情况
// 如果 yieldContinuation() 返回 false,说明没能从 Carrier Thread 上下来
// 这时候只能无奈地阻塞底层的 Carrier Thread(这正是我们极力想避免的)
if (!yielded) {
parkOnCarrierThread(false, 0);
}
}
紧接着,我们深入 yieldContinuation(),这才是魔法真正发生的地方:
private boolean yieldContinuation() {
// 1. Unmount:解绑载体线程,清理中断状态
// (这步操作其实隐含在 Continuation.yield 的前后逻辑中,或者是通过 JVMTI 通知触发)
notifyJvmtiUnmount(/*hide*/true);
try {
// 2. 真正的魔法:调用 Continuation.yield
// 这行代码会“冻结”当前栈帧,把堆栈信息保存到堆内存中
// 然后,执行流会“跳回”到 Carrier Thread 的 run() 方法中,去执行下一个任务
return Continuation.yield(VTHREAD_SCOPE);
} finally {
// 3. 恢复:当这个虚拟线程被唤醒(unpark)并重新 Mount 后,代码会从这里继续执行
notifyJvmtiMount(/*hide*/false);
}
}
通过这段源码,我们清晰地看到了 Pinning(钉住) 是如何发生的:当 yieldContinuation() 失败(通常是因为当前栈帧中有 synchronized 或 native 方法)时,代码会回退到 parkOnCarrierThread(),导致底层平台线程被阻塞。
4. 恢复执行 当 I/O 操作完成(例如网卡收到了数据),操作系统会通知 JVM。调度器(Scheduler)会再次把这个挂起的虚拟线程放入执行队列,等待空闲的载体线程将它“复活”(Mount 并恢复栈帧),从上次暂停的地方继续运行。
5. 实战调用链揭秘
为了彻底解开疑惑,我们来看看最常用的 Thread.sleep 和网络 I/O 到底发生了什么。
场景 A:Thread.sleep(1000)
-
JDK 21 修改了
Thread.sleep:它会检查当前线程是否是虚拟线程。 - 如果是,调用
VirtualThread.sleepNanos(nanos)。 -
分支处理:
-
情况 1:
nanos == 0(即Thread.sleep(0))- 直接调用
tryYield()。 - 状态标记为
YIELDING。 - 执行
yieldContinuation()尝试让出 CPU。这是一种“礼让”行为,允许调度器立刻切换到其他等待的虚拟线程。
- 直接调用
-
情况 2:
nanos > 0(即Thread.sleep(1000))- 调用
parkNanos(nanos)。 -
注册唤醒任务:
Future unparker = scheduleUnpark(nanos)。JVM 会在内部的调度队列中注册一个 1 秒后的定时事件。 -
状态标记:
setState(TIMED_PARKING)。 -
尝试挂起:调用
yieldContinuation()。- 成功:虚拟线程卸载,ForkJoinPool Worker 线程释放去干别的事。
-
失败(Pinning):如果被钉住,则执行
parkOnCarrierThread(true, remainingNanos),导致载体线程在 OS 层面阻塞等待剩余时间。
- 调用
-
情况 1:
-
唤醒:定时器触发或礼让结束后,调度器将该虚拟线程
unpark,它重新排队等待执行。
场景 B:网络 I/O (如 socket.read())
- JDK 21 重写了
NioSocketImpl等底层类。 - 当调用
read()时,如果底层 Socket 缓冲区为空(数据未到)。 -
注册 Poller:将当前 Socket 的文件描述符(FD)注册到 JVM 全局的 Poller(Linux 上对应
epoll,Mac 上对应kqueue)。 -
调用
park():虚拟线程卸载。 - 等待事件:当网卡收到数据,OS 通知 Poller。
-
唤醒:Poller 找到对应的虚拟线程,将其
unpark,虚拟线程恢复执行并成功读取数据。
这正是“同步代码,异步执行”的本质!
4. 实战:如何使用?(Code Examples)
在 JDK 21 中,使用虚拟线程非常简单。
4.1 创建虚拟线程
不需要引入任何第三方包,标准库原生支持:
// 方式 1: 直接启动一个虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("Hello from Virtual Thread: " + Thread.currentThread());
});
// 方式 2: 使用 Builder 构建更详细的配置(如名称)
Thread vThread = Thread.ofVirtual()
.name("my-vthread")
.start(() -> {
// 业务逻辑
});
4.2 替代线程池(重点)
这是最大的思维转变。过去我们用 Executors.newFixedThreadPool(200) 来限制线程数量。
现在,请忘掉线程池! 虚拟线程是用完即毁的,不需要池化。
JDK 21 提供了新的执行器:
// 使用 try-with-resources 自动关闭结构
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// 模拟耗时 I/O 操作,例如调用第三方 API
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
}
// 代码运行到这里,说明 10,000 个任务全部执行完毕。
// 在传统线程池下,这可能需要几十秒;而在虚拟线程下,耗时仅约 1 秒多一点。
5. 性能实验:用数据说话(Benchmarks)
光说不练假把式。我们设计一个简单的基准测试,直观感受虚拟线程在高并发 I/O 密集型场景下的威力。
实验目标
模拟 10,000 个并发任务,每个任务“假装”执行 1 秒钟的 I/O 操作(使用 Thread.sleep(1000) 模拟)。
实验代码
你可以直接复制这段代码在本地运行(需 JDK 21+)。
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadBenchmark {
public static void main(String[] args) {
int taskCount = 10_000;
System.out.println("=== 开始基准测试 ===");
System.out.println("任务数量: " + taskCount);
// 1. 测试平台线程(模拟传统线程池)
// 为了公平,我们给平台线程池一个相对较大的核心数,比如 200
long startPlatform = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(200)) {
IntStream.range(0, taskCount).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
});
} // try-with-resources 会自动等待所有任务完成
long endPlatform = System.currentTimeMillis();
System.out.println("平台线程池耗时: " + (endPlatform - startPlatform) + " ms");
// 手动 GC 一次,尽量减少对下一轮的影响
System.gc();
try { Thread.sleep(1000); } catch (Exception e) {}
// 2. 测试虚拟线程
long startVirtual = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, taskCount).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
});
}
long endVirtual = System.currentTimeMillis();
System.out.println("虚拟线程耗时: " + (endVirtual - startVirtual) + " ms");
}
}
实验结果分析(参考数据)
在普通的 8 核笔记本上运行,结果通常如下:
| 指标 | 平台线程池 (Fixed-200) | 虚拟线程 (Virtual Threads) | 提升倍数 |
|---|---|---|---|
| 总耗时 | ~50,601 ms | ~1,149 ms | ~43 倍 |
| 内存占用 | 较高(每个线程栈独立占用 OS 内存) | 极低(几乎只增加堆内存,且很少) | 数量级差异 |
| 原理分析 | 线程池只有 200 个线程,1 万个任务只能排队执行。 50 轮 x 1秒 = 50 秒。 |
1 万个虚拟线程几乎同时启动,同时挂起。 底层 Carrier 线程不断复用,几乎没有排队等待。 |
碾压级优势 |
6. 性能对比与适用场景
是不是所有场景都应该无脑上虚拟线程?并不是。
适用场景:I/O 密集型 (I/O Bound)
这是虚拟线程的主场。
- 典型场景:Web 服务器、微服务网关、大量数据库调用、RPC 调用。
- 效果:吞吐量(Throughput) 将呈指数级提升。原本能抗 1000 QPS 的服务,可能轻松抗住 10000 QPS。
不适用场景:CPU 密集型 (CPU Bound)
- 典型场景:视频转码、复杂的加密解密、科学计算。
- 原因:CPU 只有那么多核心。如果任务全是计算,没有任何等待时间,切换虚拟线程只会增加额外的调度开销,反而可能变慢。
误区提示:虚拟线程不会降低单个请求的延迟(Latency)。它解决的是“由于线程不够用导致的排队等待”,提升的是系统的并发容量。
6. 避坑指南(The Gotchas)
作为架构师,在落地虚拟线程时,必须警惕以下两个坑:
坑一:Pinning(线程钉住)
当虚拟线程在执行以下代码时,无法从载体线程上卸载(Unmount),导致它退化成普通的阻塞线程:
- 在
synchronized块或方法内部。 - 执行
native方法或外部函数接口(FFI)时。
反例(不要这么写):
synchronized (lock) {
// 这里的 sleep 会导致底层的 OS 线程也被阻塞,无法服务其他虚拟线程
Thread.sleep(1000);
}
解决方案:将 synchronized 替换为 ReentrantLock。JDK 核心库(如 ConcurrentHashMap)内部已经完成了这种改造。
lock.lock();
try {
Thread.sleep(1000); // 此时虚拟线程可以正常卸载,释放 OS 线程
} finally {
lock.unlock();
}
坑二:ThreadLocal 膨胀
以前线程池只有 200 个线程,ThreadLocal 里的缓存数据不会占用太多内存。
现在虚拟线程可能有 100 万个,如果每个线程都存 1MB 数据,内存瞬间爆炸。
建议:尽量减少 ThreadLocal 的使用,或者使用 JDK 21 预览特性 Scoped Values(范围值)作为替代。
7. 总结与展望
JDK 21 虚拟线程的正式发布,标志着 Java 并发编程进入了一个全新的“降维打击”时代。
核心结论:
- JDK 21 正式特性:虚拟线程不再是预览版(Preview),而是正式版(Finalized),已具备生产环境落地的能力。
- 适用场景明确:它不是万能药,仅适用于 I/O 密集型任务(Web 服务、RPC 调用)。对于 CPU 密集型任务,传统平台线程依然是王者。
- 编程范式回归:我们不再需要为了性能去写晦涩难懂的异步回调代码。“同步的代码结构,异步的执行性能”,这才是虚拟线程带给开发者的最大红利。
展望未来: 虽然虚拟线程已经由 JEP 444 定稿,但与其黄金搭档——结构化并发(Structured Concurrency)(目前在 JDK 21 仍处于预览阶段)的配合才是完全体。随着未来 JDK 版本的迭代,Java 在高并发领域的统治力将不可撼动。
现在,是时候升级你的 JDK,去重构那些陈旧的线程池代码了!
sumAll 
![[爱了]](/js/img/d1.gif)
![[尴尬]](/js/img/d16.gif)