承接的问题
第 1 章算过平台线程的成本:每条线程对应一个内核线程,默认栈 1 MB,创建和销毁都要进内核。业务上「一个请求一条线程」的写法在 1 000 并发时还能撑,到 10 000 就变成内存和调度器一起告警。第 10 章的线程池把线程数钉死在 core/max 上,用排队换稳定,但排队意味着请求延迟随负载线性上升。
虚拟线程换了一个思路:线程数量不再等于内核线程数量。阻塞不再占着一条内核线程,任务被挂起时把它从执行线程上摘下来,等条件满足再找一条空闲的执行线程放回去。这一章先把「谁在执行虚拟线程」讲清楚,后面两节再讲 pinning 和池化。
1:1 与 M:N
平台线程的模型是 1:1:一个 Thread 对象绑定一个内核线程,JVM 只是薄薄一层包装。虚拟线程是 M:N:M 条虚拟线程映射到 N 条**载体线程(carrier thread)**上,N 默认等于 Runtime.getRuntime().availableProcessors()。
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 映射 | 1:1 到内核线程 | M:N 到载体线程 |
| 栈 | 原生栈,默认约 1 MB | 堆上的栈片段,按需增长 |
| 创建成本 | 系统调用级,微秒到毫秒 | 对象分配级,纳秒到微秒 |
| 阻塞代价 | 占用内核线程 | 卸载,载体去跑别的虚拟线程 |
| 调度者 | 操作系统 | JDK 的 ForkJoinPool |
| 适合 | CPU 密集、需要线程本地状态 | IO 密集、大量阻塞等待 |
本机观察到的载体线程确实是一个 ForkJoinPool:
carrier workers (ForkJoinPool-*-worker-*) = 10
10 核机器上 10 条 worker,名字形如 ForkJoinPool-1-worker-3。虚拟线程自己不在 Thread.getAllStackTraces() 里,也不出现在 jcmd Thread.print 的线程列表中——排查时要意识到这一点,卷三第 6 章会讲怎么把两者对上。
mount、unmount 与栈片段
虚拟线程的执行状态由 continuation(续体) 表示。当它被调度到某条载体线程上运行,叫 mount;当它执行一个会阻塞的操作,JDK 把当前栈帧冻结成 栈片段(stack chunk) 存到堆上,把载体线程让出去,叫 unmount。条件满足后,调度器再找一条载体线程,把栈片段恢复成可执行的栈,继续跑。
这里有两个容易误解的点。
第一,栈不是没有成本,只是从原生内存搬到了堆。 一个刚创建的虚拟线程只分配很小的初始栈片段,随着调用深度增加再申请新的片段;unmount 时这些片段挂在虚拟线程对象上,仍然占堆。所以「虚拟线程很轻」是相对 1 MB 原生栈而言,不是零成本。下一节的实测会给出每条约 1 KB 的量级。
第二,只有「可被 JDK 感知的阻塞」才会 unmount。 Thread.sleep、Object.wait、LockSupport.park、BlockingQueue.take、Semaphore.acquire、Future.get、socket 读写这些都能让虚拟线程卸载。JNI 调用、类初始化边界则可能把载体一起占住,那是 14.2 的主题。
Thread.ofVirtual() 创建的就是虚拟线程,isVirtual() 可以判定:
Thread vt = Thread.ofVirtual().name("loan-vt").start(() -> {
System.out.println(Thread.currentThread().isVirtual()); // true
});
vt.join();
注意 Thread.getId() 在 JDK 25 已弃用,日志和排查统一用 threadId();虚拟线程的 ID 同样唯一,但它和内核线程 ID 没有对应关系。虚拟线程还固定是守护线程,setDaemon(false) 会抛 IllegalArgumentException,所以它不会阻止 JVM 退出。
一次 mount 的生命周期
把一次执行拆开看,虚拟线程在载体上的一个时间片大致是:
- 调度器从队列取出一条可运行的虚拟线程,把它的栈片段恢复到某条载体线程的栈上(mount)。
- 虚拟线程执行 Java 代码,和普通线程没有区别,CPU 时间照算。
- 遇到
park类操作,JDK 把栈帧冻结成新的栈片段,记录阻塞原因,把载体还回池里(unmount)。 - 阻塞条件满足,虚拟线程进入可运行队列,等待下一次 mount。
关键在第 3 步:是否 unmount 取决于阻塞操作是不是 JDK 自己实现的。下表是本机 JDK 25 上的实际行为,前四行是 14.2 会实测的边界:
| 操作 | 是否 unmount | 说明 |
|---|---|---|
Thread.sleep / LockSupport.park | 是 | 走 VirtualThread.park,栈片段入堆 |
Object.wait / synchronized 内阻塞 | 是 | JEP 491 起监视器不再 pin |
BlockingQueue.take / Semaphore.acquire | 是 | JUC 同步器最终都落到 park |
| socket 读写 | 是 | NIO 轮询,不占载体 |
FileInputStream.read | 否(Blocker 补偿) | JDK 内部标记阻塞,调度器临时扩载体;JFR 无 pin 事件 |
| 任意 JNI / FFM 调用 | 否 | 原生帧在载体栈上,载体被占住 |
类初始化 <clinit> 中阻塞 | 否 | 初始化锁不是 monitor |
FileInputStream 这一行容易被误读成「文件 IO 不阻塞」。它仍然阻塞,只是 JDK 知道这是一次阻塞调用,会临时增加载体来补偿,避免整个调度器停摆。自己写的 JNI 没有这个标记,所以会实打实占住载体。
吞吐从哪里来
设一个请求的端到端时间是 T,其中真正用 CPU 的时间是 C,剩下 T - C 都在等下游。平台线程模型下,同时处理的请求数受线程数限制;虚拟线程模型下,受载体数和 C/T 限制:
可并发的阻塞任务数 ≈ 载体数 × T / C
10 条载体、每次下游往返 50 ms、CPU 计算 0.5 ms,理论上能同时挂住约 1 000 个请求。这不是精确容量规划,但解释了为什么虚拟线程对「等待占比高」的服务收益大:C/T 越小,同样的载体能撑的并发越多。反过来,C/T 接近 1 的 CPU 密集任务,公式退化成「并发≈载体数」,开更多虚拟线程只会增加切换。
也正因为如此,虚拟线程的调度器不做优先级、不做公平性承诺,它只保证可运行的任务最终会被某条载体取走。需要顺序或优先级,得在业务层自己排队。
10 万个虚拟线程的实测
光说「很轻」没有意义。下面这段程序创建 10 万个虚拟线程,让它们全部阻塞在同一个闩上,测量启动耗时和堆占用,然后再拿平台线程做同样的尝试。
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class VirtualThreadScale {
static final int N = 100_000;
public static void main(String[] args) throws Exception {
System.out.println("java.version = " + System.getProperty("java.version"));
System.out.println("-Xmx = " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB");
for (int round = 1; round <= 3; round++) {
System.gc();
Thread.sleep(300);
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
long before = mem.getHeapMemoryUsage().getUsed();
CountDownLatch ready = new CountDownLatch(N);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger virtualCount = new AtomicInteger();
List<Thread> threads = new ArrayList<>(N);
long t0 = System.nanoTime();
for (int i = 0; i < N; i++) {
threads.add(Thread.ofVirtual().name("vt-" + i).start(() -> {
if (Thread.currentThread().isVirtual()) virtualCount.incrementAndGet();
ready.countDown();
try { release.await(); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
long created = System.nanoTime();
boolean allReady = ready.await(180, TimeUnit.SECONDS);
long readyAt = System.nanoTime();
long after = mem.getHeapMemoryUsage().getUsed();
release.countDown();
for (Thread t : threads) t.join();
System.out.printf("round %d: allReady=%s created=%d virtual=%d start=%dms ready=%dms total=%dms heapDelta=%.1fMB used=%.1fMB%n",
round, allReady, N, virtualCount.get(),
(created - t0) / 1_000_000, (readyAt - t0) / 1_000_000,
(System.nanoTime() - t0) / 1_000_000,
(after - before) / 1024.0 / 1024.0, after / 1024.0 / 1024.0);
}
}
}
完整程序在虚拟线程循环之外还统计载体数、尝试创建 10 万条平台线程。本机输出(Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8;运行前 load averages: 125.18,平台线程阶段把负载推到 587.15):
java.version = 25.0.5
-Xmx = 512 MB
round 1: allReady=true created=100000 virtual=100000 start=132ms ready=133ms total=1207ms heapDelta=101.2MB used=102.5MB
round 2: allReady=true created=100000 virtual=100000 start=103ms ready=103ms total=1383ms heapDelta=86.2MB used=90.0MB
round 3: allReady=true created=100000 virtual=100000 start=91ms ready=112ms total=469ms heapDelta=85.5MB used=89.2MB
carrier workers (ForkJoinPool-*-worker-*) = 10
--- platform threads, target=100000 ---
[7.223s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 512k, guardsize: 16k, detached.
[7.223s][warning][os,thread] Failed to start the native thread for java.lang.Thread "pt-8152"
platform creation stopped at 2854 threads after 3141ms
thrown: java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
三个数字值得记住:
- 堆占用约 89–103 MB,10 万条阻塞的虚拟线程平均每条 0.85–1.05 KB。这是
-Xmx512m下测的,不是「不占内存」。 - 启动 91–132 ms,且三轮都成功创建 10 万条。这里的负载很高,绝对时间会抖动,但「毫秒级启动 10 万条」这个量级是稳定的。
- 平台线程在约 8 152 条时失败。日志里失败的是
pt-8152,即第 8 153 次start()抛OutOfMemoryError;脚本打印的 2 854 是「任务体已经跑到计数器」的条数,当时负载被推到 587,大量已创建的线程还没被调度,所以这个数只反映调度进度,不是创建上限。ulimit -u显示 5 333,实际到 8 152 条才EAGAIN,macOS 的线程计数和其他进程占用让这个值不能直接照搬;结论不变——这是进程/线程数资源上限,不是调大-Xmx能解决的。
吞吐来源因此不是「虚拟线程跑得快」。单条任务的计算速度没变,甚至因为 mount/unmount 多了一点开销。收益来自两点:阻塞时载体可以复用,以及每条并发任务的内存成本从 0.5–1 MB 降到约 1 KB。对 IO 密集服务,这两点直接把「能同时等多少个下游」提高两三个数量级。
时间拆分也要看仔细:start 是 10 万次 start() 调用回到循环结束的时间,ready 是 10 万个任务都跑到 ready.countDown() 的时间,两者只差几毫秒,说明创建和首次 mount 都很快。total 里大头是 release.countDown() 之后 10 万条线程重新排队、被载体逐个唤醒、再逐个 join() 的时间;在高负载机器上这部分从 357 ms 抖到 1 280 ms,属于调度排队,不是创建成本。
堆的数字同样要拆开看:used=102.5 MB 里,绝大部分对应 10 万条线程对象、闩的回调对象和各自的栈片段。平均每条约 1 KB,和「初始栈片段很小、按需增长」的实现一致。如果任务调用链更深,这个数字会上升,所以容量评估要按实际栈深度留余量,而不是背「一条 1 KB」。
排查时有两个结构性抓手。jcmd <pid> Thread.print 只会列出平台线程,虚拟线程不在其中,但你能看到 ForkJoinPool-1-worker-* 这 N 条载体;JFR 的 jdk.VirtualThreadStart / jdk.VirtualThreadEnd 才记账虚拟线程的创建与结束:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
jcmd <pid> Thread.print | grep -c 'ForkJoinPool-.*-worker'
jcmd <pid> JFR.start name=vt settings=profile duration=10s filename=/tmp/vt.jfr
jfr summary /tmp/vt.jfr | grep -i virtual
这两条命令的组合能回答「有多少载体」和「创建了多少虚拟线程」两个不同的问题,别拿其中一个当另一个的答案。
在 libraryd-conc 里落地
VirtualLoanService 把借阅成功后的三路通知放到虚拟线程上。reserve 的库存扣减仍然要互斥(synchronized 在 JDK 25 不再 pin,14.2 会验证),但通知扇出不占用调用方线程:
public final class VirtualLoanService implements LoanService, AutoCloseable {
private final BookRepository books;
private final LoanRepository loans;
private final ExecutorService fanout = Executors.newVirtualThreadPerTaskExecutor();
public VirtualLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public Reservation reserve(String isbn, String memberId) {
Reservation reservation = reserveLocked(isbn, memberId);
notifyDownstream(reservation);
return reservation;
}
private synchronized Reservation reserveLocked(String isbn, String memberId) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Loan loan = new Loan("L-" + isbn + "-" + memberId, isbn, memberId,
Instant.now().plusSeconds(86_400));
loans.save(loan);
return new Reservation(loan.loanId(), isbn, memberId, loan.dueAt());
}
private void notifyDownstream(Reservation r) {
for (String channel : List.of("mail", "sms", "push")) {
fanout.submit(() -> send(channel, r));
}
}
private void send(String channel, Reservation r) {
// 真实实现调用下游客户端;这里只保留调度骨架
System.out.println(channel + " -> " + r.loanId()
+ " on " + Thread.currentThread());
}
@Override
public boolean returnBook(String loanId) {
return returnLocked(loanId);
}
private synchronized boolean returnLocked(String loanId) {
Loan loan = loans.findById(loanId)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loanId));
Book book = books.findByIsbn(loan.isbn())
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loan.isbn()));
books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
return true;
}
@Override
public void close() throws InterruptedException {
fanout.shutdown();
fanout.awaitTermination(5, TimeUnit.SECONDS);
}
}
newVirtualThreadPerTaskExecutor() 每个任务一条新虚拟线程,不要换成固定大小的池。理由在 14.3 展开:虚拟线程本来就是为「用完就扔」设计的,池化会把有限的载体又变成稀缺资源。
什么时候不要用虚拟线程
| 场景 | 为什么不合适 | 替代 |
|---|---|---|
| 纯 CPU 计算 | 没有阻塞可卸载,10 万条只会争抢 10 条载体 | 固定大小平台线程池 / ForkJoin |
| 极短的无阻塞任务 | 创建和调度开销高于收益 | 直接在当前线程跑 |
| 需要长时间占住原生栈的 JNI | native 调用期间 pin 住载体 | 专用平台线程池 |
| 依赖线程池做限流 | 虚拟线程数量不再是天然上限 | Semaphore(14.3) |
需要 ThreadLocal 在线程间复用缓存 | 每条虚拟线程一份,池化收益消失 | 显式传参 / ScopedValue |
还有一条常被忽略:虚拟线程解决的是「等」的成本,不解决「下游能不能承受」。10 万条虚拟线程同时打一个只有 20 个连接的数据库,结果只是把连接池的等待队列换了个地方。14.3 会把限流补上。
小结
虚拟线程是 M:N 调度:M 条虚拟线程跑在 N 条 ForkJoinPool 载体上,N 默认等于核数。阻塞操作触发 unmount,栈被冻结成堆上的栈片段,载体去执行别的任务。它的价值不是单任务更快,而是把每条阻塞任务的成本从约 1 MB 原生栈降到约 1 KB 堆,并且不再占用内核线程。
本机实测:-Xmx512m 下 10 万条阻塞虚拟线程占 89–103 MB 堆、91–132 ms 创建完成,载体线程 10 条;平台线程在第 8 153 次 start() 时 pthread_create 失败。下一节讨论剩下的 pinning:JDK 24 的 JEP 491 移除了 synchronized 导致的 pin,但 native 和类初始化边界还在。
阅读导航:上一节 13.3 上下文传递与线程池隔离 | 下一节 14.2 pinning:JDK 25 还剩什么
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。