Java 虚拟线程:调度真相、pinning 陷阱与落地边界
一句话结论:虚拟线程优化的是"等待",不是"执行"。
它把线程的内存成本从 MB 级压到 KB 级、把上下文切换从内核态挪到用户态,但它对 CPU 密集代码毫无帮助,还会被
synchronized里的阻塞直接打回原形。
结论先说
| 场景 | 用不用虚拟线程 | 原因 |
|---|---|---|
| IO 密集(JDBC、HTTP 调用、Redis、文件) | ✅ 用 | 阻塞时让出载体线程,吞吐随并发数近似线性上升 |
| 并发线程数从几百涨到几万 | ✅ 用 | 这是虚拟线程唯一真正的设计目标 |
| CPU 密集(加解密、序列化、计算) | ❌ 别用 | 没有阻塞点可让出,白付栈拷贝成本,还拖慢调度器 |
| synchronized 包住的长时间阻塞(JDK < 24) | ⚠️ 先改代码 | 会 pin 住载体线程,退化成平台线程,严重时整个调度器停摆 |
| 重度依赖 ThreadLocal 传递上下文 | ⚠️ 先改造 | 百万虚拟线程 × 每个一份 Map,内存与 GC 压力同步放大 |
| 阻塞发生在 native / JNI 内部 | ⚠️ 谨慎 | JNI 调用期间无法卸载,等同 pin |
下面逐条拆。
一、先把平台线程的成本算清楚
Java 的线程模型一直是 1:1:一个 Thread 对象对应一个操作系统内核线程。
成本清单:
| 项目 | 量级 | 说明 |
|---|---|---|
| 线程栈 | 默认 1 MB(x64 Linux) | -Xss 可调,但栈是堆外内存,不算在 -Xmx 里 |
| 内核结构 | 数 KB | task_struct + 调度实体 |
| 创建/销毁 | 数十微秒 | 需要 clone() 系统调用 |
| 上下文切换 | 2~5 微秒 | 用户态 → 内核态 → 调度 → 回来 |
于是"线程数上不去"不是软件层面的限制,而是一笔实打实的账:
2000 个平台线程 × 1 MB 栈 ≈ 2 GB 虚拟地址空间
容器内存上限 = 堆 + 元空间 + 线程栈 + 直接内存 + 代码缓存
8 GB 的容器里给堆分 6 GB,剩下的空间根本撑不起几千个线程。而 Tomcat 的 maxThreads 默认只有 200,也不是保守,是这个模型下的合理值。
更难受的是利用率。假设每个请求耗时 50 ms,其中 45 ms 在等下游:
200 线程 / 0.05 s = 4000 QPS(理论上限)
但 CPU 真正干活的时间只有 5 ms / 50 ms = 10%
CPU 闲着 90%,吞吐却被线程数卡死。唯一的出路是加线程,而加线程会撞上内存墙和上下文切换开销——2000 个线程在 16 核机器上高频切换,sy 占比抬升,吞吐反而下降。这就是经典的"加线程越加越慢"。
这个矛盾就是虚拟线程要解决的全部问题。
二、虚拟线程的本质:Continuation + 载体线程
虚拟线程不是"绿色线程"的复刻,也不是协程的语法糖。它的本质是 JVM 自己管理的 Continuation。
一次完整的生命周期:
1. Thread.ofVirtual().start(task)
└─ 创建 Thread 对象(堆上)+ 一个 Continuation 对象
2. 调度器把任务丢进 ForkJoinPool
└─ 某个载体线程(carrier,就是普通平台线程)取到任务
3. mount:把虚拟线程的栈帧"挂"到载体线程上执行
└─ 此刻它就是一个普通线程,跑在某个核上
4. 遇到阻塞点(sleep / socket read / lock park)
└─ yield:栈帧被拷贝到堆上的 StackChunk,虚拟线程卸载
5. 载体线程立即去跑队列里的下一个虚拟线程
6. 阻塞就绪 → 栈帧从堆上恢复 → 重新 mount 到**任意一个**载体线程继续跑
三个必须记住的推论:
1)栈在堆上,-Xss 对它无效。
虚拟线程的栈按需增长,初始只有几百字节,全部计入堆内存。所以你要盯的是 -Xmx 和 GC 日志,而不是 -Xss。大量虚拟线程同时持有深调用栈时,堆会被撑起来——这点和平台线程的直觉完全相反。
2)载体线程数默认等于 CPU 核数。
调度器的并行度由 jdk.virtualThreadScheduler.parallelism 控制(默认 availableProcessors()),载体线程总数上限由 jdk.virtualThreadScheduler.maxPoolSize 控制(默认 256)。
所以一台 16 核机器上,同一时刻最多只有 16 个虚拟线程在真正执行。虚拟线程让"等待"变便宜,但没让"执行"变多。
3)调度是非抢占的。
虚拟线程只在阻塞点让出载体线程,没有时间片轮转。一个跑死循环的虚拟线程会像平台线程一样霸占载体——这决定了它对 CPU 密集任务毫无收益。
三、哪些操作会让出载体线程
JDK 21 为虚拟线程重写了整套阻塞点,覆盖范围比大多数人以为的广:
| 类别 | 具体 API |
|---|---|
| 网络 | Socket / ServerSocket / DatagramSocket 的读写、SocketChannel、Selector |
| 文件 | FileInputStream / FileOutputStream / FileChannel(JDK 21 起不再 pin) |
| 并发工具 | ReentrantLock、Semaphore、CountDownLatch、BlockingQueue、CompletableFuture.get |
| 基础阻塞 | Thread.sleep、LockSupport.park、Object.wait(JDK 24 前会 pin,见下节) |
| 进程 | Process.waitFor |
| HTTP | java.net.http.HttpClient 同步调用 |
一个最直观的例子——百万级并发连接,平台线程时代根本不可能:
public class EchoServer {
public static void main(String[] args) throws IOException {
try (ServerSocket ss = new ServerSocket(9090)) {
while (true) {
Socket socket = ss.accept(); // 阻塞点
Thread.ofVirtual()
.name("echo-" + socket.getPort())
.start(() -> handle(socket)); // 一个连接一个虚拟线程
}
}
}
static void handle(Socket socket) {
try (socket;
var in = socket.getInputStream();
var out = socket.getOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) { // 阻塞点 → 自动卸载
out.write(buf, 0, n);
}
} catch (IOException ignored) {
}
}
}
ss.accept() 和 in.read() 都是阻塞点。十万个空闲连接只占用十万个轻量对象,载体线程仍然是那 16 个。
如果用平台线程写同样一段代码,一万个连接就要 10 GB 栈空间。
四、pinning:虚拟线程真正的天花板
4.1 什么是 pin
虚拟线程遇到阻塞时本来应该卸载。但有些情况下 JVM 不允许卸载,只能让载体线程跟着一起卡住——这叫 pinning(钉住)。
后果极其严重。回到那个数字:16 核机器,parallelism = 16。
只要有 16 个虚拟线程同时钉在
synchronized里等 IO,整个调度器就停摆了。
此时你有 100 万个虚拟线程也没用,它们全在队列里排队,一个都跑不起来。
4.2 三个 pin 的来源
| 来源 | 说明 | 解决 |
|---|---|---|
| synchronized 块内阻塞 | JDK 24 之前,monitor 与载体线程绑定,无法卸载栈帧 | JDK 24 起由 JEP 491 解决;此前改用 ReentrantLock |
| native 方法 / JNI 内部阻塞 | JNI 调用期间线程状态由本地代码掌控 | 无法根治,改造成异步或隔离到独立执行器 |
| 类初始化(<clinit>)期间阻塞 | 类初始化锁是 JVM 内部 monitor | 避免在静态初始化里做 IO |
4.3 反面示例与修复
// ❌ JDK 21/23 上:sleep 期间钉住载体线程
private final Object lock = new Object();
public String query() throws Exception {
synchronized (lock) {
return httpClient.send(request, BodyHandlers.ofString()).body(); // pin!
}
}
// ✅ 换成 ReentrantLock:lock 是 AQS 实现,阻塞点可被虚拟线程识别
private final ReentrantLock lock = new ReentrantLock();
public String query() throws Exception {
lock.lock();
try {
return httpClient.send(request, BodyHandlers.ofString()).body();
} finally {
lock.unlock();
}
}
4.4 怎么把 pin 抓出来
方式一:启动参数(JDK 21~23 有效)
java -Djdk.tracePinnedThreads=full -jar app.jar
出现 pin 时会打印完整堆栈,末尾带标记:
Thread[#42,ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
...
app.service.OrderService.query(OrderService.java:31) <== monitors:1
monitors:1 就是在告诉你:这里有一把 monitor 锁导致了 pin。
方式二:JFR 事件(生产环境首选)
jcmd <pid> JFR.start name=vt settings=profile duration=60s filename=vt.jfr
看 jdk.VirtualThreadPinned 事件(默认只记录超过 20 ms 的 pin),eventThread 是被钉住的虚拟线程,stackTrace 直接指向元凶。
方式三:线程转储
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
JSON 里虚拟线程带 virtual: true,被钉住的会标出载体线程归属。
4.5 JDK 24 之后
JEP 491 让 synchronized 在阻塞时也能卸载虚拟线程,这是虚拟线程能不能大规模落地的最关键一步。但注意:
- native/JNI 阻塞仍然 pin
- 类初始化阻塞仍然 pin
- 已经在用 JDK 21 的项目,不要指望升级前
synchronized能自动变好
五、线程池:该拆的拆,该留的留
5.1 不要池化虚拟线程
池化的前提是"创建昂贵",而虚拟线程的创建成本在几百纳秒到 1 微秒量级,和大对象分配差不多。复用没有任何收益,反而引入队头阻塞。
// ✅ 一个任务一个虚拟线程,随用随建
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id : ids) {
executor.submit(() -> service.handle(id));
}
} // close() 会等待所有任务结束
// ❌ 反模式:把虚拟线程塞进固定大小池,等于自废武功
ExecutorService pool = new ThreadPoolExecutor(
200, 200, 0, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
Thread.ofVirtual().factory()); // 池子只有 200,虚拟线程的意义归零
5.2 但下游资源必须限流
虚拟线程让"等待"免费,没让"资源"变多。数据库连接池还是 50 条,HTTP 连接池还是 200 条。如果放任十万个虚拟线程同时打过去,只会把连接池打爆、把下游打死。
用 Semaphore 把并发收敛到下游真实容量:
private static final Semaphore DB_PERMITS = new Semaphore(50);
public void handle(long id) throws Exception {
DB_PERMITS.acquire(); // 阻塞点,虚拟线程友好
try {
jdbcTemplate.queryForObject("select ... where id = ?", Row.class, id);
} finally {
DB_PERMITS.release();
}
}
经验法则:虚拟线程的数量上限 = 下游能承受的并发,不是 CPU 核数,也不是"越多越好"。
六、ThreadLocal:被放大的隐性成本
这是迁移时最容易踩、也最容易被忽略的一处。
坑 1:虚拟线程默认不继承 inheritable thread locals。
Thread.ofVirtual().start(task); // inheritInheritableThreadLocals = false
// 需要显式开启
Thread.ofVirtual()
.inheritInheritableThreadLocals(true)
.start(task);
老代码里靠 InheritableThreadLocal 传递 traceId / 用户上下文的,切到虚拟线程后会静默丢上下文——不报错,只是日志里 traceId 变成 null。
坑 2:数量放大。
每个虚拟线程都是一个 Thread 对象,ThreadLocalMap 挂在它上面。十万并发 = 十万份 Map。如果往里塞的是缓冲区、SimpleDateFormat、大对象,内存和 GC 压力会成倍放大。
// ❌ 平台线程时代的"优化",虚拟线程时代变成灾难
private static final ThreadLocal<byte[]> BUF = ThreadLocal.withInitial(() -> new byte[64 * 1024]);
坑 3:迁移方向。
JDK 21 引入、JDK 25 转正的 ScopedValue 是官方给的正解——不可变、有明确作用域、随作用域自动清理,不会有"忘记 remove 导致泄漏"的问题:
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public void entry(String traceId) {
ScopedValue.where(TRACE_ID, traceId).run(this::handle);
}
void handle() {
log.info("trace={}", TRACE_ID.get()); // 作用域内可读,出作用域即失效
}
在 ScopedValue 还不能用的版本上,至少要做到:只放小对象,用完必须 remove()。
七、一个可复现的对比实验
不要信"快 100 倍"这种说法,自己跑一遍。下面这段代码在任意 JDK 21+ 上可运行,模拟 20000 个任务、每个任务阻塞 50 ms(典型的下游 RT):
import java.time.Duration;
import java.util.concurrent.*;
import java.util.function.IntFunction;
public class VtBench {
static final int TASKS = 20_000;
static final Duration IO = Duration.ofMillis(50);
static void io() {
try {
Thread.sleep(IO);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
try (var pool = Executors.newFixedThreadPool(200)) {
run("platform-200", i -> pool.submit(VtBench::io));
}
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
run("virtual", i -> pool.submit(VtBench::io));
}
}
static void run(String name, IntFunction<Future<?>> submitter) throws Exception {
long t0 = System.nanoTime();
var futures = new Future<?>[TASKS];
for (int i = 0; i < TASKS; i++) futures[i] = submitter.apply(i);
for (var f : futures) f.get();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("%-14s %6d ms %,d tasks/s%n", name, ms, TASKS * 1000L / ms);
}
}
预期结果(16 核 / JDK 21,量级参考,具体数字取决于机器):
| 实现 | 耗时 | 吞吐 |
|---|---|---|
| newFixedThreadPool(200) | ≈ 5000 ms | ≈ 4000 tasks/s |
| newVirtualThreadPerTaskExecutor() | ≈ 100~300 ms | ≈ 10 万 tasks/s |
平台线程那一栏的算式很死板:20000 / 200 × 50 ms = 5000 ms,跟核数无关,纯粹被线程池大小锁死。
虚拟线程那一栏的下限是 50 ms(一次睡眠的时长),剩下的都是调度与栈拷贝开销。
但把这 50 ms 换成 CPU 空转 for 循环,结论立刻反转——虚拟线程不但没有收益,还会因为栈拷贝和调度开销比平台线程更慢。这正是很多人"试了一下觉得没用"的原因:压测场景选错了。
八、上线前的 7 项检查
| # | 检查项 | 怎么查 | 期望结果 |
|---|---|---|---|
| 1 | 是否还有 synchronized 包住 IO | grep -rn "synchronized" src/,逐个看块内是否有 DB/RPC/文件操作 | 全部换成 ReentrantLock |
| 2 | 是否有残留 pin | -Djdk.tracePinnedThreads=full 或 JFR jdk.VirtualThreadPinned | 事件数为 0 或全部 < 20 ms |
| 3 | 下游是否限流 | 检查 DB 连接池、HTTP 连接池、Redis 连接池上限 | 有独立的并发闸门(Semaphore / 连接池本身) |
| 4 | 上下文传递 | 全局搜 InheritableThreadLocal、ThreadLocal | 已改 ScopedValue 或显式传参 |
| 5 | 堆是否够 | -Xmx 是否按"平台线程栈"时代的经验值配的 | 虚拟线程栈走堆,堆要相应上调并观察 GC |
| 6 | 载体线程数 | -Djdk.virtualThreadScheduler.parallelism 是否被误改 | 保持默认(= 核数),除非有明确压测依据 |
| 7 | 有没有把它用在 CPU 密集路径 | 看线程 dump 里载体线程的 RUNNABLE 占比 | CPU 密集任务仍走固定线程池 |
九、决策树
这个任务会不会阻塞?
├─ 不会(纯计算)
│ └─ 用固定线程池,核数 + 1。虚拟线程无收益,还多付栈拷贝
└─ 会阻塞
├─ 阻塞发生在 synchronized 内?
│ ├─ JDK ≥ 24 → 可以,JEP 491 已解决
│ └─ JDK < 24 → 先改 ReentrantLock,否则 pin 住载体线程
├─ 阻塞发生在 native / JNI 内?
│ └─ 隔离到独立执行器,别和虚拟线程混跑
├─ 依赖 ThreadLocal 传上下文?
│ └─ 改 ScopedValue 或显式传参,否则内存放大
└─ 都没问题
└─ 用 newVirtualThreadPerTaskExecutor,
并用 Semaphore 把并发收敛到下游容量
十、写在最后
虚拟线程的价值不在"更快",而在让阻塞式编程模型重新变得可扩展。
过去十年 Java 生态为了扛并发,走了很长一段异步化弯路:回调、CompletableFuture 编排、Reactor 的背压与调度器——好用的代价是调试栈变长、异常栈丢失、ThreadLocal 失效、新人上手门槛陡增。
虚拟线程把这笔账还了回来:代码还是同步阻塞的写法,线程栈还是能一眼看懂的调用链,但并发数可以上到十万级。 但它有三条硬边界,越过去就会翻车:
- 它不加速 CPU——只在阻塞点让出;
- 它怕
synchronized里的阻塞——JDK 24 前是硬伤; - 它不创造资源——下游连接池该多大还是多大。
理解这三点,就不会再问"为什么我上了虚拟线程 QPS 没涨"。
系列内链
- 前置:[02-集合与并发/07-线程池核心参数与调优](07-线程池核心参数与调优.md)、[02-集合与并发/05-synchronized与锁升级](05-synchronized与锁升级.md)
- 关联:[02-集合与并发/06-AQS原理](06-AQS原理.md)——
ReentrantLock为什么能被虚拟线程识别,答案在 AQS 的park点上 - 关联:[02-集合与并发/09-ThreadLocal原理与内存泄漏](09-ThreadLocal原理与内存泄漏.md)——
ScopedValue想取代的正是它 - 关联:[03-JVM/07-线上OOM排查实战](../03-JVM/07-线上OOM排查实战.md)——虚拟线程栈在堆上,OOM 形态会变
- 点赞
- 收藏
- 关注作者
评论(0)