JDK21 虚拟线程实战:我们的微服务性能提升了 10 倍
文章目录
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 # 开启虚拟线程
框架会自动将 NamedThreadFactory 的 useVirtualThreads 参数设为 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(峰值) | 800 | 8,200 |
| P99 延迟 | 320ms | 45ms |
| 内存占用 | 680MB | 120MB |
| 线程数 | 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)
更多推荐
所有评论(0)