承接的问题
容量评审会上,运维问了一个很具体的问题:把 libraryd-conc 的借阅查询从 800 个平台线程的池换成虚拟线程,能不能直接开 100 万个。
这个问题不能用一个「能」或「不能」回答,它至少拆成四个可测的子问题:
- 创建 100 万个虚拟线程要多久?如果创建本身要 30 秒,那它就不适合放在请求链路上。
- 它们占多少堆?如果一条要 10 KB,100 万条就是 10 GB,默认堆直接 OOM。
- 占多少个操作系统线程?这一条决定了它在容器里会不会把
nproc配额吃穿。 - 出问题的时候,运维用什么命令看见这 100 万个线程?如果
jstack只能看到 17 个,那排障流程要重写。
本节把四个问题都跑一遍,给出本机数字和它们的数量级。需要先说明口径:所有数字都是单次运行,机器同期 load average 在 37–47 之间波动,只作量级参考,不是基准测试。
先算内存,再决定跑多大
虚拟线程的栈不是操作系统栈,是堆上的 Continuation 栈块。所以「能开多少」首先是堆的问题,不是线程数的问题。
经验值:一条刚创建、栈还很浅的虚拟线程大约占 0.5–1 KB 堆。按 1 KB 估,100 万条约 1 GB。本机 32 GB 物理内存,JVM 默认最大堆是 1/4,也就是 8192 MB(实测 maxHeap=8192MB)。1 GB 只占默认堆的 1/8,余量充足。
于是实验顺序是:先用默认堆跑 10 万,量出实际的每线程字节数,再按实测值推算 100 万,确认仍在默认堆的 30% 以内,才跑 100 万。这个过程不要跳步。真实项目里见过直接把 -Xmx 开到物理内存 80% 去跑百万虚拟线程的,结果 GC 把整机拖垮——问题不在虚拟线程,在于堆给得太大,G1 的回收线程和业务线程抢 CPU。
本机实验用的是默认参数,没有额外指定 -Xmx,这样读者复现时不用猜参数。
三档实测:创建、堆、载体
测量程序做四件事:记录创建前后的堆与 ThreadMXBean 计数,创建 N 个虚拟线程让它们全部挂在一个 CountDownLatch 上,打印统计,最后统一放行并 join。
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
/** 创建 N 个虚拟线程并让它们全部挂起,量创建成本与堆占用。 */
public final class VtScale {
static final ThreadMXBean TMX = ManagementFactory.getThreadMXBean();
static final MemoryMXBean MEM = ManagementFactory.getMemoryMXBean();
static final AtomicLong TOUCHED = new AtomicLong();
static long usedHeap() {
return MEM.getHeapMemoryUsage().getUsed();
}
static long carriers() {
return Thread.getAllStackTraces().keySet().stream()
.map(Thread::getName)
.filter(n -> n.startsWith("ForkJoinPool-1-worker"))
.count();
}
public static void main(String[] args) throws Exception {
int n = Integer.parseInt(args[0]);
System.out.printf("maxHeap=%dMB 要创建=%d%n", Runtime.getRuntime().maxMemory() / (1 << 20), n);
System.gc();
long heap0 = usedHeap();
long threads0 = TMX.getThreadCount();
CountDownLatch hold = new CountDownLatch(1);
CountDownLatch started = new CountDownLatch(n);
Thread[] refs = new Thread[n];
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) {
final int id = i;
refs[i] = Thread.ofVirtual().name("vt-" + id).start(() -> {
TOUCHED.incrementAndGet();
started.countDown();
try {
hold.await(); // 全部挂起,保持存活
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
TOUCHED.incrementAndGet();
});
}
started.await();
long createMs = (System.nanoTime() - t0) / 1_000_000L;
long heap1 = usedHeap();
System.out.printf("创建完成: %d 个, 耗时 %d ms%n", n, createMs);
System.out.printf("堆: %dMB -> %dMB, 增量 %dMB, 每个约 %d 字节%n",
heap0 / (1 << 20), heap1 / (1 << 20),
(heap1 - heap0) / (1 << 20), (heap1 - heap0) / Math.max(1, n));
System.out.printf("ThreadMXBean.getThreadCount(): %d -> %d%n", threads0, TMX.getThreadCount());
System.out.println("载体线程数(按线程名统计): " + carriers());
System.out.println("JAVA_PID=" + ProcessHandle.current().pid());
Thread.sleep(20_000); // 保持存活,供外部 jcmd 观察
long t1 = System.nanoTime();
hold.countDown();
for (Thread t : refs) t.join();
System.out.printf("join 耗时 %d ms, 任务计数=%d%n",
(System.nanoTime() - t1) / 1_000_000L, TOUCHED.get());
}
}
三档规模各跑一次,每档独立 JVM:
| 线程数 | 创建耗时 | 堆增量 | 每线程 | 创建速率 | 载体线程 |
|---|---|---|---|---|---|
| 10 000 | 49 ms | 20 MB | 2097 B | 204 个/ms | 10 |
| 100 000 | 179 ms | 121 MB | 1269 B | 559 个/ms | 10 |
| 1 000 000 | 597 ms | 890 MB | 933 B | 1675 个/ms | 10 |
三条结论,都只要看数量级:
创建耗时不是线性的。 10 万条 179 ms、100 万条 597 ms,十倍的数量只花了 3.3 倍的时间。原因不是虚拟线程有什么批量优化,而是 JIT:10 万档里 Thread.ofVirtual().start() 这条路径还在解释执行和 C1 阶段,1 万档更明显(49 ms 对应 10 万档的 179 ms,速率差了 2.7 倍)。同一段代码预热之后再跑,每条的边际成本会掉到 0.6 微秒左右。所以不要拿冷启动的创建耗时去做容量规划,那个数字高估了 2–3 倍。
每线程堆占用随规模下降,收敛在 1 KB 附近。 2097 B → 1269 B → 933 B,下降的是每个线程分摊的固定开销(类元数据、调度器队列节点、JIT 之后的临时对象)。稳定值约 900–1000 B,可以按 1 KB/条做估算:100 万条 ≈ 1 GB,1000 万条 ≈ 10 GB。这个数字比平台线程小两个数量级——平台线程默认 -Xss 是 1 MB(保留地址空间,不是实际占用),而虚拟线程的栈按需增长、挂在堆上。
载体线程始终是 10 个。 10 万个虚拟线程、100 万个虚拟线程,ForkJoinPool-1-worker-* 都是 10 个,等于 availableProcessors()。这是虚拟线程最核心的一句话:线程数不再是稀缺资源,CPU 才是。
百万线程在堆里长什么样
运行期间用 jcmd 看堆:
garbage-first heap total reserved 8388608K, committed 2887680K, used 922625K
创建完成时用了 890 MB,挂起 25 秒后 used 是 922 MB,committed 2.8 GB。committed 远大于 used 是 G1 的正常行为:创建过程中短命对象把 region 撑起来了,used 才是真实的存活量。
这 933 字节里装的是什么,值得拆开看,因为它决定了「什么样的虚拟线程更贵」:
| 组成 | 大致量级 | 说明 |
|---|---|---|
Thread 对象本身 | ~200 B | 名字、状态、ThreadLocalMap 引用 |
Continuation + 首个栈块 | ~500 B | 栈帧按需增长,方法少就浅 |
| 调度器队列节点 | ~100 B | 挂起时在 ForkJoinPool 的队列里 |
| 其他(字符数组等) | ~100 B | name("vt-123456") 的字符串 |
最后一行是可以优化的:给 100 万个线程起唯一名字,光是名字字符串就占几十 MB。生产上要么不命名,要么用 Thread.ofVirtual() 默认的匿名线程,要么只在采样窗口内命名。
还有一个反直觉的数字:100 万个线程 join 花了 10 181 ms,而 10 万个只花了 870 ms。十倍线程数对应 11.7 倍收尾时间,基本线性。收尾比创建贵一个数量级,因为每个线程都要被唤醒、跑完剩余逻辑、从调度器队列里摘除、等 GC 回收它的 Continuation。如果你的场景是「创建-用完-销毁」的高频短任务,收尾成本会盖过创建成本,这时候该复用而不是重建——但注意下一节要讲的:虚拟线程不该池化。
为什么 jstack 看不见它们
这是运维最不适应的一点。100 万个虚拟线程存活期间执行:
jcmd <pid> Thread.print > /tmp/tp.txt
wc -l /tmp/tp.txt # 208 行
grep -c 'vt-' /tmp/tp.txt # 0
Thread.print(就是 jstack)只输出平台线程。100 万虚拟线程在跑,转储里一行都没有,行数和空载时几乎一样(208 行)。用旧流程排障的人会得出「没有线程在跑」的错误结论。
ThreadMXBean 同样看不见:
ThreadMXBean.getThreadCount(): 6 -> 17
ThreadMXBean.getTotalStartedThreadCount(): 6 -> 17
创建 100 万个虚拟线程前后,getThreadCount() 从 6 变到 17,getTotalStartedThreadCount() 也只增加了 11。增加的 11 个是 10 个载体线程加 1 个调度器线程。ThreadMXBean 的口径是平台线程,虚拟线程不计入任何一个计数器。
要看虚拟线程,得用 Thread.dump_to_file:
jcmd <pid> Thread.dump_to_file -format=plain /tmp/dump.txt
100 万条线程的转储实测:818 778 770 字节(约 781 MB),耗时 8.0 秒,条目数正好 1 000 000。单条长这样:
#27 "vt-0" virtual WAITING 2026-10-28T19:04:22.350861Z
at java.base/java.lang.VirtualThread.park(VirtualThread.java:745)
- parking to wait for <java.util.concurrent.CountDownLatch$Sync@2f54d2c8>
at java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
at java.base/java.util.concurrent.CountDownLatch.await(CountDownLatch.java:230)
at VtScale.lambda$main$0(VtScale.java:49)
关键词是第三列的 virtual。解析脚本靠它区分虚拟线程和平台线程,不要靠线程名。
781 MB / 8 秒意味着百万级转储不能随手做:它会占满容器临时盘,也会让 JVM 停顿 8 秒(Thread.dump_to_file 的影响级别是 Medium,随线程数上升)。生产上正确的做法是:先按业务标识过滤(jcmd 不支持按名字过滤,得自己解析),或者干脆不开百万线程——见后面的边界。
顺带一提,JDK 25 的 VirtualThreadSchedulerMXBean 提供了三个更轻的指标,不需要转储:
import jdk.management.VirtualThreadSchedulerMXBean;
import java.lang.management.ManagementFactory;
VirtualThreadSchedulerMXBean sched = ManagementFactory.getPlatformMXBean(
VirtualThreadSchedulerMXBean.class);
System.out.printf("parallelism=%d poolSize=%d mounted=%d queued=%d%n",
sched.getParallelism(), sched.getPoolSize(),
sched.getMountedVirtualThreadCount(), sched.getQueuedVirtualThreadCount());
本机跑 20 个虚拟线程全部挂起时的输出是 parallelism=10 poolSize=10 mounted=0 queued=0——挂起的线程不占载体,所以 mounted 是 0。mounted 长期等于 parallelism 说明载体吃满,queued 持续增长说明调度器排队,这两个比值才是虚拟线程版的「池饱和度」。
在 libraryd-conc 里落地
把上面这套测量封成 conc-ops 里的一个自检类,在压测前后各跑一次,用数据回答「这台机器能开多少」:
package com.plume.conc.ops;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
/**
* 按实测的每线程字节数反推安全规模。
* 不对线程数做任何硬编码,只做「已知条件 + 余量系数」的除法。
*/
public final class VtCapacity {
private static final MemoryMXBean MEM = ManagementFactory.getMemoryMXBean();
/** bytesPerThread 用 VtScale 实测,不要抄别人的 1 KB。 */
public static int safeThreads(long bytesPerThread, double heapRatio) {
long max = Runtime.getRuntime().maxMemory();
long usable = (long) (max * heapRatio);
return (int) Math.min(Integer.MAX_VALUE, usable / bytesPerThread);
}
/** 启动时先建 1 万个量一次,避免用理论值估算。 */
public static long measureBytesPerThread() throws InterruptedException {
int probe = 10_000;
System.gc();
long before = MEM.getHeapMemoryUsage().getUsed();
Thread[] refs = new Thread[probe];
java.util.concurrent.CountDownLatch hold = new java.util.concurrent.CountDownLatch(1);
for (int i = 0; i < probe; i++) {
refs[i] = Thread.ofVirtual().start(() -> {
try {
hold.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Thread.sleep(200);
long after = MEM.getHeapMemoryUsage().getUsed();
hold.countDown();
for (Thread t : refs) {
t.join();
}
return Math.max(1, (after - before) / probe);
}
}
用法:measureBytesPerThread() 在启动阶段量一次(约 200 ms),拿到本机的真实每线程字节数;safeThreads(bytes, 0.5) 给出「堆只用一半」时的上限。在 8 GB 默认堆、933 B/线程的实测值下,这个上限是 459 万——远超业务需要,说明在这个规模上,虚拟线程数不是瓶颈,瓶颈在下游。这正是 6.3 的主题。
把三个数字放进容量表
实测数字只有放进一张容量表才有用。假设一次借阅查询的虚拟线程存活 8 ms(3 ms 计算 + 5 ms 等下游),单机需要 5 万并发在飞,怎么换算:
在飞线程数 = 50 000
每线程堆 = 933 B (本机实测,按 1 KB 保守取)
堆占用 = 50 000 × 1 KB ≈ 49 MB
载体需求 = 核数 × (1 + W/C) = 10 × (1 + 5/3) ≈ 27 (5.2 的公式)
实际载体 = 10 (调度器默认 = availableProcessors)
四行里只有第三行是新东西,其余三行来自前几章。它说明一件事:5 万在飞线程的堆成本只有 49 MB,真正的约束是「5 ms 等待 × 5 万并发」需要多少下游并发能力。如果下游连接池只有 20 个连接,5 万个虚拟线程会在第 21 个上排队,排队时间 = 50 000 / 20 × 5 ms ≈ 12.5 秒——这才是用户看到的延迟。
同样的算法,把在飞线程数从 5 万换成 100 万:堆 1 GB(仍然可接受),排队时间变成 250 秒(完全不可接受)。所以「百万虚拟线程」在技术上完全可行,在业务上通常没有意义——它只是把线程池的队列换成了连接池的队列,队列长度没变,等待时间也没变。6.3 会给出这组排队时间的实测。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 用冷启动的创建耗时做容量规划 | 高估 2–3 倍 | 预热后测,或直接按 0.6 μs/条估 |
| 按 1 MB 栈去估虚拟线程内存 | 以为只能开几千个 | 实测约 1 KB/条 |
为了「保险」把 -Xmx 开到物理内存 80% | GC 与业务抢 CPU,整机抖动 | 默认 1/4 或按实测活对象定 |
用 jstack / Thread.print 找虚拟线程 | 看到 0 个,误判「没在跑」 | Thread.dump_to_file -format=plain 找 virtual |
用 ThreadMXBean.getThreadCount() 做告警 | 百万线程时仍报 17 | VirtualThreadSchedulerMXBean 的 mounted/queued |
| 给每个虚拟线程起唯一长名字 | 名字字符串吃掉几十 MB | 匿名或短名,采样时再命名 |
| 池化虚拟线程 | 失去「用完即弃」的意义,还引入队列 | 直接 Thread.ofVirtual().start() |
什么时候不要用海量虚拟线程:三种情况。第一,任务本身是 CPU 密集的——开 100 万个也只是在 10 个核上排队,还多付 900 MB 堆(回到 5.2 的 N+1)。第二,下游有硬上限——数据库只给 20 个连接,100 万虚拟线程只会在连接池上排 100 万人的队,吞吐一点不涨(6.3 详述)。第三,需要严格并发上限的批处理——虚拟线程没有「最多同时跑 N 个」的语义,得自己用 Semaphore 兜。
小结
本机默认 8192 MB 堆下,10 万 / 100 万虚拟线程的实测是:创建 179 ms / 597 ms,堆增量 121 MB / 890 MB,每线程约 1.3 KB / 0.9 KB,载体线程始终 10 个。创建耗时受 JIT 影响,十倍数量只花 3.3 倍时间,不能拿冷启动数字做规划;每线程占用收敛在 1 KB 附近,可以按此估算。
三个反直觉的点值得记住:ThreadMXBean 完全看不见虚拟线程(100 万个时仍报 17 个平台线程),Thread.print 同样看不见(208 行、0 条),要看只能用 Thread.dump_to_file——而百万级转储是 781 MB、8 秒。以及收尾比创建贵:100 万个 join 花了 10.2 秒。
下一节处理虚拟线程规模化之后最常见的性能故障:明明开了几万个线程,CPU 却只用了一个核——pinning。而它的诊断方法,在 JDK 24 之后已经和旧教材写的完全不一样了。
阅读导航:上一节 5.3 队列长度、拒绝率与降级 | 下一节 6.2 pinning 的诊断与消除
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。