有句话说,新技术落地从来不是一帆风顺的。Java 21 正式引入的虚拟线程(Virtual Threads)在发布时被寄予厚望——用同步代码风格实现高并发,彻底摆脱回调地狱和响应式编程的心智负担。然而在真实的生产环境中,从压测阶段到线上运行,我们遇到了不少"文档里没有写"的问题。本文将完整记录这段踩坑历程,包括压测数据对比、线上故障复现、以及 GC 调优的全过程,希望能为正在评估或已经上线虚拟线程的团队提供一份实战参考。
背景:为什么要上虚拟线程
业务痛点
我们的核心服务是一个典型的 I/O 密集型网关层,负责聚合下游十几个微服务的调用结果。在高峰期,单实例需要承载约 8000 QPS 的请求量,每个请求平均会发起 3-5 次下游 RPC 调用,每次调用的平均延迟在 20-80ms 之间。
在传统的平台线程模型下,Tomcat 默认线程池大小为 200。按照经典公式:
线程数 = QPS × 平均响应时间(秒)
理论所需线程数 = 8000 × 0.15(假设平均 150ms 完成所有下游调用)= 1200 个线程。这意味着我们需要将线程池扩容到 1200 才能满足吞吐需求,而 1200 个平台线程带来的上下文切换开销和内存占用已经非常可观。
虚拟线程的理论优势
虚拟线程的核心价值在于:
- 轻量级:一个虚拟线程的初始内存开销约为 1KB,而平台线程通常需要 1MB 的栈空间
- 高数量:可以轻松创建数百万个虚拟线程,不再受操作系统线程数的限制
- 同步风格:无需改写为异步/响应式代码,保持代码可读性
- 兼容性好:与现有的
synchronized、ReentrantLock、ThreadLocal等机制兼容
这些特性看起来完美契合我们的场景。于是团队决定在一个非核心的聚合服务上先行试点。
压测阶段:数据说话
测试环境配置
为了确保数据的可对比性,我们在隔离环境中进行了严格的 A/B 测试:
| 配置项 | 规格 |
|---|---|
| 服务器 | 8 核 32G,CentOS 7.9 |
| JDK | OpenJDK 21.0.2 |
| GC | G1GC(默认参数) |
| 应用框架 | Spring Boot 3.2 + 内嵌 Tomcat |
| 压测工具 | JMeter 5.6,10 台施压机 |
| 下游模拟 | WireMock,模拟 50ms 固定延迟 |
对照组设置
- 对照组 A:传统平台线程,Tomcat
max-threads=400 - 对照组 B:虚拟线程,通过
spring.threads.virtual.enabled=true开启 - 对照组 C:虚拟线程 +
synchronized关键路径(模拟遗留代码)
吞吐量对比
逐步加压至单实例 12000 QPS,记录各组的吞吐量和 P99 延迟:
| 目标 QPS | 平台线程 实际QPS | 平台线程 P99(ms) | 虚拟线程 实际QPS | 虚拟线程 P99(ms) |
|---|---|---|---|---|
| 2000 | 2000 | 85 | 2000 | 72 |
| 4000 | 3980 | 142 | 4000 | 95 |
| 6000 | 5650 | 310 | 6000 | 118 |
| 8000 | 6200 | 890 | 8000 | 145 |
| 10000 | 6500 | 2100 | 9800 | 180 |
| 12000 | OOM | — | 10500 | 350 |
数据结论非常清晰:
- 平台线程在 6000 QPS 以上开始明显退化,P99 延迟飙升,最终在 12000 QPS 时因无法创建更多线程而 OOM
- 虚拟线程的吞吐曲线几乎是线性的,直到 10000 QPS 以上才出现轻微退化
- P99 延迟方面,虚拟线程的优势在高并发下尤为明显——平台线程在 8000 QPS 时 P99 已近 1 秒,而虚拟线程仍保持在 150ms 以内
内存占用对比
在 8000 QPS 稳定运行 10 分钟后采样:
| 指标 | 平台线程 | 虚拟线程 |
|---|---|---|
| 活跃线程数 | 400 | ~8000 |
| 线程栈内存总量 | ~400MB | ~8MB |
| JVM 堆内存使用 | 2.1GB | 2.8GB |
| RSS(进程总内存) | 3.8GB | 4.2GB |
这里出现了一个值得注意的现象:虚拟线程的堆内存使用比平台线程高出约 700MB。这个差异在当时没有引起足够重视,后来成为线上 GC 问题的导火索。
对照组 C 的意外发现
当我们在虚拟线程环境中使用 synchronized 块保护共享资源时,发现了一个被称为 Pinning(钉住) 的现象。虚拟线程在执行 synchronized 块或本地方法调用时,会被"钉"在其载体线程(carrier thread)上,无法被卸载。
这意味着:如果 synchronized 块内部有阻塞操作(如 I/O 调用),载体线程也会被阻塞,虚拟线程的并发优势被严重削弱。
在对照组 C 的测试中,当关键路径使用了 synchronized 后,虚拟线程的吞吐量直接退化到与平台线程接近的水平。JFR(Java Flight Recorder)的数据显示:
|
|
这个发现促使我们对代码库进行了一次全面扫描,将所有热路径上的 synchronized 替换为 ReentrantLock。ReentrantLock 在虚拟线程上不会导致 Pinning,因为它在等待锁时会正确卸载虚拟线程。
上线过程:从灰度到全量
灰度策略
基于压测数据的信心,我们制定了三阶段灰度计划:
- 第一阶段(1 周):10% 流量切入虚拟线程版本,观察基础指标
- 第二阶段(1 周):50% 流量,关注 GC 和内存表现
- 第三阶段(持续观察):全量切换,重点监控长尾延迟
第一阶段一切正常。第二阶段在 50% 流量下运行了两天后,运维监控开始出现 GC 暂停时间的告警。
线上故障:GC 暂停时间飙升
问题表现
全量上线后的第三天,业务方反馈部分请求的响应时间出现"毛刺"。从监控数据来看:
- GC 暂停时间:从原来的平均 15ms 上升到 80-200ms,偶发超过 500ms
- Full GC 频率:从每天 1-2 次上升到每小时 3-5 次
- 老年代使用率:持续在 85% 以上,回收效率低下
GC 日志中的典型片段如下:
|
|
根因分析
问题的根因可以归结为三个方面:
1. 虚拟线程对象本身的 GC 压力
每个虚拟线程在 JVM 内部都是一个 java.lang.VirtualThread 对象,存在于堆中。在 8000 QPS、平均请求处理时间 150ms 的场景下,任意时刻活跃的虚拟线程数量约为 1200 个。这些对象虽然不大(约 1-2KB),但它们持有的 continuation 栈帧 会占用额外的堆空间。
当一个虚拟线程被卸载(unmount)时,它的栈帧会被复制到堆中的一个 Continuation 对象里。在高并发下,这些 Continuation 对象会频繁创建和销毁,给年轻代 GC 带来额外压力。
2. ThreadLocal 的放大效应
虚拟线程与 ThreadLocal 的组合是一个隐蔽的性能陷阱。我们的服务中使用了 ThreadLocal 来传递请求上下文(traceId、用户信息等)。在平台线程模型下,400 个线程意味着最多 400 份 ThreadLocal 副本。而切换到虚拟线程后,同一时间可能有 8000+ 个虚拟线程活跃,ThreadLocal 的副本数量暴增 20 倍。
更关键的是,虚拟线程的生命周期通常很短(处理完一个请求就结束),这意味着这些 ThreadLocal 对象会随着虚拟线程的销毁而变成垃圾。在高并发下,这导致了大量的短生命周期大对象涌入年轻代。
3. 连接池配置不匹配
虚拟线程改变了并发模型,但下游连接池的配置没有相应调整。我们原来使用 HikariCP,最大连接数设为 50。在平台线程时代,400 个线程竞争 50 个连接是合理的——大部分线程会在等待连接时被阻塞,但由于线程数有限,不会造成资源耗尽。
切换到虚拟线程后,8000 个虚拟线程同时竞争 50 个数据库连接。虽然虚拟线程在等待连接时不会占用载体线程,但大量等待中的虚拟线程持有的上下文对象(请求参数、中间结果等)全部存活在堆中,进一步加剧了 GC 压力。
GC 调优:三轮迭代
第一轮:调整堆大小和新生代比例
最直觉的反应是"堆不够大"。我们将堆从 4GB 扩到 8GB,并调整了新生代比例:
|
|
效果:Full GC 频率下降 50%,但 Young GC 的暂停时间反而增加了——更大的新生代意味着每次 Young GC 需要扫描更多的存活对象。P99 延迟没有明显改善。
第二轮:切换到 ZGC
既然 G1GC 在虚拟线程场景下表现不佳,我们尝试了 ZGC——它的亚毫秒级暂停时间在理论上可以完全解决 GC 暂停问题:
|
|
效果:GC 暂停时间确实降到了 1ms 以内,但出现了新的问题——ZGC 的并发标记和转移阶段会持续占用 CPU 资源。在我们的 8 核机器上,ZGC 的后台线程占用了约 1.5 个核的 CPU,导致业务请求的处理速度整体下降 15%。
此外,ZGC 的内存占用更高(需要额外的染色指针和读屏障),RSS 从 4.2GB 涨到了 5.8GB,部分机器的 cgroup 内存限制被触及。
第三轮:G1GC 精细化调优 + 应用层优化
最终,我们回到了 G1GC,但进行了更精细的参数调优,同时配合应用层的改动:
JVM 参数优化
|
|
关键参数解释:
| 参数 | 值 | 调优逻辑 |
|---|---|---|
MaxGCPauseMillis |
50ms | 明确告诉 G1 暂停时间目标,让它自适应调整回收策略 |
G1HeapRegionSize |
4MB | 增大 Region 大小,减少 Humongous 对象的产生 |
InitiatingHeapOccupancyPercent |
35% | 降低并发标记的触发阈值,提前开始回收 |
G1MixedGCCountTarget |
16 | 增加 Mixed GC 的次数,每次回收更少的 Region,降低单次暂停 |
G1HeapWastePercent |
3% | 降低可容忍的堆浪费比例,更积极地触发 Mixed GC |
ConcGCThreads |
2 | 限制并发 GC 线程数,为业务线程保留 CPU |
ParallelGCThreads |
6 | STW 阶段的并行度,与核心数匹配 |
应用层优化
1. 用 Scoped Values 替代 ThreadLocal
Java 21 引入的 ScopedValue(预览特性)是虚拟线程场景下 ThreadLocal 的理想替代方案。它在语义上类似,但有以下优势:
- 不可变,消除了线程安全问题
- 有明确的作用域边界,不会产生泄漏
- 虚拟线程可以高效地继承父线程的 Scoped Value
|
|
这一改动直接将上下文对象的内存占用降低了约 60%。
2. 连接池参数重新设计
我们重新计算了连接池的大小。核心思路是:虚拟线程让"等待"变得廉价,但数据库连接本身是有限的。过多的等待者虽然不会阻塞载体线程,但会增加堆内存压力。
新策略:
|
|
同时在应用层增加了一个 Semaphore 作为入口限流器:
|
|
这个 Semaphore 将同时活跃的虚拟线程数量限制在 2000 以内,超出的请求快速失败。虽然看起来限制了并发能力,但实际上避免了"雪崩效应"——当系统已经过载时,更多的并发请求只会让所有人都等得更久。
3. 消除不必要的 Pinning
通过 JFR 持续监控 jdk.VirtualThreadPinned 事件,逐一排查并修复了剩余的 Pinning 点:
- 数据库驱动中的
synchronized块:升级到支持虚拟线程的驱动版本 - 第三方 SDK 中的本地方法调用:用
ReentrantLock包装或移至独立的平台线程池 - 日志框架中的同步写入:改为异步日志(Log4j2 AsyncAppender)
调优效果:前后对比
经过三轮迭代后,系统的核心指标回到了健康水平:
| 指标 | 调优前 | 调优后 | 改善幅度 |
|---|---|---|---|
| 平均 GC 暂停 | 85ms | 12ms | -86% |
| P99 GC 暂停 | 520ms | 35ms | -93% |
| Full GC 频率 | 4次/小时 | 0.2次/天 | -99% |
| P99 请求延迟 | 350ms | 95ms | -73% |
| 堆内存使用峰值 | 5.2GB | 3.8GB | -27% |
| CPU 利用率 | 78% | 62% | -16% |
| 最大可承载 QPS | 8500 | 13000 | +53% |
特别值得注意的是,调优后的系统不仅 GC 表现更好,整体吞吐量也提升了 53%。这说明 GC 问题本身也在拖慢业务处理——当 GC 暂停频繁发生时,大量虚拟线程被暂停,它们持有的请求上下文无法释放,进一步加剧了内存压力,形成恶性循环。
虚拟线程使用注意事项清单
基于这段时间的实践经验,整理一份虚拟线程上线的检查清单:
代码层面
- 检查
synchronized使用:在热路径上,将synchronized替换为ReentrantLock。可以用 JFR 的VirtualThreadPinned事件来定位问题点 - 审查
ThreadLocal:评估是否可以用ScopedValue替代,或者至少为ThreadLocal设置合理的清理策略 - 避免在虚拟线程中执行 CPU 密集型任务:虚拟线程适合 I/O 等待,不适合纯计算。CPU 密集型任务应使用传统的
ForkJoinPool或固定大小的线程池 - 注意
native方法调用:JNI 调用同样会导致 Pinning
配置层面
- 连接池大小:不要因为"虚拟线程很便宜"就无限放大连接池。数据库和下游服务的承载能力是有限的
- 入口限流:使用
Semaphore或类似机制限制同时活跃的虚拟线程数量,防止雪崩 - GC 参数:虚拟线程场景下需要更低的
InitiatingHeapOccupancyPercent和更积极的 Mixed GC 策略 - 监控 JFR 事件:将
jdk.VirtualThreadPinned和jdk.VirtualThreadSubmitFailed纳入监控告警
框架层面
- Spring Boot 3.2+:通过
spring.threads.virtual.enabled=true一键开启,但需要确认所有依赖库都兼容 - 数据库驱动:PostgreSQL JDBC 42.7+、MySQL Connector/J 8.3+ 对虚拟线程有较好的支持
- HTTP 客户端:Java 内置的
HttpClient天然支持虚拟线程,RestTemplate 需要额外配置
常见误区与陷阱
误区一:“虚拟线程越多越好”
虚拟线程虽然轻量,但不是免费的。每个虚拟线程都需要堆内存来存储其 continuation 栈帧。在某些极端场景下(如虚拟线程内部嵌套大量局部变量),单个虚拟线程的堆占用可以达到几十 KB。如果同时有 10 万个这样的虚拟线程活跃,仅 continuation 就需要数 GB 的堆空间。
误区二:“虚拟线程可以替代异步编程”
虚拟线程解决的是"同步阻塞代码的并发效率"问题,但它不能替代所有异步场景。例如:
- 事件驱动架构:仍然需要消息队列和事件循环
- 流式处理:Reactive Streams 的背压机制在虚拟线程模型下需要额外设计
- 实时推送:WebSocket 长连接的管理逻辑并不因虚拟线程而简化
误区三:“开启虚拟线程后不需要调优”
这是最危险的误区。虚拟线程改变了并发模型,但没有改变物理资源的限制——CPU 核心数、内存带宽、数据库连接数、网络带宽都是固定的。虚拟线程只是让"等待"变得更高效,但当所有虚拟线程都不需要等待时(CPU 密集型场景),它不会比平台线程更快。
陷阱:虚拟线程 + CompletableFuture
一个容易忽略的陷阱是:CompletableFuture.supplyAsync() 默认使用 ForkJoinPool.commonPool(),这个池的大小等于 CPU 核心数减一。如果在虚拟线程中调用 CompletableFuture 的异步方法,实际上是在平台线程池上执行,而不是在虚拟线程上。
正确做法是显式指定一个虚拟线程执行器:
|
|
性能监控体系搭建
在调优过程中,我们逐步建立了一套针对虚拟线程的监控体系,以下是核心监控项:
JVM 层监控
| 监控项 | 采集方式 | 告警阈值 |
|---|---|---|
| 活跃虚拟线程数 | JMX ThreadMXBean |
> 5000 |
| VirtualThreadPinned 事件 | JFR 持续录制 | > 100次/分钟 |
| 载体线程利用率 | JFR + 自定义指标 | > 80% |
| Continuation 对象堆占用 | JMAP 分析 | > 1GB |
| GC 暂停时间 P99 | GC 日志解析 | > 50ms |
应用层监控
| 监控项 | 采集方式 | 告警阈值 |
|---|---|---|
| Semaphore 等待队列长度 | 自定义 Micrometer Gauge | > 500 |
| 连接池等待数 | HikariCP Metrics | > 20 |
| 请求快速失败率 | 应用层 Counter | > 5% |
| 下游调用 P99 延迟 | 链路追踪 | > 200ms |
这套监控体系让我们能够在问题恶化之前及时发现。例如,当活跃虚拟线程数异常增长时,通常意味着下游某个服务响应变慢,导致虚拟线程积压。此时可以通过 Semaphore 的快速失败机制保护系统不被压垮,同时触发告警通知值班人员排查下游服务。
虚拟线程与协程的对比思考
在调研阶段,团队内部曾有过一次关于技术选型的讨论:既然 Kotlin 协程和 Go 的 goroutine 已经验证了用户态调度的可行性,为什么还要选择虚拟线程?
核心考量有三个方面:
生态兼容性。我们的技术栈以 Java 为主,大量使用了 Spring、MyBatis、各种 SDK 等依赖。这些库的 API 都是同步阻塞风格。虚拟线程的最大优势在于"不改代码就能享受高并发",而协程通常需要语言层面的 suspend/async 关键字支持,与现有 Java 库的兼容性有限。
迁移成本。从平台线程切换到虚拟线程,核心改动只有一行配置。而从同步代码迁移到协程,需要重写大量的方法签名和调用链。对于一个有几十万行代码的系统来说,这个成本是不可接受的。
团队认知。Java 开发者对线程模型有深入的理解,虚拟线程的学习曲线几乎为零。而引入协程意味着团队需要掌握新的并发原语和思维模式。
当然,如果是一个全新的 Kotlin 项目,协程可能是更好的选择。技术选型没有绝对的优劣,只有适合与不适合。
后续演进方向
当前系统稳定运行后,我们还在探索几个方向:
Structured Concurrency(结构化并发)。Java 21 中的预览特性,可以将一组相关的虚拟线程组织为一个工作单元,统一管理和取消。这对于"扇出调用多个下游服务并聚合结果"的场景非常有用——任何一个子任务失败,都可以立即取消其他子任务,避免资源浪费。
ZGC 的 Generational 模式。随着 JDK 22/23 中 Generational ZGC 的成熟,它的内存效率已经大幅改善。在虚拟线程场景下,大量的短生命周期 continuation 对象正好适合分代回收。计划在 JDK 23 LTS 发布后重新评估 ZGC。
虚拟线程 + Project Loom 的 ScopedValue 稳定版。当 ScopedValue 从预览特性正式转正后,可以彻底消除 ThreadLocal 在虚拟线程场景下的性能和安全隐患。
虚拟线程不是银弹,但它确实是 Java 并发编程十年来最重要的改进之一。关键在于理解它的运行机制——虚拟线程是"廉价的等待",而不是"廉价的计算"。当你的系统瓶颈在于 I/O 等待(数据库查询、远程调用、文件读写)时,虚拟线程能带来显著的吞吐提升;当瓶颈在于 CPU 计算时,虚拟线程不会带来任何魔法。
每一次技术升级都是一次重新认识系统的机会。从这次虚拟线程的落地实践中,我们不仅获得了性能提升,更重要的是建立了一套从压测到监控、从发现问题到调优优化的完整方法论。这套方法论的价值,远超过某一次具体的参数调整。