《Java 并发编程·线程与内存模型》14.1 虚拟线程的调度模型

平台线程一条要占 0.5–1 MB 栈,一万并发就逼近内存红线。本节从 1:1 与 M:N 的差别讲起,拆开载体线程、mount/unmount 和栈片段,并在本机用 -Xmx512m 创建 10 万个虚拟线程:堆占用约 89–103 MB、启动 91–132 ms,载体线程只有 10 条。

承接的问题

第 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 的生命周期

把一次执行拆开看,虚拟线程在载体上的一个时间片大致是:

  1. 调度器从队列取出一条可运行的虚拟线程,把它的栈片段恢复到某条载体线程的栈上(mount)。
  2. 虚拟线程执行 Java 代码,和普通线程没有区别,CPU 时间照算。
  3. 遇到 park 类操作,JDK 把栈帧冻结成新的栈片段,记录阻塞原因,把载体还回池里(unmount)。
  4. 阻塞条件满足,虚拟线程进入可运行队列,等待下一次 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
极短的无阻塞任务创建和调度开销高于收益直接在当前线程跑
需要长时间占住原生栈的 JNInative 调用期间 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 还剩什么

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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