《Java 并发编程·性能诊断与事故》6.1 十万/百万虚拟线程实测

十万和一百万虚拟线程在本机实跑:三档创建耗时 49/179/597 ms,堆占用约 1 KB 一条,载体线程始终 10 个。文章给出完整数据、781 MB 的 jcmd 转储实测,并解释 ThreadMXBean 与 Thread.print 为何完全看不见虚拟线程。

承接的问题

容量评审会上,运维问了一个很具体的问题:把 libraryd-conc 的借阅查询从 800 个平台线程的池换成虚拟线程,能不能直接开 100 万个。

这个问题不能用一个「能」或「不能」回答,它至少拆成四个可测的子问题:

  1. 创建 100 万个虚拟线程要多久?如果创建本身要 30 秒,那它就不适合放在请求链路上。
  2. 它们占多少堆?如果一条要 10 KB,100 万条就是 10 GB,默认堆直接 OOM。
  3. 占多少个操作系统线程?这一条决定了它在容器里会不会把 nproc 配额吃穿。
  4. 出问题的时候,运维用什么命令看见这 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 00049 ms20 MB2097 B204 个/ms10
100 000179 ms121 MB1269 B559 个/ms10
1 000 000597 ms890 MB933 B1675 个/ms10

三条结论,都只要看数量级:

创建耗时不是线性的。 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 Bname("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() 做告警百万线程时仍报 17VirtualThreadSchedulerMXBean 的 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 的诊断与消除

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收