Java 21 虚拟线程生产踩坑:从压测数据到线上 GC 调优的完整记录

Virtual Threads 高并发场景的真实表现与调优经验

有句话说,新技术落地从来不是一帆风顺的。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 的栈空间
  • 高数量:可以轻松创建数百万个虚拟线程,不再受操作系统线程数的限制
  • 同步风格:无需改写为异步/响应式代码,保持代码可读性
  • 兼容性好:与现有的 synchronizedReentrantLockThreadLocal 等机制兼容

这些特性看起来完美契合我们的场景。于是团队决定在一个非核心的聚合服务上先行试点。

压测阶段:数据说话

测试环境配置

为了确保数据的可对比性,我们在隔离环境中进行了严格的 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

数据结论非常清晰:

  1. 平台线程在 6000 QPS 以上开始明显退化,P99 延迟飙升,最终在 12000 QPS 时因无法创建更多线程而 OOM
  2. 虚拟线程的吞吐曲线几乎是线性的,直到 10000 QPS 以上才出现轻微退化
  3. 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)的数据显示:

1
2
3
jdk.VirtualThreadPinned 事件数: 15,230 次/分钟
平均 Pin 持续时间: 45ms
载体线程利用率: 98%(几乎全部被钉住)

这个发现促使我们对代码库进行了一次全面扫描,将所有热路径上的 synchronized 替换为 ReentrantLockReentrantLock 在虚拟线程上不会导致 Pinning,因为它在等待锁时会正确卸载虚拟线程。

上线过程:从灰度到全量

灰度策略

基于压测数据的信心,我们制定了三阶段灰度计划:

  1. 第一阶段(1 周):10% 流量切入虚拟线程版本,观察基础指标
  2. 第二阶段(1 周):50% 流量,关注 GC 和内存表现
  3. 第三阶段(持续观察):全量切换,重点监控长尾延迟

第一阶段一切正常。第二阶段在 50% 流量下运行了两天后,运维监控开始出现 GC 暂停时间的告警。

线上故障:GC 暂停时间飙升

问题表现

全量上线后的第三天,业务方反馈部分请求的响应时间出现"毛刺"。从监控数据来看:

  • GC 暂停时间:从原来的平均 15ms 上升到 80-200ms,偶发超过 500ms
  • Full GC 频率:从每天 1-2 次上升到每小时 3-5 次
  • 老年代使用率:持续在 85% 以上,回收效率低下

GC 日志中的典型片段如下:

1
2
3
4
[2.145s][info][gc] GC(1234) Pause Young (Normal) (G1 Evacuation Pause) 
  Heap before: 3200M(4096M)  Heap after: 2800M(4096M)  
  Humongous: 120M  Eden: 800M->0M(600M)  
  Pause: 185.3ms

根因分析

问题的根因可以归结为三个方面:

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,并调整了新生代比例:

1
2
3
4
5
-Xms8g -Xmx8g \
-XX:NewRatio=1 \
-XX:G1NewSizePercent=40 \
-XX:G1MaxNewSizePercent=60 \
-XX:+UseG1GC

效果:Full GC 频率下降 50%,但 Young GC 的暂停时间反而增加了——更大的新生代意味着每次 Young GC 需要扫描更多的存活对象。P99 延迟没有明显改善。

第二轮:切换到 ZGC

既然 G1GC 在虚拟线程场景下表现不佳,我们尝试了 ZGC——它的亚毫秒级暂停时间在理论上可以完全解决 GC 暂停问题:

1
2
3
4
5
-Xms8g -Xmx8g \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:SoftMaxHeapSize=6g \
-XX:ZCollectionInterval=120

效果:GC 暂停时间确实降到了 1ms 以内,但出现了新的问题——ZGC 的并发标记和转移阶段会持续占用 CPU 资源。在我们的 8 核机器上,ZGC 的后台线程占用了约 1.5 个核的 CPU,导致业务请求的处理速度整体下降 15%。

此外,ZGC 的内存占用更高(需要额外的染色指针和读屏障),RSS 从 4.2GB 涨到了 5.8GB,部分机器的 cgroup 内存限制被触及。

第三轮:G1GC 精细化调优 + 应用层优化

最终,我们回到了 G1GC,但进行了更精细的参数调优,同时配合应用层的改动:

JVM 参数优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-Xms6g -Xmx6g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=50 \
-XX:G1HeapRegionSize=4m \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1ReservePercent=15 \
-XX:G1MixedGCCountTarget=16 \
-XX:G1HeapWastePercent=3 \
-XX:ConcGCThreads=2 \
-XX:ParallelGCThreads=6 \
-XX:+ParallelRefProcEnabled \
-XX:+G1UseAdaptiveIHOP

关键参数解释:

参数 调优逻辑
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 改造前:ThreadLocal
private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

// 改造后:ScopedValue
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

// 使用方式
ScopedValue.runWhere(CONTEXT, new RequestContext(traceId, userId), () -> {
    // 在此作用域内可以通过 CONTEXT.get() 访问上下文
    processRequest();
});

这一改动直接将上下文对象的内存占用降低了约 60%。

2. 连接池参数重新设计

我们重新计算了连接池的大小。核心思路是:虚拟线程让"等待"变得廉价,但数据库连接本身是有限的。过多的等待者虽然不会阻塞载体线程,但会增加堆内存压力。

新策略:

1
2
3
4
5
6
7
8
spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      # 关键:使用 Semaphore 限制并发请求数
      # 防止过多虚拟线程同时进入数据库等待队列

同时在应用层增加了一个 Semaphore 作为入口限流器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private final Semaphore concurrentLimit = new Semaphore(2000);

public Response handle(Request request) throws InterruptedException {
    if (!concurrentLimit.tryAcquire(100, TimeUnit.MILLISECONDS)) {
        return Response.tooManyRequests();
    }
    try {
        return doHandle(request);
    } finally {
        concurrentLimit.release();
    }
}

这个 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.VirtualThreadPinnedjdk.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 的异步方法,实际上是在平台线程池上执行,而不是在虚拟线程上。

正确做法是显式指定一个虚拟线程执行器:

1
2
3
4
5
ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture.supplyAsync(() -> {
    return callRemoteService();
}, vtExecutor);

性能监控体系搭建

在调优过程中,我们逐步建立了一套针对虚拟线程的监控体系,以下是核心监控项:

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 计算时,虚拟线程不会带来任何魔法。

每一次技术升级都是一次重新认识系统的机会。从这次虚拟线程的落地实践中,我们不仅获得了性能提升,更重要的是建立了一套从压测到监控、从发现问题到调优优化的完整方法论。这套方法论的价值,远超过某一次具体的参数调整。

本博客文章采用 CC BY-NC-SA 4.0 许可协议
服务器推荐

腾讯云 · 新用户专属优惠

本博客部署在腾讯云服务器,稳定运行一年多。如果你是新用户或想搭建个人项目,推荐试试腾讯云的优惠活动。

查看优惠详情 →
阅读
上一篇
从单体到事件驱动:用 Event Sourcing 重构订单系统的架构决策复盘
下一篇
大模型评估不靠感觉:一套面向企业场景的 LLM Benchmark 自建方法
广告

📚 关注公众号,免费获取技术材料

扫码关注公众号,回复「资料」领取:

  • 📘 企业架构设计模板
  • 📗 数据治理实施指南
  • 📙 工业软件技术白皮书
公众号二维码

长按或扫描二维码