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 Dump1. 测试背景项目中的典型 LLM 调用链如下HTTP Request - Spring MVC Controller - Service - ExecutorService - Spring AI ChatClient.prompt().call() - OpenAI-compatible HTTP APIChatClient.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. 核心假设固定线程池配置如下corePoolSize 32 maxPoolSize 32 queueCapacity 10000 rejectionPolicy AbortPolicyMock LLM 的固定响应时间为 1.5 s因此固定线程池的理论吞吐上限约为32 / 1.5 21.33 req/s当并发数超过 32 时32 个工作线程阻塞在 HTTP 响应读取 ↓ 后续任务无法立即执行 ↓ 任务进入 Executor Queue ↓ Queue Wait 持续增加 ↓ 端到端 P95 / P99 延迟上升Virtual Thread 模式使用Executors.newVirtualThreadPerTaskExecutor();每个请求对应一个 Virtual Thread。当请求阻塞在可挂起的网络 I/O 上时Virtual Thread 可以暂时释放 Carrier Thread从而避免每个等待中的业务任务长期绑定一个平台线程。需要注意Virtual Thread 并不会消除所有平台线程。JVM Runtime、Web 容器、HTTP Client 和 Carrier Thread 仍然需要平台线程。4. 测试架构为了排除真实 LLM Provider 的随机响应时间、限流和费用等因素本次测试使用独立 Mock LLM。完整调用链如下异步 HTTP 压测客户端 - Spring MVC BenchmarkController - BenchmarkLlmService - 可切换 ExecutorService - Spring AI ChatClient.prompt().call() - OpenAI-compatible HTTP Client - 独立 JVM Mock LLM - 固定等待 1500 ms - 返回标准 Chat Completions JSONMock LLM 与被测应用运行在两个独立 JVM 中因此 Mock 服务自身的等待线程、堆内存和 CPU 不计入被测应用指标。测试链路仍然真实包含TCP SocketHTTP 请求与响应Spring MVC ControllerServiceExecutor 调度Spring AIHTTP ClientJSON 序列化与反序列化。因此本次测试不是简单地使用Thread.sleep()替代完整业务链路。5. 测试环境项目配置操作系统Darwin 27.0.0arm64CPUApple M410 核内存24 GiBBenchmark JDKEclipse Temurin 25.0.39 LTSSpring Boot4.1.0Spring AI2.0.0被测应用堆内存-Xms512m -Xmx512mMock 服务堆内存-Xms256m -Xmx256mGCG1GCMock 固定延迟1500 msFixed Pool 大小32并发档位10 / 50 / 100 / 200 / 500Pinning 检查-Djdk.tracePinnedThreadsfull本次正式压测实际运行在 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 采样一次。两种模式只切换执行器配置其他测试条件保持一致。请求数量并发档位总和10 50 100 200 500 860单轮、单模式请求数860 × 40 34,400每种模式重复 3 次34,400 × 3 103,200两种执行模式合计103,200 × 2 206,400本次测试未执行 1000 并发也未直接压测真实 LLM Provider。7. 指标定义7.1 End-to-End Latency从压测客户端发出 HTTP 请求到完整读取响应的总耗时。End-to-End Latency 网络传输 服务端排队 业务执行 Mock LLM 等待 响应返回7.2 Queue Wait任务提交到执行器后等待真正开始执行的时间。queueWait startExecuteTime - submitTime该指标用于判断请求是否卡在 Executor Queue 中。7.3 Execution Time任务开始执行到任务执行完成的时间。executionTime finishTime - startExecuteTime该时间包含 Spring AI 调用、HTTP 请求、Mock LLM 的 1500 ms 等待以及响应解析。7.4 Throughput单位时间内完成的请求数Throughput completedRequests / elapsedTime7.5 其他观测指标Error RateExecutor Active ThreadsExecutor Queue Size活跃 Virtual Thread 任务数Platform Thread 数量Heap、RSS、CPUJFRVirtualThreadPinned事件Thread Dump 调用栈。8. 测试结果8.1 总体结果正式测试共完成总请求数206,400 成功请求206,400 失败请求0 错误率0%8.2 500 并发关键结果指标Fixed-32Virtual Thread变化Throughput21.24 req/s325.52 req/s1432.59%End-to-End P9524.14 s1.52 s-93.68%Queue Wait P9522.62 s3.65 ms-99.98%Execution P951.512 s1.518 s基本一致Error Rate0%0%无变化结果表明两种模式的实际执行耗时基本一致性能差异主要来自 Fixed-32 的队列等待。8.3 低并发结果10 并发时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 P95501508.61 ms1004518.57 ms2009046.99 ms50022624.26 ms与此同时Execution P95 始终约为 1.51 s。因此高并发下变慢的并不是 LLM HTTP 调用本身而是请求在固定线程池中的等待时间。8.5 Virtual Thread 吞吐趋势并发数Virtual Thread 吞吐5033.15 req/s10066.18 req/s200131.37 req/s500325.52 req/s吞吐量随并发度近似线性增长。500 并发、1.5 s 固定响应时间下的理论吞吐为500 / 1.5 333.33 req/s实际吞吐为 325.52 req/s约达到理论值的 97.66%。这说明在该场景下Executor 已经不再是主要瓶颈。9. 结果分析9.1 Fixed-32 的吞吐上限符合理论值固定线程池理论吞吐32 / 1.5 21.33 req/s实测在 100、200、500 并发下吞吐量稳定在约 21 req/s500 并发时为 21.24 req/s。实测结果与理论值高度接近说明固定线程池的 32 个工作线程已经全部被阻塞式 LLM I/O 占满。9.2 Virtual Thread 没有让 LLM 推理更快500 并发时Fixed Execution P95 1.512 s Virtual Execution P95 1.518 s两组执行时间基本一致。因此不能将结果解释为“Virtual Thread 提高了模型推理速度”。它优化的是线程等待与任务调度模型而不是远端模型本身。9.3 优化的本质是消除显式排队端到端耗时可以拆分为End-to-End Latency Queue Wait Execution Time 其他网络与框架开销Fixed-32 在 500 并发下Queue Wait P95 ≈ 22.62 s Execution P95 ≈ 1.51 s End-to-End P95 ≈ 24.14 sVirtual 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 为 32Executor Queue Peak 为 468。这与并发模型完全一致500 个并发任务 - 32 个任务正在执行 - 468 个任务进入队列Virtual Thread 模式中Peak Active Virtual Tasks 500 Executor Queue 0因此可以将性能差异明确归因于固定业务平台线程数量造成的排队瓶颈。11. Pinning 检查测试使用以下参数输出 Virtual Thread Pinning 信息-Djdk.tracePinnedThreadsfull同时通过 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. 为什么仍然需要限流固定线程池在一定程度上会形成隐式背压线程池满 - 请求进入队列 - 超过队列容量后拒绝Virtual Thread Per Task Executor 不使用传统任务队列因此可以快速创建大量并发任务。如果缺少显式限流流量可能直接打到 LLM Provider。生产环境仍应根据真实容量设置SemaphoreBulkheadRate Limiter请求超时重试上限熔断策略HTTP Connection Pool 上限。三类机制的职责不同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 PoolSocket 和文件描述符JVM 内存Web ServerRedis、数据库、对象存储等业务依赖调用费用和预算上限。本次 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 sVirtual Thread 没有改变 LLM 本身的响应速度。本次调用链没有观察到明显的 Virtual Thread Pinning。Virtual Thread 不能替代限流、隔离、超时和熔断。本次测试不能证明真实 LLM Provider 在 500 并发下也能达到 325 QPS整个项目的所有调用链都不存在 PinningRedis、数据库和对象存储不会成为新瓶颈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 的适用边界。
