虚拟线程:把阻塞变成廉价
学完你要能自己回答
- 虚拟线程、平台线程、线程池三者是什么关系?为什么不应该池化虚拟线程?
- 一次阻塞调用发生时,虚拟线程的栈去了哪里?载体线程做什么?
- JDK 21 里哪些操作会 pin 住载体线程?怎么发现和规避?
- 为什么 CPU 密集型任务不会因为虚拟线程变快?
把线程想成「窗口」与「取号单」01
平台线程像银行柜台:一个客户占一个窗口,客户去复印材料(阻塞 I/O)时,窗口也跟着空转。想服务更多客户,只能加窗口,而窗口数量受操作系统限制。
虚拟线程像取号单:客户拿到号就去旁边等,窗口只在真正办理业务时被占用。客户去复印时,窗口叫下一个号。号可以发几十万张,窗口仍然只有 CPU 核数量那么多。
| 平台线程 | 虚拟线程 | |
|---|---|---|
| 映射 | 1:1 绑定内核线程 | M:N 由 JVM 调度到载体线程 |
| 栈 | 内核栈,默认约 1MB 预留 | 堆上的 Continuation 对象,按需增长 |
| 创建成本 | 微秒到毫秒级,数量受 OS 限制 | 纳秒到微秒级,百万级可行 |
| 阻塞代价 | 占住内核线程 | 卸载栈,释放载体线程 |
| 适用任务 | CPU 密集、需要长期占用 | I/O 密集、大量并发、短任务 |
三个必须建立的心智锚点02
锚点一:虚拟线程仍然是一次「线程」
Thread.currentThread()、ThreadLocal、InterruptedException、join这些语义都还在。- 它不是协程库里的 Task,也不是 Reactor 的 Mono;它是
java.lang.Thread的一个实现。 - 因此现有阻塞式代码通常可以直接迁移,不需要把方法改成异步回调。
锚点二:调度权从操作系统交给 JVM
虚拟线程运行在载体线程(carrier thread)上,载体线程是普通的平台线程,默认来自一个 ForkJoinPool。虚拟线程遇到阻塞点时,把栈帧卸载(unmount)到堆上,载体线程立刻去跑别的虚拟线程;阻塞结束后再挂载(mount)回来。
锚点三:便宜不等于无限
- 虚拟线程便宜,但它访问的数据库连接、HTTP 连接、文件句柄并不便宜。
- 没有限流时,十万虚拟线程会同时压向只有 20 个连接的池,瓶颈只是从线程转移到了连接池。
- 正确姿势:虚拟线程负责「等待」,信号量/连接池负责「稀缺资源的并发上限」。
先猜再看如果一个任务只做 1 + 1,用虚拟线程会比平台线程快吗?
挂载、卸载与 Continuation01
每个虚拟线程内部持有一个 Continuation,它保存了虚拟线程的栈帧。运行时的大致路径是:
start()把虚拟线程提交给调度器,调度器把它放进载体线程的任务队列。- 载体线程执行
Continuation.run(),虚拟线程开始运行。 - 遇到可卸载的阻塞点(如
Socket读、Thread.sleep、LockSupport.park),运行时把当前栈帧冻结进 Continuation,run()返回。 - 载体线程从队列取下一个任务;阻塞条件满足后,调度器把虚拟线程重新排队,等某个载体线程挂载。
Thread t = Thread.ofVirtual()
.name("worker-", 1)
.start(() -> {
System.out.println("virtual=" + Thread.currentThread().isVirtual());
System.out.println("carrier=" + Thread.currentThread().toString());
try { Thread.sleep(100); } catch (InterruptedException e) { }
});
t.join();
载体线程的名字形如 ForkJoinPool-1-worker-1。注意:不要把载体线程当成虚拟线程的身份,也不要缓存 Thread.currentThread() 到 ThreadLocal 后跨阻塞点使用。
Pinning:JDK 21 的头号雷区02
当虚拟线程在阻塞时无法卸载,它就会钉住(pin)载体线程。被钉住的载体线程无法去执行其他虚拟线程,吞吐会退化回平台线程模型。
| 场景 | JDK 21 行为 | 规避方式 |
|---|---|---|
在 synchronized 块/方法中阻塞 | 钉住载体线程 | 改用 ReentrantLock;JDK 24 起 JEP 491 已解决 |
| 执行 native 方法或 Foreign Function | 钉住载体线程 | 缩短 native 调用;升级到支持 FFM 的版本 |
| 类初始化过程中的阻塞 | 可能钉住 | 避免在 static 初始化里做 I/O |
Object.wait() | 钉住 | 改用 Condition.await() |
普通 Thread.sleep / Socket 读 | 正常卸载 | 无需处理 |
// JDK 21:这段代码会 pin 住载体线程
synchronized (lock) {
Thread.sleep(Duration.ofSeconds(1)); // 卸载失败
}
// 改为 ReentrantLock,阻塞点可正常卸载
lock.lock();
try {
Thread.sleep(Duration.ofSeconds(1));
} finally {
lock.unlock();
}
检测方式:启动参数 -Djdk.tracePinnedThreads=full 会打印钉住时的栈;JFR 打开 jdk.VirtualThreadPinned 事件可以统计频率和持续时间。JDK 21 的 JFR 事件默认阈值是 20ms。
调度器与并行度03
虚拟线程调度器默认是一个 ForkJoinPool,并行度等于 Runtime.getRuntime().availableProcessors()。它只负责运行虚拟线程,不负责限制业务并发。
# 查看/调整调度器并行度(一般不需要改)
-Djdk.virtualThreadScheduler.parallelism=8
-Djdk.virtualThreadScheduler.maxPoolSize=256
# 诊断 pinning
-Djdk.tracePinnedThreads=full
parallelism是同时运行的载体线程数,默认等于 CPU 核数;把它调大不会让 CPU 变多。maxPoolSize是载体线程池上限,主要应对阻塞导致的临时扩容。- 不要用
-XX:ActiveProcessorCount之外的方式「给虚拟线程留核」,调度器与业务线程池共享 CPU。
yield 时让出载体线程。一个死循环的虚拟线程会像平台线程一样占满一个核。迁移路线:先换执行器,再查 pinning01
- 第一步:替换线程池。把
Executors.newFixedThreadPool(n)换成Executors.newVirtualThreadPerTaskExecutor(),保持任务代码不变。 - 第二步:限制稀缺资源。用
Semaphore或连接池限制数据库/HTTP 并发,不要用线程池大小充当限流。 - 第三步:扫描 pinning。打开
jdk.tracePinnedThreads=full或 JFR,把synchronized中的阻塞调用改成ReentrantLock。 - 第四步:压测与观测。观察吞吐、延迟分位、载体线程数、连接池等待时间,确认瓶颈已转移。
// Before:固定线程池,队列堆满后开始拒绝
ExecutorService pool = Executors.newFixedThreadPool(200);
// After:每个任务一个虚拟线程,任务结束即销毁
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(50));
return "ok";
});
}
} // close() 等待所有任务结束
ExecutorService 在 Java 19 起实现 AutoCloseable,close() 会先停止接收新任务,再等待已提交任务完成。try-with-resources 是比 shutdown() + awaitTermination() 更简洁的写法。
用信号量给下游限流02
public class DownstreamGate {
private static final Semaphore DB_PERMITS = new Semaphore(20);
static String query(String id) throws Exception {
DB_PERMITS.acquire();
try {
return callDatabase(id); // 只有 20 个虚拟线程能同时进入
} finally {
DB_PERMITS.release();
}
}
}
- 虚拟线程数量可以是十万,但数据库连接只有几十个;信号量把「等待」留在虚拟线程层,避免连接池超时。
- 不要让信号量许可数大于连接池上限,否则等待会转移到连接池,观测上更难定位。
- HTTP 客户端自身通常有连接池,优先使用客户端内置限制,信号量用于没有内置限制的资源。
new ThreadPoolExecutor(200, 200, ..., new VirtualThreadFactory())。用线程池包虚拟线程等于给取号单设置固定窗口,既失去弹性,又容易误解并发上限。可运行示例:十万次阻塞任务03
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class VirtualThreadsDemo {
public static void main(String[] args) throws Exception {
AtomicInteger done = new AtomicInteger();
long start = System.nanoTime();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(10));
done.incrementAndGet();
return null;
});
}
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("done=" + done.get());
System.out.println("elapsedMs=" + ms);
System.out.println("isVirtual=" + Thread.ofVirtual().unstarted(() -> {}).isVirtual());
}
}
done=100000 elapsedMs≈1200(取决于机器,远小于 100000×10ms 的串行时间) isVirtual=true
十万个任务各睡 10ms,若串行需要 1000 秒;虚拟线程让它们在等待时互相让出载体线程,总耗时接近「任务提交 + 调度」的开销。实际数字受 CPU、内存和调度器影响,但量级差异就是这个特性的意义。
先猜再看:三个高频误解01
先猜再看虚拟线程阻塞时,它的栈会一直占用内存吗?
先猜再看把固定线程池的核心线程数设成 10000,是不是就等价于虚拟线程?
先猜再看JDK 21 里用了 synchronized 就一定会 pin 吗?
synchronized 块/方法内部发生阻塞时才会 pin。快速进出、不阻塞的同步块没有问题。JDK 24 的 JEP 491 已移除这一限制,但 JDK 21 项目仍需排查。费曼自检:能否讲清因果02
01. 用一句话解释虚拟线程为什么能开几十万个。
02. 为什么虚拟线程不适合 CPU 密集型任务?
03. 虚拟线程池化为什么是反模式?
04. 如何证明一次阻塞没有 pin 住载体线程?
-Djdk.tracePinnedThreads=full 或 JFR 的 jdk.VirtualThreadPinned 事件;没有输出/事件即未检测到钉住。