JDK21 虚拟线程实战:我们的微服务性能提升了 10 倍

IO 密集型场景 QPS 上不去?传统线程池可能是瓶颈。我们换成虚拟线程后,性能提升了 10 倍。


一、传统线程池的瓶颈

去年双十一前,我们有一个订单查询接口,QPS 峰值只有 800。

翻了一遍代码:业务逻辑没问题,数据库有索引,Redis 缓存也加了。唯一可疑的地方是线程池配置——Tomcat 最大线程数 200,每个请求进来要占用一个线程,然后去查 Redis、查 MySQL、调下游服务,全是 IO 等待。

200 个线程,每个线程平均等待 50ms,QPS 理论上限就是 200 / 0.05 = 4000。但实际因为线程切换开销、上下文切换、锁竞争,根本跑不到这个数。

想提高 QPS,只能加大线程池。但平台线程不是免费的:

  • 每个线程默认栈大小 1MB,2000 个线程就是 2GB 内存
  • 线程调度由操作系统负责,上下文切换成本随线程数线性增长
  • 超过 5000 个线程后,CPU 大部分时间花在调度上,而不是执行业务

这就是传统微服务在 IO 密集型场景下的天花板。我们需要的不是更多线程,而是更便宜的线程

JDK 21 的虚拟线程(Virtual Thread),就是来打破这个天花板的。


二、虚拟线程原理:JDK 调度 vs OS 调度

2.1 平台线程的代价

平台线程(Platform Thread)是 OS 线程的一对一包装。创建一个平台线程,JVM 需要向操作系统申请资源:

平台线程创建 → OS 分配栈内存(1MB) → OS 调度器注册 → 上下文切换开销

每个平台线程独占 1MB 栈空间,调度由操作系统的时间片轮转算法决定。当线程等待 IO 时,OS 不会释放这个线程占用的调度槽位——它只是把线程挂起,等到 IO 完成再唤醒。

这就导致一个问题:等待 IO 的线程和正在计算 CPU 的线程,在 OS 眼里没有区别,都占用调度资源

2.2 虚拟线程的设计

虚拟线程的核心思路是:让 JVM 自己调度线程,不再依赖 OS

虚拟线程创建 → JDK 分配极小栈(按需) → JDK 调度器管理 → IO 等待时释放载体线程

关键机制是挂载/卸载(Mount/Unmount)

// 虚拟线程执行 IO 操作时的行为
virtualThread.run(() -> {
    // 1. 虚拟线程被挂载到一个平台线程(载体线程)上执行
    doSomeCalculation();

    // 2. 发起 IO 请求(网络/数据库/文件),虚拟线程自动卸载
    // 载体线程被释放,可以执行其他虚拟线程
    String data = httpClient.send(request);  // 阻塞点

    // 3. IO 完成后,虚拟线程重新挂载到载体线程上继续执行
    processResponse(data);
});

这意味着:100 万个虚拟线程在等待 IO,只需要几十个平台线程作为载体就够了

2.3 对比数据

维度平台线程虚拟线程
创建开销~1MB 栈内存~几 KB(按需增长)
调度方式操作系统JDK 内部
IO 等待时占用调度槽位释放载体线程
合理数量级几百~几千几万~百万
适用场景CPU 密集型IO 密集型

虚拟线程不是"更快的线程",而是"更便宜的线程"。它解决的不是单线程执行速度,而是系统能同时承载的并发量级


三、MetaLite 中如何启用虚拟线程

3.1 内置支持

MetaLite 在 NamedThreadFactory 中已经内置了虚拟线程支持:

public class NamedThreadFactory implements ThreadFactory {

    private final boolean useVirtualThreads;

    @Override
    public Thread newThread(Runnable runnable) {
        if (useVirtualThreads) {
            return createVirtualThread(runnable);
        } else {
            return createPlatformThread(runnable);
        }
    }

    private Thread createVirtualThread(Runnable runnable) {
        String threadName = String.format(Locale.ROOT, "%s-%d-V",
                this.threadNamePrefix, this.threadNumber.getAndIncrement());
        return Thread.ofVirtual()
                .name(threadName)
                .uncaughtExceptionHandler(handler)
                .inheritInheritableThreadLocals(true)
                .factory()
                .newThread(runnable);
    }
}

关键点:

  • 线程名称以 -V 结尾标识虚拟线程,-P 结尾标识平台线程
  • 使用 inheritInheritableThreadLocals(true) 确保 ThreadContext 中的 TraceId、用户 ID 等上下文能正确传递
  • 统一的未捕获异常处理器 UncaughtExceptionLogger,虚拟线程的异常不会静默丢失

3.2 在业务线程池中启用

MetaLite 的业务线程池配置非常简单,只需在 application.yml 中设置 useVirtualThreads

metalite:
  thread-pool:
    business:
      name: "biz-pool"
      useVirtualThreads: true    # 开启虚拟线程

框架会自动将 NamedThreadFactoryuseVirtualThreads 参数设为 true

// ThreadPoolManager 内部实现
executor.setThreadFactory(
    NamedThreadFactory.newInstance(config.getName(), config.isUseVirtualThreads())
);

3.3 Tomcat 容器适配

除了业务线程池,Web 容器层面的线程模型同样关键。Tomcat 在 JDK 21 下已经支持虚拟线程,但需要显式开启:

server:
  tomcat:
    threads:
      max: 200              # 虚拟线程场景下无需太大
    virtual-threads:
      enabled: true         # 开启 Tomcat 虚拟线程支持

注意:开启虚拟线程后,max-threads 不再限制并发处理能力,它只是载体线程的数量。真正能承载的并发请求量级取决于 IO 等待时间和虚拟线程的调度效率。

3.4 性能对比

我们在 backend-demo 中做了一个简单测试:一个同时查询 Redis + MySQL 的接口,在相同硬件条件下对比性能。

指标平台线程(200)虚拟线程
QPS(峰值)8008,200
P99 延迟320ms45ms
内存占用680MB120MB
线程数200~15(载体线程)

QPS 提升了约 10 倍,内存占用反而降低了 80%。原因是虚拟线程在等待 IO 时释放了载体线程,CPU 时间片被更高效地利用。


四、适用场景和注意事项

4.1 适合用虚拟线程的场景

IO 密集型,毫无疑问。以下场景换上虚拟线程基本都能见到明显提升:

  • 数据库查询:JDBC 调用是典型的阻塞 IO 操作
  • HTTP 调用下游服务:MetaLite 的 InternalServiceClient 底层使用 Apache HttpClient,是阻塞 IO
  • Redis 操作:Jedis/Lettuce 同步调用
  • 文件读写:日志写入、文件上传下载
  • MQ 消费:消息处理中包含多个 IO 步骤

判断标准很简单:如果线程大部分时间在等待外部响应,就适合用虚拟线程

4.2 不适合虚拟线程的场景

虚拟线程不是银弹,以下场景请继续使用平台线程:

  • CPU 密集型计算:虚拟线程在 CPU 计算时仍然占用载体线程,不会带来收益
  • 同步代码块(synchronized):虚拟线程在 synchronized 块中阻塞时,会导致载体线程 pinned(钉住),无法执行其他虚拟线程
  • 大量使用 ThreadLocal 的场景:虽然 MetaLite 通过 inheritInheritableThreadLocals(true) 做了兼容,但如果 ThreadLocal 数据量大,会影响性能

4.3 调试技巧

虚拟线程因为数量可能非常大,调试时需要注意:

# JVM 参数:打印虚拟线程的调度信息
-Djdk.virtualThreadScheduler.parallelism=4
-Djdk.virtualThreadScheduler.maxPoolSize=256

# JVM 参数:虚拟线程阻塞时输出堆栈(排查 pinned 问题)
-Djdk.tracePinnedThreads=short

在 IDEA 中调试时,虚拟线程和平台线程的表现是一致的——断点、步进、变量查看都没有区别。但要注意,虚拟线程没有线程 Dump 中的 “WAITING” 状态,它们被 JDK 内部调度器管理,不会出现在传统的线程 Dump 里。

4.4 ThreadContext 兼容性

MetaLite 的全链路 TraceId、用户 ID 等上下文信息依赖 ThreadLocal。虚拟线程通过 inheritInheritableThreadLocals(true) 保证了继承性:

// MetaLite 的 NamedThreadFactory 中
return Thread.ofVirtual()
        .name(threadName)
        .uncaughtExceptionHandler(handler)
        .inheritInheritableThreadLocals(true)  // 关键:继承 InheritableThreadLocal
        .factory()
        .newThread(runnable);

这意味着在虚拟线程中,你依然可以通过 ThreadContext.getTraceId() 获取 TraceId,不需要额外处理。


五、总结

虚拟线程的本质是:用 JDK 的调度器替代 OS 的调度器,让 IO 等待不再浪费资源

对于微服务来说,大多数接口都是 IO 密集型——查数据库、调缓存、调下游服务。在这些场景下,虚拟线程带来的不是渐进式优化,而是数量级的提升。

MetaLite 从设计之初就将虚拟线程作为核心能力之一,通过 NamedThreadFactory 的封装,业务开发者只需一行配置就能享受到 JDK 21 的红利。

本文介绍的虚拟线程生产实践,源自 MetaLite 框架在实际项目中的经验沉淀。从 NamedThreadFactory 的虚拟线程封装到 Tomcat 适配,每一项设计都经过真实业务场景验证。


框架简介:元界 MetaLite — 下一代企业级 Java 微服务技术底座

作者简介:基于 Spring 体系 15 年企业级开发经验,专注于通过企业级生产环境落地的工程思维和架构思想打造下一代Java 微服务技术底座

完整文档与源码:Gitee 搜索 MetaLite (https://gitee.com/MetaLite)

更多推荐