虚拟线程速查
学完你要能自己回答
- 虚拟线程与平台线程、线程池分别是什么关系?为什么不应该池化虚拟线程?
- JDK 21 中哪些操作会导致 pinning?如何检测和规避?
- 什么任务不适合虚拟线程?为什么 CPU 密集任务不会变快?
创建与运行01
JDK 21 提供三种入口:Thread.ofVirtual()、Thread.startVirtualThread()、Executors.newVirtualThreadPerTaskExecutor()。生产代码优先用执行器,便于统一关闭和异常处理。
// 1. 直接启动
Thread t = Thread.startVirtualThread(() -> {
System.out.println("virtual=" + Thread.currentThread().isVirtual());
});
t.join();
// 2. Builder:命名、继承性等
Thread named = Thread.ofVirtual()
.name("order-vt-", 0)
.start(() -> handleOrder());
// 3. 执行器:每个任务一个虚拟线程
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f = executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
System.out.println(f.get());
}
- 虚拟线程默认是守护线程,不会阻止 JVM 退出。
- 虚拟线程没有「线程池」概念:每个任务创建一条,用完即弃,创建成本极低。
- 不要用
ThreadPoolExecutor池化虚拟线程;池化会限制并发,也失去轻量优势。 ExecutorService.close()会等待已提交任务完成;适合请求级或批处理级作用域。
调度模型:载体线程与挂载02
虚拟线程由 JVM 调度到少量载体线程(carrier threads)上运行。载体线程是平台线程,默认数量等于 CPU 核数。虚拟线程阻塞时,JVM 把它从载体上卸下(unmount),载体去运行别的虚拟线程;阻塞结束后再重新挂载。
关键推论:虚拟线程不加速 CPU 计算。如果任务一直在算,没有阻塞点,它和平台线程一样占用一个核;创建一百万个也只会排队。虚拟线程的收益来自「大量任务在等待 I/O」。
// 10,000 个任务各睡 1 秒:虚拟线程接近 1 秒完成
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
long start = System.nanoTime();
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return null;
});
}
// close() 等待全部完成
}
System.out.println("done");
Pinning:JDK 21 的头号雷区03
当虚拟线程在无法卸载的情况下阻塞,它会钉住(pin)载体线程。JDK 21 中常见原因有两个:在 synchronized 块 / 方法内阻塞,以及阻塞在 native 方法或外部函数调用中。被钉住的载体无法执行其他虚拟线程,严重时退化为平台线程模型。
private final Object lock = new Object();
// JDK 21:sleep 在 synchronized 内会 pin 载体
synchronized (lock) {
Thread.sleep(Duration.ofSeconds(1)); // 不要这样写
}
// 改法一:缩小 synchronized 范围,把阻塞移出去
synchronized (lock) {
updateState();
}
Thread.sleep(Duration.ofSeconds(1));
// 改法二:用 ReentrantLock,虚拟线程可正常卸载
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(Duration.ofSeconds(1));
} finally {
lock.unlock();
}
- JDK 21 检测:启动参数
-Djdk.tracePinnedThreads=full会打印 pinning 堆栈。 - JFR 事件
jdk.VirtualThreadPinned记录超过阈值(默认 20ms)的钉住。 - JDK 24 的 JEP 491 已消除
synchronized导致的 pinning;JDK 21 仍需注意。 - 并非所有 synchronized 都 pin:只有在其内部发生阻塞时才会。短临界区通常没问题。
Object.wait()等监视器等待也可能导致 pinning;高并发代码优先用ReentrantLock/Condition。
jdk.VirtualThreadPinned 事件或吞吐异常时,再针对性修改。并发控制:虚拟线程不是无限资源04
虚拟线程很便宜,但下游资源不是:数据库连接、HTTP 连接池、文件句柄、下游服务容量都有限。创建一百万虚拟线程去调同一个数据库,只会把压力转移给连接池或下游。
Semaphore permits = new Semaphore(100); // 限制同时访问下游的请求数
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> {
permits.acquire();
try {
return callDownstream(req); // 真正受限于 100 个许可
} finally {
permits.release();
}
});
}
}
- 连接池大小仍然是硬上限;虚拟线程只改变「等待连接」的成本,不改变连接数量。
- 给外部调用设置超时,避免虚拟线程永久阻塞。
- 批处理场景可以用
StructuredTaskScope(预览)统一取消子任务。 - 不要把虚拟线程与固定线程池混用:池化虚拟线程没有意义。
JDK 21 的 StructuredTaskScope 仍是预览 API,需要 --enable-preview。它把一组子任务绑定到一个作用域:任一失败可取消其余任务,离开作用域前统一 join。
ThreadLocal、上下文与迁移05
虚拟线程支持 ThreadLocal,但数量可能达到百万级;每个线程一份副本会放大内存。传统「线程池 + ThreadLocal 缓存」的优化在虚拟线程下可能适得其反。
- 请求上下文优先考虑 Scoped Values(JDK 21 预览,25 正式):不可变、作用域结束自动失效。
- 必须用 ThreadLocal 时,在请求结束时
remove(),避免长生命周期虚拟线程持有大对象。 - 不要用 ThreadLocal 做「线程级缓存」;虚拟线程不复用,缓存命中率低。
- 日志 MDC 等框架上下文需确认是否兼容虚拟线程与 Scoped Values。
// JDK 21 预览,需要 --enable-preview
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
handle(); // 在此作用域内可读
});
static void handle() {
System.out.println(REQUEST_ID.get()); // req-42
}
观测与排障06
虚拟线程的排障工具与平台线程不同:传统 jstack 只能看到载体线程,虚拟线程需要 JSON 线程转储或 JFR。
# 打印 pinning 堆栈(仅 JDK 21 需要)
java -Djdk.tracePinnedThreads=full -jar app.jar
# JSON 线程转储(包含虚拟线程)
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JFR 记录虚拟线程事件
jcmd <pid> JFR.start duration=60s filename=vt.jfr
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned vt.jfr
Thread.currentThread().isVirtual()可在日志中区分线程类型。- 虚拟线程默认命名类似空字符串;用
Thread.ofVirtual().name(...)命名便于定位。 - 线程本地随机数:JDK 21 的
ThreadLocalRandom对虚拟线程友好;Random共享实例仍有竞争。 - CPU 火焰图仍然按载体线程采样;虚拟线程等待时间要看 JFR 事件,不要只看 CPU。
版本边界BOUNDARY
这张表比 API 本身更值得记。写代码时先确认你的运行时版本。
| 特性 / API | 状态 | 说明 |
|---|---|---|
| 虚拟线程 | 21 正式 | JDK 19–20 预览;JEP 444。 |
| synchronized pinning | 21 存在 / 24 消除 | JDK 21 需规避;JDK 24 JEP 491 移除该限制。 |
| 结构化并发 | 21 预览 | 需要 --enable-preview,后续版本持续预览。 |
| Scoped Values | 21 预览 / 25 正式 | JDK 21 不能直接用于生产。 |
| Thread.ofVirtual / startVirtualThread | 21 正式 | JDK 19–20 为预览 API。 |
费曼自检SELF CHECK
先合上页面,用自己的话回答,再展开对照。能讲出因果和反例,才算真正掌握。