Java 虚拟线程:调度真相、pinning 陷阱与落地边界

举报
yd_254019812 发表于 2026/11/01 21:00:30 2026/11/01
【摘要】 一句话结论:虚拟线程优化的是"等待",不是"执行"。 它把线程的内存成本从 MB 级压到 KB 级、把上下文切换从内核态挪到用户态,但它对 CPU 密集代码毫无帮助,还会被 synchronized 里的阻塞直接打回原形。 结论先说 | 场景 | 用不用虚拟线程 | 原因 | |---|---|---| | IO 密集(JDBC、HTTP 调用、Redis、文件) | ✅ 用 | 阻塞时让出...

一句话结论:虚拟线程优化的是"等待",不是"执行"。

它把线程的内存成本从 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 失效、新人上手门槛陡增。

虚拟线程把这笔账还了回来:代码还是同步阻塞的写法,线程栈还是能一眼看懂的调用链,但并发数可以上到十万级。 但它有三条硬边界,越过去就会翻车:

  1. 它不加速 CPU——只在阻塞点让出;
  2. 它怕 synchronized 里的阻塞——JDK 24 前是硬伤;
  3. 它不创造资源——下游连接池该多大还是多大。

理解这三点,就不会再问"为什么我上了虚拟线程 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 形态会变
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。