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. 测试目标
本次测试主要验证以下问题:
- 固定线程池在并发量超过线程数后,是否会形成明显的任务排队。
- Virtual Thread 是否能够降低执行器排队时间。
- Virtual Thread 是否能够提升阻塞式 LLM 调用的并发承载能力。
- 性能提升究竟来自 LLM 执行速度变化,还是线程调度模型变化。
- 高并发下是否存在 Virtual Thread Pinning。
- 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. 测试方法
每个场景采用以下流程:
- 预热 30 秒。
- 每个 worker 发送 40 个正式请求。
- 每种并发度运行 3 次。
- 最终结果取 3 次测试的中位数。
- 每个 worker 使用独立持久 HTTP/1.1 连接。
- Fixed 和 Virtual 模式串行运行,避免资源竞争。
- JVM 指标每 250 ms 采样一次。
- 两种模式只切换执行器配置,其他测试条件保持一致。
请求数量
并发档位总和:
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 固定线程数这一层瓶颈后,系统瓶颈并不会消失,而会转移到更真实的约束:
- LLM Provider 响应速度;
- Provider QPS、RPM、TPM 配额;
- HTTP Connection Pool;
- Socket 和文件描述符;
- JVM 内存;
- Web Server;
- Redis、数据库、对象存储等业务依赖;
- 调用费用和预算上限。
本次 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. 后续测试建议
为了进一步增强结论的完整性,可以补充以下实验:
- 在 JDK 21 下复测 50、200、500 并发档位。
- 增加 Fixed-100、Fixed-500 与 Virtual Thread 的对照。
- 接入真实 LLM Provider,小规模验证真实网络和配额影响。
- 增加不同 Mock 延迟,例如 100 ms、500 ms、3 s 和 10 s。
- 验证 HTTP Connection Pool 上限对吞吐的影响。
- 增加 Semaphore / Bulkhead 后观察吞吐、排队和拒绝率。
- 增加超时、重试和故障注入测试。
- 分析 Heap、RSS、Platform Thread 和 Carrier Thread 的变化。
- 对真实业务链路进行 JFR Pinning 检查。
- 补充 CPU 密集型任务,明确 Virtual Thread 的适用边界。