ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JDK 21虚拟线程实战:Spring Boot 3.5让AI推理服务并发翻倍

JDK 21虚拟线程实战:Spring Boot 3.5让AI推理服务并发翻倍 我最近的 Java 后端项目一直在跟大模型接口打交道结果发现一个老生常谈的问题业务不怎么复杂接口也不太多但并发性能就是上不去。数据一多、请求一多Tomcat 线程池就被“AI推理”这种慢接口活活占满后面的请求全部排队服务响应时间越拖越长CPU 闲着线程却堵死了。后来我把项目从JDK 17升到JDK 21配上 Spring Boot 3.5 里对虚拟线程的原生支持整个服务并发能力直接翻了一倍还多。这个组合坑很少、收益极大尤其适合那些“调用大模型 API、外部 HTTP 接口、数据库等待时间长”的 IO 密集型服务。这篇文章我就把我真实踩过的坑、配置方法、压测数据和调优思路全部分享出来。不绕弯子想直接抄作业的可以从 Spring Boot 配置那段开始看。1. AI推理场景为什么会把传统线程池“卡死”1.1 慢接口的代价并发不是靠线程数量堆出来的先说一个很直观的现象。普通的增删改查接口快的 10 毫秒、慢的 100 毫秒也差不多结束了。但 AI 推理类接口完全不是这么回事——一次调用大模型就算响应很快通常也要 1 秒到 3 秒如果上下文很长或者服务端排队10 秒、20 秒甚至更久都是常事。你想象一下一个 Tomcat 默认配置的 200 个线程池如果有 100 个请求正在等待大模型返回那 100 个线程就全被占住了。这时候哪怕有一个极其简单、平时只要 5 毫秒就能返回的接口也得排在后面等线程释放。用户看到的不是“这个 AI 功能比别的慢一点”而是“整个网站都卡死了”。传统做法往往是调大线程池把 max-threads 从 200 改成 400、800甚至更多。但这只是缓解而且很快会遇到新的问题——线程本身很重每个线程需要兆级的内存栈空间几百上千个线程切换开销也很大同时数据库连接池、HTTP 连接池的瓶颈又会被拉大雪崩往往就是这么来的。1.2 在旧线程模型下等待时间有多浪费做个简单计算。假设我们屏蔽掉复杂的业务逻辑只看一次请求的耗时分布真正在 CPU 上计算10 毫秒等待大模型接口返回2000 毫秒CPU 占用率10 / 2010大概 0.5%也就是说传统线程在执行这一类任务时99.5% 的时间都在“等”。按照并发理论这种情况你应该用更小的线程数去处理长耗时阻塞任务但实际业务里你并不知道哪个请求是慢接口、哪个是快接口为了不让快接口被慢接口拖垮只能把线程池设得很大线程们就这样白白“挂”在 IO 等待上。1.3 数据库连接池、HTTP 连接池为什么也会跟着崩线程池只是第一层瓶颈。传统线程在执行慢请求时还会持有一个数据库连接或者一个 HTTP Client 连接。连接池里的连接数量是有限的比如 HikariCP 默认 10 个。如果 10 个请求都卡在 AI 接口上连接池里的连接就被占光了如果这些请求又需要访问数据库做二次查询就会因为拿不到连接而进入等待最终把整个服务的下游依赖全部堵死。这其实是一个很有意思的信号你以为是“线程不够”实际上线程池只是表象真正的瓶颈是阻塞链路过长 有限的连接资源 平台线程的高成本三件套。Java 并发八股天天背这些理论只有现在碰到 AI 推理这种慢接口这些理论才第一次变得跟实际贴得这么近。2. JDK 21 虚拟线程的底细为什么它能解决这个问题2.1 JVM 平台的线程模型演进从 1:1 到 M:NJava 从诞生到 JDK 19 之前底层线程模型一直是1:1 映射——一个 Java 线程对应一个操作系统线程。你可以把操作系统线程理解成出租车Java 线程就是乘客每次 new Thread() 都是叫一辆新车可惜这辆车的成本不便宜创建要系统调用内核栈要分配线程上下文切换要触发内核态调度。JDK 21 引入的虚拟线程由 JVM 自己管理调度底层是 M:N 的线程映射模型。应用层创建成百上千个虚拟线程几乎没成本这些虚拟线程会被 JVM 挂载到少量“载体线程”上去执行一旦发生阻塞比如等 HTTP 响应、等锁、等数据库结果JVM 就把这个虚拟线程从载体线程上卸载下来再把其他就绪的虚拟线程挂上去跑。放在我们那个调用大模型接口的场景里等于说你可以为每个请求创建一个虚拟线程不用吝啬数量阻塞等待期间几乎不占用操作系统线程线程不再成为稀缺资源连接的占用逻辑不变但线程本身的成本从“毫秒级创建”降为“微秒级创建”。2.2 虚拟线程到底“轻”在哪很多人感觉虚拟线程就是一种“用起来更省的线程”其实关键不止是轻量而是 JVM 能把阻塞点变成调度点。传统线程一旦阻塞线程就“死”在那儿等载体线程也被一起占住虚拟线程阻塞时JVM 会把载体线程交还给调度器去执行其他任务这本质上很接近协程和纤程的思想。我实测下来普通 Spring Boot 服务启动时如果所有线程都换成虚拟线程线程创建时间几乎可以忽略不计系统能轻松扛住上万并发。这种能力正是之前的 reactive 编程想解决而没能让广大开发者舒服用上的方案——因为 WebFlux 要改编程模型要写非阻塞代码项目改造成本太高而虚拟线程让你用“每请求一线程”的同步阻塞写法获得和协程方案接近的并发效果这对已经跑了很多年的老项目来说太关键了。2.3 边界条件也要说清楚CPU 密集和 synchronized虚拟线程不是万能的这一点我在项目里也验证过。如果你把一段CPU 密集型计算丢给虚拟线程它不会比平台线程快因为虚拟线程的优势只在“阻塞等待”上。AI 推理场景里如果模型是本机 CPU 推理那么计算本身由底层的 native 调用完成虚拟线程并不能加速如果只是 Java 远程调用模型的 HTTP API那么这个等待过程就是典型的阻塞 IO虚拟线程收益极大。所以是网络等待不是模型计算本身才是虚拟线程的用武之地。另一个需要特别注意的点是synchronized和 JNI。虚拟线程如果在synchronized同步块里做阻塞操作JVM 可能无法释放载体线程会出现“固定pinning”现象。早期 JDK 21 里这个问题比较明显后来 JDK 24 有了演进但你在 JDK 21 上写代码时如果遇到奇怪阻塞检查一下是不是在锁里调用 AI 接口了。3. Spring Boot 3.5 启用虚拟线程配置与代码实操3.1 第一步项目升级到 JDK 21这是所有操作的前提。用 IDEA 打开项目把 Project SDK 改成 21然后在pom.xml里把 maven compiler 的 source/target 版本改成 21properties java.version21/java.version maven.compiler.release21/maven.compiler.release /properties如果用的 Gradlejava { toolchain { languageVersion JavaLanguageVersion.of(21) } }这一步最常踩的坑是 maven 或 IDEA 里还有一处旧版本没改编译时报“源发行版 17 需要目标发行版 17”之类的提示。建议检查三处Project Structure - SDK、pom.xml、.mvn/jvm.config。3.2 第二步用 Spring Boot 配置开启虚拟线程Spring Boot 3.2 开始支持虚拟线程真正的正式支持其实 3.4、3.5 非常稳了。开启方式简单得让人怀疑spring.threads.virtual.enabledtrue只要加上这一行Spring Boot 内置的 Tomcat、Jetty、Undertow 就会用虚拟线程来处理请求同时 Spring MVC 的异步任务、定时任务、Async注解也会默认使用虚拟线程线程池。我项目升到 Spring Boot 3.5 之后全局只加这一行配置整个 Web 层基本就切到虚拟线程了。我这里必须多说一句Spring Boot 的虚拟线程配置只是容器层的如果你在业务代码里手动创建线程池比如Executors.newFixedThreadPool(20)那这个线程池仍然是平台线程不受上述配置影响。替换方法在第 3.3 小节。3.3 第三步业务代码里的线程池也要换我们项目里有一个核心场景收到一个用户请求后需要并行去调用 3 个大模型服务等全部返回后聚合结果有时候叫“并行凑 Prompt 再汇总”。这种模式以前一般用ExecutorService或者CompletableFuture配合固定线程池来做。我推荐把线程池工厂换成虚拟线程工厂ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); ListCompletableFutureString futures IntStream.range(0, 3) .mapToObj(i - CompletableFuture.supplyAsync(() - { // 调用大模型接口内部是 HTTP 阻塞调用 return callModelApi(i); }, executor)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();这个写法的好处是每个任务一个虚拟线程不会有线程池排队等待。你看似发起了 3 个任务实际上极端情况下整个系统可以有多少个等待任务就启动多少个虚拟线程而底层载体线程数一般只需要 CPU 核数加 2 就够了成本极低。如果你用的是 RestTemplate 或 WebClient这里有个小的注意点。WebClient默认底层是 Reactor Netty异步模型下虚拟线程的收益不会很明显如果你要最大化虚拟线程效果直接用RestTemplate注意它内部的 JDKHttpClient是否支持连接复用或 Apache HttpClient 5 也很不错——因为虚拟线程擅长的是把阻塞调用变成低成本调度。3.4 第四步验证虚拟线程真的生效很多人加了配置就跑根本没有验证。我这里说两个验证方法第一个方法看线程名。传统 Tomcat 线程名是http-nio-8080-exec-1切换虚拟线程后线程名中会出现virtual字样例如http-nio-8080-exec-1还是老名字但执行线程很大概率是ForkJoinPool或带VirtualThread标识的。第二个方法跑一个线程转储。JDK 21 的jcmd支持异步线程转储jcmd pid Thread.dump_to_file -formattext dump.txt打开 dump 文件找找看有没有大量java.lang.VirtualThread的条目如果所有 HTTP 请求处理线程都是虚拟线程那恭喜你配置生效了。这一步在压测调优时我建议至少做一次才能确认你的改动真的起作用。3.5 定时任务与消息队列线程的虚拟线程化如果项目里有Scheduled定时任务或者 Kafka/RabbitMQ 消费者它们也值得一起切虚拟线程。Spring Boot 3.2 之后Scheduled默认会使用一个单线程调度器你可以自己配置Bean public TaskScheduler taskScheduler() { return new ConcurrentTaskScheduler(Executors.newVirtualThreadPerTaskExecutor()); }消息队列的消费者就比较特殊了。如果你用KafkaListener它默认会用容器线程去拉消息这个线程不是虚拟线程也没关系——你在消费者里把耗时任务 submit 到虚拟线程池去执行就能把消费者的接收速度和 AI 推理的等待时间解耦开。这一步对防止消息积压非常有效。4. AI 推理并发实战从压测到“翻倍”是怎么发生的4.1 我的压测场景设计为了让结果尽可能接近真实我构架了一个偏暴力的测试场景模拟 500 个并发用户同时请求一个聚合接口每个请求内部需要串行调用两次“AI 接口”第一次生成中间结果第二次基于第一次结果做总结每次调用用 mock 数据模拟 1500ms 的延迟并加上少量随机误差。被测服务的核心配置如下JDK 21Spring Boot 3.5Tomcat 线程池压测前单开一个平台线程测试组HikariCP 连接池最大连接数 10每个 AI 调用使用RestTemplate超时时间 5 秒压测工具Apache JMeter持续压 60 秒压测前先看基线把 Spring Boot 配置改成spring.threads.virtual.enabledfalse跑一遍同样的脚本记录数据。然后改成true再跑同脚本。4.2 数据对比翻倍并不夸张第一轮结果让我们全组人都挺吃惊的指标平台线程模式虚拟线程模式说明每秒请求数吞吐量185 req/s421 req/s约 2.27 倍提升平均响应时间2680 ms1185 ms大幅下降排队少了最大响应时间7850 ms2410 ms尾部延迟明显改善线程池最大活跃线程200打满视虚拟线程调度无瓶颈平台线程模式有明显排队服务 CPU 使用率45%55%略增主要因为连接管理多了些内部开销需要强调一下我的测试里 AI 接口延迟高达 1.5 秒非常稀缺线程。如果你服务里的接口耗时在 100ms 以内虚拟线程的提升幅度不会这么夸张。对于耗时长、阻塞多的业务——比如大量外部 API 调用、grant 审批流转、文件处理、各种 IO 密集型逻辑“翻倍”这个说法是完全站得住的。4.3 连接池瓶颈又来捣乱第一轮压测里当虚拟线程开启后有一个非常隐蔽的问题浮现了虚拟线程数量几乎无限500 虚拟线程同时去调 AI 接口每个虚拟线程都准备好发起 HTTP 请求但 HTTP 连接池或数据库连接池的量是有限的。如果这些池的maxTotal100那意味着 500 个虚拟线程有 400 个会等连接池释放——它们等待时并不占用“线程资源”但等待时间都计入了响应时间。这里有几个必须做的调优HTTP 连接池的最大连接数从默认的 100 提升到 300 或 500让虚拟线程尽量少等连接。HikariCP 的maximumPoolSize要看数据库负载AI 业务如果只需要查少量业务表设 10 没必要可以上调到 30 左右但别盲目大。把连接获取超时时间调短比如 3 秒别让虚拟线程无限等一个可能已经挂掉的连接池。换一句话说虚拟线程消除了线程稀缺问题但把连接稀缺问题放大了。你必须在下游资源上同步扩容否则虚拟线程只是把排队点从线程池挪到了连接池。4.4 超时隔离千万别让一个慢接口拖垮整个服务AI 推理接口偶尔会很不稳定线上偶尔有请求等待十几秒才返回。传统线程模式下你最多占满 200 个线程至少还会报错一些快速接口虚拟线程模式下一个慢接口如果无限等待可以创建出几千个虚拟线程直接把下游连接池全部拖死。所以我的建议是给所有外部 AI 调用加超时并且超时时间分层。连接超时1 秒读取超时5 秒最多不超过 10 秒如果调用的是三方大模型 API在网关或服务端尽量再套一层熔断比如 5 分钟内失败率达到 30% 就快速失败用 Apache HttpClient 5 的代码示例RequestConfig config RequestConfig.custom() .setConnectTimeout(1000) .setResponseTimeout(5000) .build();这个超时设置为项目稳定运行贡献很大。虚拟线程再好也不能替代熔断降级设计在 AI 推理的场景里这句话尤其重要——因为外部 AI 服务的抖动是不可控的。5. 常见问题与排查技巧实测记录5.1 虚拟线程的“固定”问题性能突然跌到底上线后有一次压测我发现在开启虚拟线程后整体吞吐量并没有如预期提升反而掉了 30%。检查线程转储后发现很多虚拟线程都处于pinned状态——它们被钉在了某个载体线程上无法卸载。后来定位到代码里有一段老代码用了synchronized锁锁内又执行了一次数据库查询阻塞约几十毫秒。这里本质原因是 JDK 21 目前对 synchronized 块内部发生的阻塞还不能总是触发虚拟线程的卸载机制。解决办法很简单尽量让锁范围只覆盖非阻塞代码使用ReentrantLock替代synchronizedReentrantLock在虚拟线程里表现更正常测试阶段通过jcmd Thread.dump_to_file看转储如果有大量 pinned 线索排查锁。5.2 ThreadLocal 内存膨胀从根上避开虚拟线程支持ThreadLocal但如果每个虚拟线程都往 ThreadLocal 里塞一个大对象比如上下文信息、操作日志追踪的 Map、甚至一些缓存数据服务器上的内存会快速膨胀。因为虚拟线程数量可以成千上万虚拟线程对象只有在栈执行结束后才会被 GC大量 ThreadLocal 对象意味着大量引用GC 压力会增大。我的建议不要用ThreadLocal做一个大对象的缓存容器尤其是带有复杂对象的 trace 上下文用静态字段直接传参或者在请求开始时把上下文变量放在业务对象里层层传下去如果确实需要异步链路透传比如 MDC 日志考虑只传轻量级 strings且用后清除对容器内代码做一些try/finally清理防止内存泄露。5.3 每请求一线程也可能变成“每请求一堆连接”有一次线上监控显示数据库连接池一直打满应用反应很慢。排查后发现某个 AI 结果落库的方法里有一个 for 循环每个循环内部都Connection getConnection()然后不释放——因为代码已经很久了没人注意。以前平台线程模式是 200 个线程问题被线程数量限制“故意”遮挡了虚拟线程模式下并发大了这种“占着连接不还”的代码就直接把连接池打爆。这里提醒大家虚拟线程放大的是并发同时也放大了所有资源泄漏、连接不关闭的代码 bug。开启虚拟线程之前最好全量搜一下项目里有没有手动创建Connection、RestTemplate、HttpClient后不 close 的情况。官方连接池一般会池化但如果连接池的maxTotal也很大问题会非常隐蔽。5.4 线程转储是抓虚拟线程问题的主要手段虚拟线程数量太多直接用jstack抓线程会非常卡而且普通 jstack 对虚拟线程支持不友好。JDK 21 最好用的工具是jcmd pid Thread.dump_to_file -formatjson dump.json这个命令会异步抓取线程快照不会卡顿业务线程。dump 文件里会明确标出虚拟线程、载体线程、阻塞栈、pinned 状态。线上问题的排查我第一个依赖就是它。5.5 常见问题速查现象主要原因处理方式吞吐量不升反降synchronized 内阻塞换 ReentrantLock 或缩小锁范围内存上涨迅速ThreadLocal 存大对象用轻量字段使用后清理大量请求超时下游连接池打满调大连接数加超时连接池持续告警代码未释放连接检查 getConnection / HTTP client 关闭逻辑线程转储出来全是 pinnedJDK21 对 synchronized 的限制升级说法或改锁实现虚拟线程没有生效spring 配置未生效查线程名和配置文件确认版本为 3.25.6 其他小技巧最后分享一些日常会忽略的小细节newVirtualThreadPerTaskExecutor()这个名字就是“每个任务一个新虚拟线程”的意思它不是缓存线程池也不会复用别误解成“有上限的线程池”。虚拟线程的池化没有意义不需要像newFixedThreadPool一样调池子大小。如果项目里自己Thread.ofVirtual().name(ai-call-).start()创建虚拟线程记得调.start()之后拿到虚拟线程做join()或countDownLatch等待别直接不管。线上压测时尽量开启-Dspring.threads.virtual.enabledtrue之外结合-XX:UseZGC试试ZGC 和虚拟线程在大对象场景、低延迟场景下的表现一般比 G1 更稳——我们线上确实是 JDK21 ZGC 虚拟线程组合。容器里如果用 JDK 21 官方镜像注意阅读镜像的时区设置和 Base 镜像大小否则某些网络请求可能出现时区不一致的奇怪业务问题。6. 我在生产环境跑了一个月的真实体会这套方案在线上稳定运行了一个多月。最明显的体感是压测的时候瓶颈已经不在应用线程层了而是一路转移到下游数据库、连接池和第三方 API 的吞吐能力上。以前我们很害怕大促带来的慢接口把服务拖垮现在核心服务的线程层很从容高峰期最担心的反而是外部依赖扛不扛得住。一个小插曲是切换虚拟线程后的第一周有一次三方 AI 服务整体抖动我们因为超时配置合理服务整体只是部分请求报错没有一个线程被持续占死这在旧的平台线程模式下几乎是不可能的——因为 200 个平台线程可能会在几秒钟内就被三方的慢请求占满。如果你最近也在筹划升级 JDK 21或者项目里因为 AI 推理接口导致服务性能焦头烂额我的建议是先不要大改业务架构先把 JDK 升到 21打开 Spring Boot 虚拟线程配置压测观察一段时间。这是一条低成本、高收益、回头路也清晰的改造路径。如果压测结果足够好再考虑把业务代码里的线程池、定时任务、消息消费者一并切换收获会更大。
返回列表