Virtual Thread 优化 Spring AI 阻塞式 LLM I/O:对照压测报告

Virtual Thread 优化 Spring AI 阻塞式 LLM I/O:对照压测报告

摘要

在基于 Spring AI 的 LLM 调用链中,ChatClient.prompt().call() 属于同步阻塞式 HTTP I/O。请求等待模型响应期间,业务线程几乎不消耗 CPU,却会持续占用平台线程。当并发量超过固定线程池容量后,大量请求会进入执行器队列,导致端到端延迟快速增长。

本次测试构建了一个独立运行的 OpenAI-compatible Mock LLM,通过真实 HTTP 链路固定模拟 1500 ms 响应,并对以下两种执行模型进行对照:

  • 固定大小为 32 的平台线程池;
  • 每个任务创建一个 Virtual Thread。

正式测试覆盖 10、50、100、200、500 五个并发档位,每组重复 3 次并取中位数,共完成 206,400 次请求,成功 206,400 次,失败 0 次

500 并发时,Virtual Thread 将吞吐量从 21.24 req/s 提升至 325.52 req/s ,端到端 P95 延迟从 24.14 s 降至 1.52 s。两组执行阶段的 P95 均约为 1.51 s,说明性能提升并非来自 LLM 响应变快,而是来自固定线程池排队时间的消除。

关键词: Java、Virtual Thread、Spring AI、LLM、阻塞式 I/O、性能测试、JFR、Thread Dump


1. 测试背景

项目中的典型 LLM 调用链如下:

text 复制代码
HTTP Request
  -> Spring MVC Controller
  -> Service
  -> ExecutorService
  -> Spring AI ChatClient.prompt().call()
  -> OpenAI-compatible HTTP API

ChatClient.prompt().call() 是同步阻塞调用。线程发起 HTTP 请求后,需要等待模型服务返回完整响应。在等待期间,线程没有持续执行 CPU 计算,但传统平台线程仍然会被占用。

LLM 调用通常具有以下特点:

  • 单次响应时间较长;
  • 主要耗时来自网络和远端推理等待;
  • CPU 计算占比低;
  • 高并发下容易耗尽业务线程;
  • 线程池饱和后,尾延迟主要由排队时间构成。

因此,本次测试重点验证 Virtual Thread 是否适合承载同步阻塞式 LLM I/O。


2. 测试目标

本次测试主要验证以下问题:

  1. 固定线程池在并发量超过线程数后,是否会形成明显的任务排队。
  2. Virtual Thread 是否能够降低执行器排队时间。
  3. Virtual Thread 是否能够提升阻塞式 LLM 调用的并发承载能力。
  4. 性能提升究竟来自 LLM 执行速度变化,还是线程调度模型变化。
  5. 高并发下是否存在 Virtual Thread Pinning。
  6. Virtual Thread 是否会把瓶颈转移到 HTTP 连接、内存和上游服务配额等其他位置。

3. 核心假设

固定线程池配置如下:

text 复制代码
corePoolSize = 32
maxPoolSize = 32
queueCapacity = 10000
rejectionPolicy = AbortPolicy

Mock LLM 的固定响应时间为 1.5 s,因此固定线程池的理论吞吐上限约为:

text 复制代码
32 / 1.5 = 21.33 req/s

当并发数超过 32 时:

text 复制代码
32 个工作线程阻塞在 HTTP 响应读取
        ↓
后续任务无法立即执行
        ↓
任务进入 Executor Queue
        ↓
Queue Wait 持续增加
        ↓
端到端 P95 / P99 延迟上升

Virtual Thread 模式使用:

java 复制代码
Executors.newVirtualThreadPerTaskExecutor();

每个请求对应一个 Virtual Thread。当请求阻塞在可挂起的网络 I/O 上时,Virtual Thread 可以暂时释放 Carrier Thread,从而避免每个等待中的业务任务长期绑定一个平台线程。

需要注意,Virtual Thread 并不会消除所有平台线程。JVM Runtime、Web 容器、HTTP Client 和 Carrier Thread 仍然需要平台线程。


4. 测试架构

为了排除真实 LLM Provider 的随机响应时间、限流和费用等因素,本次测试使用独立 Mock LLM。

完整调用链如下:

text 复制代码
异步 HTTP 压测客户端
  -> Spring MVC BenchmarkController
  -> BenchmarkLlmService
  -> 可切换 ExecutorService
  -> Spring AI ChatClient.prompt().call()
  -> OpenAI-compatible HTTP Client
  -> 独立 JVM Mock LLM
  -> 固定等待 1500 ms
  -> 返回标准 Chat Completions JSON

Mock LLM 与被测应用运行在两个独立 JVM 中,因此 Mock 服务自身的等待线程、堆内存和 CPU 不计入被测应用指标。

测试链路仍然真实包含:

  • TCP Socket;
  • HTTP 请求与响应;
  • Spring MVC Controller;
  • Service;
  • Executor 调度;
  • Spring AI;
  • HTTP Client;
  • JSON 序列化与反序列化。

因此,本次测试不是简单地使用 Thread.sleep() 替代完整业务链路。


5. 测试环境

项目 配置
操作系统 Darwin 27.0.0,arm64
CPU Apple M4,10 核
内存 24 GiB
Benchmark JDK Eclipse Temurin 25.0.3+9 LTS
Spring Boot 4.1.0
Spring AI 2.0.0
被测应用堆内存 -Xms512m -Xmx512m
Mock 服务堆内存 -Xms256m -Xmx256m
GC G1GC
Mock 固定延迟 1500 ms
Fixed Pool 大小 32
并发档位 10 / 50 / 100 / 200 / 500
Pinning 检查 -Djdk.tracePinnedThreads=full

本次正式压测实际运行在 JDK 25.0.3 上。测试结论可以用于说明 Virtual Thread 的并发模型,但不应直接表述为"Java 21 环境实测结果"。若需要与 Java 21 强绑定,应在 JDK 21 下补跑关键档位。


6. 测试方法

每个场景采用以下流程:

  1. 预热 30 秒。
  2. 每个 worker 发送 40 个正式请求。
  3. 每种并发度运行 3 次。
  4. 最终结果取 3 次测试的中位数。
  5. 每个 worker 使用独立持久 HTTP/1.1 连接。
  6. Fixed 和 Virtual 模式串行运行,避免资源竞争。
  7. JVM 指标每 250 ms 采样一次。
  8. 两种模式只切换执行器配置,其他测试条件保持一致。

请求数量

并发档位总和:

text 复制代码
10 + 50 + 100 + 200 + 500 = 860

单轮、单模式请求数:

text 复制代码
860 × 40 = 34,400

每种模式重复 3 次:

text 复制代码
34,400 × 3 = 103,200

两种执行模式合计:

text 复制代码
103,200 × 2 = 206,400

本次测试未执行 1000 并发,也未直接压测真实 LLM Provider。


7. 指标定义

7.1 End-to-End Latency

从压测客户端发出 HTTP 请求,到完整读取响应的总耗时。

text 复制代码
End-to-End Latency
=
网络传输
+ 服务端排队
+ 业务执行
+ Mock LLM 等待
+ 响应返回

7.2 Queue Wait

任务提交到执行器后,等待真正开始执行的时间。

text 复制代码
queueWait = startExecuteTime - submitTime

该指标用于判断请求是否卡在 Executor Queue 中。

7.3 Execution Time

任务开始执行,到任务执行完成的时间。

text 复制代码
executionTime = finishTime - startExecuteTime

该时间包含 Spring AI 调用、HTTP 请求、Mock LLM 的 1500 ms 等待以及响应解析。

7.4 Throughput

单位时间内完成的请求数:

text 复制代码
Throughput = completedRequests / elapsedTime

7.5 其他观测指标

  • Error Rate;
  • Executor Active Threads;
  • Executor Queue Size;
  • 活跃 Virtual Thread 任务数;
  • Platform Thread 数量;
  • Heap、RSS、CPU;
  • JFR VirtualThreadPinned 事件;
  • Thread Dump 调用栈。

8. 测试结果

8.1 总体结果

正式测试共完成:

text 复制代码
总请求数:206,400
成功请求:206,400
失败请求:0
错误率:0%

8.2 500 并发关键结果

指标 Fixed-32 Virtual Thread 变化
Throughput 21.24 req/s 325.52 req/s +1432.59%
End-to-End P95 24.14 s 1.52 s -93.68%
Queue Wait P95 22.62 s 3.65 ms -99.98%
Execution P95 1.512 s 1.518 s 基本一致
Error Rate 0% 0% 无变化

结果表明,两种模式的实际执行耗时基本一致,性能差异主要来自 Fixed-32 的队列等待。

8.3 低并发结果

10 并发时:

text 复制代码
Fixed Throughput   = 6.63 req/s
Virtual Throughput = 6.63 req/s

Fixed P95   ≈ 1.513 s
Virtual P95 ≈ 1.512 s

此时并发数低于固定线程池容量,请求不需要明显排队,因此 Virtual Thread 没有表现出业务意义上的优势。

这说明测试结果并不是"Virtual Thread 在任何并发下都更快",而是当固定平台线程成为瓶颈后,Virtual Thread 的优势才会出现。

8.4 Fixed-32 排队趋势

Fixed-32 的 Queue Wait P95 随并发增长如下:

并发数 Queue Wait P95
50 1508.61 ms
100 4518.57 ms
200 9046.99 ms
500 22624.26 ms

与此同时,Execution P95 始终约为 1.51 s。

因此,高并发下变慢的并不是 LLM HTTP 调用本身,而是请求在固定线程池中的等待时间。

8.5 Virtual Thread 吞吐趋势

并发数 Virtual Thread 吞吐
50 33.15 req/s
100 66.18 req/s
200 131.37 req/s
500 325.52 req/s

吞吐量随并发度近似线性增长。

500 并发、1.5 s 固定响应时间下的理论吞吐为:

text 复制代码
500 / 1.5 = 333.33 req/s

实际吞吐为 325.52 req/s,约达到理论值的 97.66%。这说明在该场景下,Executor 已经不再是主要瓶颈。


9. 结果分析

9.1 Fixed-32 的吞吐上限符合理论值

固定线程池理论吞吐:

text 复制代码
32 / 1.5 = 21.33 req/s

实测在 100、200、500 并发下,吞吐量稳定在约 21 req/s,500 并发时为 21.24 req/s。

实测结果与理论值高度接近,说明固定线程池的 32 个工作线程已经全部被阻塞式 LLM I/O 占满。

9.2 Virtual Thread 没有让 LLM 推理更快

500 并发时:

text 复制代码
Fixed Execution P95   = 1.512 s
Virtual Execution P95 = 1.518 s

两组执行时间基本一致。

因此,不能将结果解释为"Virtual Thread 提高了模型推理速度"。它优化的是线程等待与任务调度模型,而不是远端模型本身。

9.3 优化的本质是消除显式排队

端到端耗时可以拆分为:

text 复制代码
End-to-End Latency
=
Queue Wait
+ Execution Time
+ 其他网络与框架开销

Fixed-32 在 500 并发下:

text 复制代码
Queue Wait P95 ≈ 22.62 s
Execution P95  ≈ 1.51 s
End-to-End P95 ≈ 24.14 s

Virtual Thread 模式中 Queue Wait 几乎消失,因此端到端 P95 接近实际执行时间。


10. Thread Dump 证据

Fixed-32 在 500 并发时的 Thread Dump 显示:

  • 存在 32 个 llm-fixed-* 平台线程;
  • 32 个线程全部停留在 Spring AI / HTTP Client 的 Socket Response Read 调用栈;
  • Executor Active Thread Peak 为 32;
  • Executor Queue Peak 为 468。

这与并发模型完全一致:

text 复制代码
500 个并发任务
- 32 个任务正在执行
- 468 个任务进入队列

Virtual Thread 模式中:

text 复制代码
Peak Active Virtual Tasks = 500
Executor Queue = 0

因此,可以将性能差异明确归因于固定业务平台线程数量造成的排队瓶颈。


11. Pinning 检查

测试使用以下参数输出 Virtual Thread Pinning 信息:

text 复制代码
-Djdk.tracePinnedThreads=full

同时通过 JFR 检查 VirtualThreadPinned 事件。

在本次被测 Spring AI + HTTP 调用链中,没有观察到明显 Pinning 事件,说明该调用链能够正常挂起 Virtual Thread,并释放 Carrier Thread。

但该结论仅适用于本次测试链路,不能直接外推为整个项目不存在 Pinning 风险。以下情况仍需重点检查:

  • synchronized 临界区内执行长时间阻塞调用;
  • Native 方法中长时间持锁;
  • 使用锁包裹外部 HTTP 调用;
  • 在数据库事务中执行耗时 LLM 请求;
  • 第三方库内部存在不兼容的阻塞实现。

12. 资源开销

Virtual Thread 并不是"零成本并发"。

本次测试在 500 并发时观察到 Virtual 模式的 RSS 比 Fixed 模式高约 156 MiB。但由于测试过程中复用了 JVM,且 GC 时机存在差异,该结果不能用于精确计算单个 Virtual Thread 的内存开销。

更合理的结论是:

Virtual Thread 使用一定的运行时资源,换取更高的阻塞式 I/O 并发承载能力。工程中仍需持续关注内存、HTTP 连接、Socket、文件描述符和上游服务容量。


13. 为什么仍然需要限流

固定线程池在一定程度上会形成隐式背压:

text 复制代码
线程池满
  -> 请求进入队列
  -> 超过队列容量后拒绝

Virtual Thread Per Task Executor 不使用传统任务队列,因此可以快速创建大量并发任务。如果缺少显式限流,流量可能直接打到 LLM Provider。

生产环境仍应根据真实容量设置:

  • Semaphore;
  • Bulkhead;
  • Rate Limiter;
  • 请求超时;
  • 重试上限;
  • 熔断策略;
  • HTTP Connection Pool 上限。

三类机制的职责不同:

text 复制代码
Virtual Thread:降低阻塞等待的平台线程成本

Semaphore / Bulkhead:限制同时进入某个依赖的任务数

Rate Limiter:限制单位时间内的请求数

并发上限应由 Provider 的 QPS、RPM、TPM、成本预算和连接容量决定,而不是由 JVM 能创建多少个 Virtual Thread 决定。


14. 瓶颈转移

Virtual Thread 消除 Executor 固定线程数这一层瓶颈后,系统瓶颈并不会消失,而会转移到更真实的约束:

  1. LLM Provider 响应速度;
  2. Provider QPS、RPM、TPM 配额;
  3. HTTP Connection Pool;
  4. Socket 和文件描述符;
  5. JVM 内存;
  6. Web Server;
  7. Redis、数据库、对象存储等业务依赖;
  8. 调用费用和预算上限。

本次 Benchmark 未访问 Redis、数据库和对象存储,因此不能证明这些组件在真实业务中不会成为新的瓶颈。

500 并发下 Virtual 模式 P95 约为 1.52 s,但 P99 已接近 1.95 s,也说明在更高并发下,HTTP 和运行时尾延迟开始出现。


15. 测试结论

本次测试可以证明:

  • Fixed-32 在阻塞式 LLM I/O 场景中,并发超过线程池容量后会产生明显排队。
  • 固定线程池高并发下的端到端延迟,主要由 Queue Wait 构成。
  • Virtual Thread 能显著降低 Executor Queue Wait。
  • Virtual Thread 能提升同步阻塞式 HTTP I/O 的并发承载能力。
  • 500 并发下,吞吐从 21.24 req/s 提升至 325.52 req/s。
  • 500 并发下,端到端 P95 从 24.14 s 降至 1.52 s。
  • 两组 Execution P95 都约为 1.51 s,Virtual Thread 没有改变 LLM 本身的响应速度。
  • 本次调用链没有观察到明显的 Virtual Thread Pinning。
  • Virtual Thread 不能替代限流、隔离、超时和熔断。

本次测试不能证明:

  • 真实 LLM Provider 在 500 并发下也能达到 325 QPS;
  • 整个项目的所有调用链都不存在 Pinning;
  • Redis、数据库和对象存储不会成为新瓶颈;
  • Virtual Thread 在 CPU 密集型任务中同样具有优势;
  • 当前结果等价于 Java 21 环境的测试结果;
  • Virtual Thread 的资源开销可以忽略。

16. 最终结论

本次优化的核心并不是让单次 LLM 请求执行得更快,而是改变阻塞式 I/O 的线程承载方式。

固定线程池中,一个等待远端响应的请求会长期占用一个平台线程。当请求量超过线程池容量后,新增请求只能排队,导致尾延迟快速增长。

Virtual Thread 允许每个请求继续使用直观的同步编程模型,同时在可挂起的阻塞 I/O 期间释放 Carrier Thread,从而消除固定业务线程数量带来的排队瓶颈。

因此,Virtual Thread 特别适合以下场景:

  • 同步阻塞式 HTTP 调用;
  • LLM Provider 调用;
  • 高延迟远程服务;
  • 大量并发但 CPU 占用较低的 I/O Bound 任务。

但在生产环境中,它必须与显式限流、依赖隔离、超时、重试和连接池容量控制配合使用。Virtual Thread 提升的是并发承载能力,而不是无限制地扩大系统容量。


17. 后续测试建议

为了进一步增强结论的完整性,可以补充以下实验:

  1. 在 JDK 21 下复测 50、200、500 并发档位。
  2. 增加 Fixed-100、Fixed-500 与 Virtual Thread 的对照。
  3. 接入真实 LLM Provider,小规模验证真实网络和配额影响。
  4. 增加不同 Mock 延迟,例如 100 ms、500 ms、3 s 和 10 s。
  5. 验证 HTTP Connection Pool 上限对吞吐的影响。
  6. 增加 Semaphore / Bulkhead 后观察吞吐、排队和拒绝率。
  7. 增加超时、重试和故障注入测试。
  8. 分析 Heap、RSS、Platform Thread 和 Carrier Thread 的变化。
  9. 对真实业务链路进行 JFR Pinning 检查。
  10. 补充 CPU 密集型任务,明确 Virtual Thread 的适用边界。
相关推荐
AKA__Zas2 小时前
芝士算法(前缀和2.0)
java·数据结构·算法·leetcode·哈希算法·学习方法
caishenzhibiao2 小时前
降雨带波段点差 同花顺期货通指标
java·c语言·c#
七夜zippoe2 小时前
OpenClaw Prompt 工程:从角色设定到 Skill 封装的 Agent 输出质量方法论
java·服务器·prompt·openclaw·skill封装
rosmis2 小时前
agent各指标定义
android·java·开发语言
mifengxing2 小时前
Java 集合进阶(一)
java·开发语言·数据结构·复习笔记
名字还没想好☜2 小时前
Python concurrent.futures 实战:用 ThreadPoolExecutor 并发处理 + as_completed 收结果
数据库·python·php·并发
令狐前生3 小时前
Intellij IDEA 2025 破解安装
java·ide·intellij-idea
数聚天成DeepSData3 小时前
数聚天成 DeepSData 数据价值落地实战指南
java·maven·devops
小小小米粒3 小时前
阿姆达尔定律(Amdahl‘s Law)
java·开发语言