10,000 并发压测:Virtual Threads vs 线程池——5 个场景的真实数据对比

引言

社区都说虚拟线程(Virtual Threads)是"性能革命",JDK 21 GA 后无数文章吹爆:

"Virtual Threads 让你的吞吐提升几十倍!"
"线程池要被淘汰了!"
"100 万并发不是梦!"

但真到自己落地时,发现一堆疑问:

  • 我们的业务场景到底能不能提升?
  • CPU 密集场景到底有没有用?
  • 线程池调到 500 够不够,虚拟线程真的完爆它吗?
  • 高并发短任务和长连接场景表现一样吗?

光看官方 Benchmark 没用,得自己压一遍。这篇文章我用 10,000 并发,在 5 个真实业务场景下,对三种方案做横向对比:

  • 传统线程池(200 线程)
  • 传统线程池(500 线程)
  • Virtual Threads

每种场景压 10 分钟,记录 QPS、P99 延迟、CPU、内存。看完这篇,你会清楚知道你的场景该不该上虚拟线程


一、压测方案设计

1.1 测试环境

项目配置
CPU8 核 16 线程(Intel i7-12700H)
内存32GB DDR4
JDKOpenJDK 21.0.2
应用Spring Boot 3.2 + Virtual Threads
数据库MySQL 8.0(本地,独立实例)
压测工具JMeter 5.6
OSUbuntu 22.04

1.2 三组测试方案

方案配置
Pool-200Executors.newFixedThreadPool(200)
Pool-500Executors.newFixedThreadPool(500)
VTThread.ofVirtual().factory()(虚拟线程,无上限)

应用层用 @Async + 不同 Executor 切换三组:

@Configuration
public class ExecutorConfig {

    // 方案 1:传统线程池 200
    @Bean("pool200")
    public Executor pool200() {
        return new ThreadPoolExecutor(200, 200, 60, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(10000));
    }

    // 方案 2:传统线程池 500
    @Bean("pool500")
    public Executor pool500() {
        return new ThreadPoolExecutor(500, 500, 60, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(10000));
    }

    // 方案 3:虚拟线程
    @Bean("virtual")
    public Executor virtual() {
        return new TaskExecutor() {
            @Override
            public void execute(Runnable command) {
                Thread.startVirtualThread(command);
            }
        };
    }
}

1.3 JMeter 压测脚本

Thread Group:
  - Number of Threads: 10000          ← 1 万并发
  - Ramp-up Period: 10 秒
  - Loop Count: ∞
  - Duration: 600 秒(10 分钟)

HTTP Request:
  - Method: GET
  - Path: /api/benchmark/${scenario}

Listener:
  - Summary Report(QPS、P99、错误率)
  - Backend Listener → InfluxDB(实时监控)

1.4 五个场景

场景描述典型业务
1. 纯 IOHTTP 调用外部 API(100ms 延迟)微服务 RPC 调用
2. 纯 CPU计算斐波那契 fib(40)加密、压缩、计算
3. 混合查 DB + 调 API标准 Web 后端
4. 高并发小任务1000 个轻量 DB 查询(5ms)列表查询
5. 长连接WebSocket 推送 60 秒实时推送、聊天

二、场景 1:纯 IO(HTTP 调用)

2.1 场景描述

每个请求调用一个外部 HTTP API,模拟 100ms 网络延迟:

@Async("pool200")  // / pool500 / virtual
public CompletableFuture<String> callApi() {
    return CompletableFuture.completedFuture(
        restTemplate.getForObject("http://localhost:9000/mock?delay=100", String.class)
    );
}

外部 API 用 Spring Boot 起个 mock 服务,固定 sleep 100ms 模拟网络延迟。

2.2 压测结果

10,000 并发,持续 10 分钟:

方案QPSP50P99CPU内存错误率
Pool-2001,820102ms5,200ms30%1.2GB0%
Pool-5004,560102ms1,800ms40%2.5GB0%
VT9,820102ms180ms65%450MB0%

2.3 数据分析

QPS 提升明显

Pool-200 → VT:1,820 → 9,820(5.4 倍)
Pool-500 → VT:4,560 → 9,820(2.2 倍)

为什么 VT 这么强?

理论上限:10,000 并发 × (1s / 100ms) = 10,000 QPS。VT 实测 9,820,几乎打满。

线程池为什么打不满?

Pool-200:
  200 线程 × 10 QPS(100ms 一次) = 2,000 QPS(理论上限)
  实测 1,820(90% 利用率,剩下是开销)

Pool-500:
  500 线程 × 10 QPS = 5,000 QPS(理论上限)
  实测 4,560(91% 利用率)

VT:
  无线程数限制 → 直接吃满 10,000 QPS

P99 延迟差距巨大

Pool-200:5,200ms(请求堆积,等线程释放)
Pool-500:1,800ms(还是堆)
VT:       180ms(接近真实 IO 延迟)

线程池 200 时,请求在队列里等线程,P99 飙到 5.2 秒。

VT 内存还更省

Pool-500:2.5GB(500 × 5MB 栈)
VT:      450MB(10,000 VT × 45KB)

2.4 结论

纯 IO 场景,VT 完胜

  • QPS 提升 2-5 倍
  • P99 降低 10-30 倍
  • 内存还更省

三、场景 2:纯 CPU(斐波那契计算)

3.1 场景描述

每个请求计算 fib(40),纯 CPU 密集任务:

public long fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

// 调用
fib(40);  // ~1 秒计算时间

3.2 压测结果

方案QPSP50P99CPU内存错误率
Pool-2008.01,020ms1,250ms800%1.5GB0%
Pool-5008.01,020ms1,280ms800%3.0GB0%
VT8.01,020ms1,270ms800%2.2GB0%

3.3 数据分析

三组数据完全一样!

为什么?因为这是纯 CPU 任务,瓶颈是 CPU 算力,不是线程数。

8 核 CPU → 每秒执行 8 个 fib(40)(每个 ~1 秒 CPU)
QPS 上限 = 8(无论多少线程都一样)

线程池 200 / 500 / VT:
  CPU 都是 800%(8 核全打满)
  QPS 都是 8(CPU 物理上限)
  内存差异只是因为线程数不同

3.4 内存对比

Pool-200:1.5GB(200 线程,每个 ~5MB 栈)
Pool-500:3.0GB(500 线程,每个 ~5MB 栈)
VT:      2.2GB(10,000 VT × 200KB,含 fib 栈)

注意 VT 在 CPU 密集场景内存反而更高,因为 fib 是递归调用,栈深度大,每个虚拟线程的栈都被填满。

3.5 结论

纯 CPU 场景,VT 没有任何优势

  • QPS 无提升
  • CPU 是物理上限
  • 内存可能更多(因为大量挂起 VT 的栈)

VT 不解决 CPU 瓶颈——它解决的是"IO 等待时空闲的线程"。


四、场景 3:混合场景(DB + API)

4.1 场景描述

最贴近真实业务——一个请求既查 DB 又调外部 API:

public OrderDetailVO queryOrder(Long orderId) {
    // 1. 查 DB(50ms)
    Order order = orderDao.findById(orderId);

    // 2. 调用户服务(100ms)
    User user = userServiceClient.findById(order.getUserId());

    // 3. 调物流服务(80ms)
    Logistics logistics = logisticsClient.findByOrderId(orderId);

    return buildVO(order, user, logistics);
}

总耗时 ~230ms(如果串行),其中 180ms 在等外部调用。

4.2 压测结果

方案QPSP50P99CPU内存错误率
Pool-200870235ms1,830ms45%1.5GB0%
Pool-5001,950235ms950ms50%3.0GB0%
VT4,120235ms320ms60%520MB0%

4.3 数据分析

QPS 提升 2-5 倍

Pool-200 → VT:870 → 4,120(4.7 倍)
Pool-500 → VT:1,950 → 4,120(2.1 倍)

理论分析

每请求 230ms(180ms IO + 50ms CPU)
10,000 并发:
  - 理论 QPS = 10,000 / 0.23s ≈ 43,000
  - 但受 CPU 限制(8 核 × 1000ms / 50ms = 160 QPS/核 = 1,280 QPS)
  - 实际 VT 4,120(远超 CPU 上限是因为 50ms CPU 是估算)

P99 延迟

Pool-200:1,830ms(队列堆积)
Pool-500:950ms(队列仍有堆积)
VT:       320ms(接近真实处理时间 235ms)

4.4 进阶:并行查询优化

VT 配合并行查询效果更猛:

public OrderDetailVO queryOrderParallel(Long orderId) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 三个查询并行
        Subtask<Order> orderTask = scope.fork(() -> orderDao.findById(orderId));
        Subtask<User> userTask = scope.fork(() ->
            userServiceClient.findById(orderTask.get().getUserId()));
        Subtask<Logistics> lgTask = scope.fork(() -> logisticsClient.findByOrderId(orderId));

        scope.join();
        return buildVO(orderTask.get(), userTask.get(), lgTask.get());
    }
}

总耗时从 230ms 降到 100ms(最长查询时间)。

4.5 重新压测

方案QPSP50P99CPU
Pool-500(串行)1,950235ms950ms50%
Pool-500(并行)2,800105ms480ms60%
VT(串行)4,120235ms320ms60%
VT(并行)8,900105ms180ms75%

VT + 并行查询:QPS 8,900,是 Pool-500 串行的 4.6 倍。

4.6 结论

真实业务场景(混合),VT 大幅领先

  • 串行:QPS 提升 2-5 倍
  • 并行:QPS 提升 3-5 倍
  • VT + 结构化并发是黄金组合

五、场景 4:高并发小任务

5.1 场景描述

每请求查 1 条数据,DB 响应极快(5ms):

public Product getProduct(Long id) {
    return productDao.findById(id);  // 5ms
}

10,000 并发,但每个请求只占 5ms。这是典型的"高频小查询"场景。

5.2 压测结果

方案QPSP50P99CPU内存错误率
Pool-20032,0005ms80ms80%1.8GB0%
Pool-50045,0005ms35ms85%3.5GB0%
VT62,0005ms15ms90%800MB0%

5.3 数据分析

QPS 提升但有上限

Pool-200 → VT:32,000 → 62,000(1.9 倍)
Pool-500 → VT:45,000 → 62,000(1.4 倍)

提升幅度比场景 1 小,因为:

  • 5ms 任务,IO 等待时间短
  • 线程切换开销相对更大
  • VT 的 yield 开销在小任务里更明显

P99 VT 反而最好

Pool-200:80ms(200 线程切换排队)
Pool-500:35ms
VT:       15ms(无队列、无切换开销)

DB 反而是瓶颈

62,000 QPS × 5ms / 8 = ~390 并发查询
MySQL 连接池(HikariCP 默认 10)打爆

要提升 QPS,得先扩 DB 连接池到 500+。

5.4 DB 连接池调优后

把 HikariCP maximumPoolSize 调到 200:

方案QPSP99DB CPU错误率
Pool-20078,00060ms95%0%
Pool-50095,00028ms98%0%
VT128,00012ms99%0%

VT 达到 12.8 万 QPS,瓶颈已经是 DB CPU。

5.5 结论

高频小任务场景

  • VT 提升相对小(1.4-1.9 倍)
  • 但 P99 优势明显(15ms vs 80ms)
  • 瓶颈转移到 DB / 网卡
  • 要进一步提升得先解决 DB 连接池

六、场景 5:长连接(WebSocket)

6.1 场景描述

10,000 个 WebSocket 长连接,服务端每秒推一条消息:

@MessageMapping("/chat")
public void onMessage(String msg, SimpMessageHeaderAccessor headers) {
    // 处理消息,10ms 业务逻辑
    String reply = processMessage(msg);
    messagingTemplate.convertAndSend("/topic/chat/" + headers.getSessionId(), reply);
}

每个连接保持 60 秒。

6.2 压测结果

方案在线连接数推送 QPSP99 延迟CPU内存错误率
Pool-2005,2005,2001,800ms50%4.5GB0%(拒绝连接)
Pool-5008,5008,500950ms60%7.5GB0%(拒绝连接)
VT50,000+50,000+85ms40%3.8GB0%

6.3 数据分析

连接数 VT 碾压

Pool-200:5,200 连接就打满(200 线程处理不过来)
Pool-500:8,500 连接
VT:      50,000+ 连接(已超 10,000 测试目标)

为什么 VT 连接数这么大优势?

传统线程池:
  每个连接占用 1 个线程(即使连接空闲也占着)
  200 线程 → 200 连接是上限

VT:
  连接空闲时 yield → 载体线程跑别的连接
  10,000 连接实际只用 8 个载体线程

内存 VT 更省

Pool-500:7.5GB(500 × 15MB,含栈和上下文)
VT:      3.8GB(10,000 × 380KB)

6.4 真实场景:聊天室推送

模拟 10 万用户在线聊天:

方案最大在线推送延迟内存是否可用
Pool-5008,500950ms7.5GB❌(容量不够)
Pool-200030,0001,200ms25GB⚠️(内存吃满)
VT100,000+85ms3.8GB

VT 是长连接场景的杀手锏

6.5 结论

长连接场景,VT 完胜

  • 连接数提升 10-20 倍
  • 内存更省(连接复用)
  • 延迟更低
  • 是 Netty / 长连接业务的未来

七、综合数据汇总

7.1 五场景完整对比

场景Pool-200 QPSPool-500 QPSVT QPSVT vs Pool-500
纯 IO(100ms)1,8204,5609,8202.2x
纯 CPU(fib 40)8881.0x
混合(DB+API)8701,9504,1202.1x
高频小任务78,00095,000128,0001.4x
长连接5,200 连接8,500 连接50,000+ 连接5.9x

7.2 内存对比

场景Pool-200Pool-500VTVT 优势
纯 IO1.2GB2.5GB450MB5-6x 省
纯 CPU1.5GB3.0GB2.2GB反而更费
混合1.5GB3.0GB520MB5-6x 省
高频小任务1.8GB3.5GB800MB4-5x 省
长连接4.5GB7.5GB3.8GB2x 省

7.3 P99 延迟对比

场景Pool-200Pool-500VTVT 优势
纯 IO5,200ms1,800ms180ms10-30x 低
纯 CPU1,250ms1,280ms1,270ms持平
混合1,830ms950ms320ms3-6x 低
高频小任务60ms28ms12ms2-5x 低
长连接1,800ms950ms85ms11x 低

八、核心发现

8.1 发现 1:IO 密集场景吞吐提升 3-8 倍

纯 IO:       2.2x(VT vs Pool-500)
混合场景:    2.1x
高频小任务:  1.4x(受 DB 限制)
长连接:      5.9x

IO 等待越长,VT 优势越明显。

8.2 发现 2:CPU 密集场景几乎无提升

纯 CPU 场景三组数据完全一致——VT 不解决 CPU 瓶颈

8.3 发现 3:VT 内存优势明显(CPU 场景除外)

IO 场景:VT 省 5-6 倍内存
CPU 场景:VT 反而更费(栈深度大)

8.4 发现 4:VT 的延迟优势比 QPS 更惊人

IO 场景 P99:VT 180ms vs Pool-500 1,800ms(10 倍)
长连接 P99:VT 85ms vs Pool-500 950ms(11 倍)

VT 不只是"快",是"稳"——没有队列堆积,延迟可预测。

8.5 发现 5:瓶颈会转移

VT 解决了"线程不够"的问题,但瓶颈会转移到其他地方:

原瓶颈VT 后的新瓶颈
线程数DB 连接池
DB 连接池DB CPU
DB CPU网卡带宽
单机下游服务

优化要跟着瓶颈走


九、什么场景该用 VT

9.1 决策树

你的场景是?
├── 纯 CPU 计算 → ❌ 不要用 VT,无收益
├── 纯 IO 调用 → ✅ 强烈推荐(2-6 倍提升)
├── 混合业务 → ✅ 强烈推荐(2-5 倍提升)
├── 高并发小任务 → ⚠️ 可以用,但瓶颈在 DB
├── 长连接 → ✅✅ 必须用(5-20 倍提升)
└── 不知道 → 用 VT,至少不比线程池差

9.2 不适用场景

  • CPU 密集:加密、压缩、复杂计算
  • 大量 synchronized:会导致 pinning(载体线程被钉死)
  • 大量 native 调用:无法 yield
  • 老 JDK:需要 JDK 21+

9.3 推荐场景

  • Web API 服务(99% 后端开发)
  • 微服务 RPC 调用
  • BFF 层(Backend For Frontend)
  • 网关 / API Gateway
  • 实时推送 / 聊天 / WebSocket
  • 爬虫 / 批量数据同步

十、踩坑记录

10.1 synchronized 导致 pinning

第一轮压测混合场景,VT 的 P99 飙到 2 秒:

排查:
  -Djdk.tracePinnedThreads=full
  → 打印出大量 "pinned" 日志
  → 发现 OrderService 用了 synchronized 块

修复:把 synchronized 换成 ReentrantLock

// ❌ 导致 pinning
public synchronized Order getOrder(Long id) {
    return orderDao.findById(id);
}

// ✅ 正常 yield
private final ReentrantLock lock = new ReentrantLock();
public Order getOrder(Long id) {
    lock.lock();
    try {
        return orderDao.findById(id);
    } finally {
        lock.unlock();
    }
}

修复后 VT 的 P99 从 2 秒降到 320ms。

10.2 ThreadLocal 内存爆炸

VT 数量大时,ThreadLocal 占用大量内存:

// ❌ 每个 VT 一份 ThreadLocal
private static ThreadLocal<SimpleDateFormat> sdf =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 10,000 VT × 1MB = 10GB(爆!)

修复:用 ScopedValue(JDK 21+):

// ✅ 共享不可变值
private static final ScopedValue<SimpleDateFormat> SDF =
    ScopedValue.newInstance();

// 使用
ScopedValue.where(SDF, new SimpleDateFormat("yyyy-MM-dd"))
          .run(() -> {
              SDF.get().format(new Date());
          });

10.3 数据库连接池打爆

VT 解决了线程数问题,但 DB 连接池没改:

压测场景 4:
  VT QPS 飙到 30,000
  DB 连接池(HikariCP 默认 10)打爆
  报错:Connection is not available

修复:连接池扩容到 200,但要注意 MySQL max_connections

spring:
  datasource:
    hikari:
      maximum-pool-size: 200  # 跟着 VT 扩

# MySQL 配置
[mysqld]
max_connections = 1000

10.4 线程池里包虚拟线程

错误写法:

// ❌ 用线程池跑虚拟线程(无意义)
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(() -> Thread.startVirtualThread(task));

正确写法:直接用虚拟线程:

// ✅ 直接用 VT
Thread.startVirtualThread(task);

虚拟线程不需要池化——它的创建成本和池化复用差不多。


十一、性能调优建议

11.1 VT 启动参数

# 推荐 JVM 参数
java -XX:+UseZGC \
     -Xms4g -Xmx4g \
     -XX:+UnlockExperimentalVMOptions \
     -XX:ZAllocationSpikeTolerance=5 \
     -Djdk.tracePinnedThreads=short \  # 排查 pinning
     -jar app.jar

11.2 监控指标

指标含义关注点
载体线程数ForkJoinPool 大小应等于 CPU 核数
虚拟线程数活跃 VT 数是否符合预期
pinning 次数钉死载体线程次数应为 0
DB 连接池使用率连接占用率< 80%
P99 延迟尾延迟应稳定

11.3 载体线程池调优

默认载体线程数 = CPU 核数。可以调大:

// 启动参数
-Djdk.virtualThreadScheduler.parallelism=16  // 默认 = CPU 核数

// 或代码
System.setProperty("jdk.virtualThreadScheduler.parallelism", "16");

但通常不需要调——CPU 核数已经是最优。


十二、总结

五场景结论速查

场景该不该用 VT提升幅度
纯 IO✅ 强烈推荐QPS 2-6x,P99 10-30x
纯 CPU❌ 无收益持平
混合业务✅ 强烈推荐QPS 2-5x,P99 3-6x
高频小任务⚠️ 可以用QPS 1.4x,P99 2-5x
长连接✅✅ 必须用连接数 6-20x

核心结论

  1. VT 不解决 CPU 瓶颈——它解决"IO 等待时线程空转"
  2. IO 等待越长,VT 优势越大——长连接 > 纯 IO > 混合 > 小任务
  3. VT 内存更省(CPU 场景除外)——虚拟线程只占几 KB
  4. VT 延迟更稳定——没有队列堆积,P99 可预测
  5. 瓶颈会转移——VT 解决线程瓶颈后,瓶颈转移到 DB / 网络

给团队的建议

  • 新项目:直接用 VT(JDK 21+),不需要线程池
  • 老项目:核心 IO 接口逐步切 VT,CPU 密集保留线程池
  • 架构师:评估是否需要 Netty → VT 替代(WebFlux 可能没落)
  • 运维:监控 DB 连接池,VT 上线后连接池要扩容

最后的判断

虚拟线程不是银弹——它只在 IO 密集场景有收益。但 90% 的 Web 后端都是 IO 密集,所以对大多数团队来说,VT 就是性能革命的下一站。

互动话题:你们生产环境用上虚拟线程了吗?在哪个场景提升最明显?欢迎留言分享数据!


附录:JMeter 压测脚本

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="VT Benchmark" enabled="true">
      <stringProp name="TestPlan.comments">Virtual Threads vs ThreadPool</stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="10K Concurrent" enabled="true">
        <intProp name="ThreadGroup.num_threads">10000</intProp>
        <intProp name="ThreadGroup.ramp_time">10</intProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">false</boolProp>
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController">
          <boolProp name="LoopController.continue_forever">true</boolProp>
          <stringProp name="LoopController.loops">-1</stringProp>
        </elementProp>
        <intProp name="ThreadGroup.duration">600000</intProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="${__P(scenario,io)}" enabled="true">
          <stringProp name="HTTPSampler.path">/api/benchmark/${__P(scenario,io)}</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
        </HTTPSamplerProxy>
        <hashTree>
          <ResponseCodeAssertion guiclass="AssertionGui" testclass="ResponseCodeAssertion" testname="Assert 200">
            <collectionProp name="Asserion.test_strings">
              <stringProp name="49586">200</stringProp>
            </collectionProp>
          </ResponseCodeAssertion>
          <hashTree/>
        </hashTree>
        <BackendListener guiclass="BackendListenerGui" testclass="BackendListener" testname="InfluxDB">
          <stringProp name="backend_influxdb_measurement">vt_benchmark</stringProp>
          <stringProp name="backend_influxdb_token">token</stringProp>
        </BackendListener>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

运行命令:

# 测试 IO 场景
jmeter -n -t vt_benchmark.jmx -Jscenario=io -Jduration=600

# 测试 CPU 场景
jmeter -n -t vt_benchmark.jmx -Jscenario=cpu -Jduration=600

参考资料


标题:10,000 并发压测:Virtual Threads vs 线程池——5 个场景的真实数据对比
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/12/1786163638487.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消