Java 虚拟线程在 AI 应用中的实践——并发性能提升有多大
Java 虚拟线程在 AI 应用中的实践——并发性能提升有多大
半年前,我们 AI 应用的并发能力开始撑不住了。
峰值时期大约 300 个并发 AI 请求,每个请求平均等待模型响应 2~3 秒,传统线程池里线程全部阻塞在网络 I/O 上,新请求开始排队,响应时间飙到了 8 秒以上。
当时的配置是:
tomcat 最大线程:400
AI 调用线程池:200 核心线程,300 最大线程
平均每个 AI 请求阻塞时间:2.5 秒上调线程数?每个线程默认 512KB 栈内存,300 个线程就是 150MB,这还只是栈内存,还有线程调度的 CPU 开销。线程不是越多越好,超过 CPU 核心数之后,大量时间花在上下文切换上。
当时有同事建议直接上 WebFlux + 响应式编程,我想了想,拒绝了。响应式改造的代价太大,整个业务逻辑要从头重写,而且 Spring AI 对响应式的支持虽然有,但生态没有命令式那么完整。
然后我想到了 Java 21 的虚拟线程。
虚拟线程是什么,凭什么能解决这个问题
虚拟线程(Virtual Threads)是 Java 21 正式引入的特性,核心思想是:把线程和 OS 线程解耦。
传统线程(平台线程)和 OS 线程是 1:1 的关系,一个 Java 线程对应一个 OS 线程,在 I/O 等待时这个 OS 线程被阻塞,什么也干不了。
虚拟线程和 OS 线程(叫做载体线程,carrier thread)是多对一的关系。大量虚拟线程被调度到少量载体线程上运行。当虚拟线程遇到阻塞操作(比如等待网络响应),它会从载体线程上「卸载」(unmount),让出载体线程去跑其他虚拟线程,自己等 I/O 完成后再被「挂载」(mount)回某个载体线程继续执行。
从代码角度看,写法还是同步阻塞的,但底层调度是高效的。
这对 AI 应用来说是天然的适配场景,因为 AI 请求就是典型的高并发、高 I/O 等待、低 CPU 计算的场景。
实测数据
先上测试环境的数据,不然都是嘴上说说。
测试环境:
- 8 核 16G 内存
- Spring Boot 3.2.x,Java 21
- 模拟 AI 请求:用 MockServer 模拟 ChatModel,人为加入 2 秒延迟
场景一:低并发(50 并发)
| 方式 | 平均响应时间 | P99 响应时间 | 吞吐量(req/s) |
|---|---|---|---|
| 传统线程池(100线程) | 2.1s | 2.3s | 23.8 |
| 虚拟线程 | 2.0s | 2.2s | 24.5 |
低并发下差异不大,因为线程池够用。
场景二:高并发(500 并发)
| 方式 | 平均响应时间 | P99 响应时间 | 吞吐量(req/s) |
|---|---|---|---|
| 传统线程池(100线程) | 10.2s | 15.8s | 9.8 |
| 虚拟线程 | 2.2s | 2.8s | 227 |
差异巨大。传统线程池里 500 个并发请求在排队,响应时间飙升;虚拟线程近乎线性扩展,始终保持在 2~3 秒的水平。
场景三:极限并发(2000 并发)
| 方式 | 平均响应时间 | P99 响应时间 | 吞吐量(req/s) |
|---|---|---|---|
| 传统线程池(100线程) | 40s+ 大量超时 | - | < 3 |
| 虚拟线程 | 2.4s | 3.5s | 833 |
传统方式基本崩了,虚拟线程依然平稳。
内存消耗对比(1000 个并发连接):
| 方式 | JVM 内存占用 |
|---|---|
| 传统线程池(1000线程) | +512MB(仅线程栈) |
| 虚拟线程 | +约 10MB |
虚拟线程的内存开销极低,1000 个虚拟线程的内存约等于 1~2 个传统线程。
Spring Boot 3 开启虚拟线程
配置极其简单,这是我喜欢虚拟线程的原因之一:零代码改动,只需要配置。
方式一:application.yml 配置(Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true这一行配置让 Spring MVC 的 Tomcat 使用虚拟线程处理请求,同时 Spring 的 @Async 也会用虚拟线程执行器。
方式二:手动配置(更精细的控制)
@Configuration
public class VirtualThreadConfig {
/**
* 配置 Tomcat 使用虚拟线程
*/
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
};
}
/**
* 配置 @Async 任务使用虚拟线程
*/
@Bean(name = "virtualThreadTaskExecutor")
public AsyncTaskExecutor virtualThreadTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
/**
* 覆盖 Spring Boot 默认的 @Async 执行器
*/
@Bean
@Primary
public AsyncTaskExecutor applicationTaskExecutor() {
return virtualThreadTaskExecutor();
}
}AI 调用场景的具体实践
AI 应用里几个典型的高并发场景:
场景一:并发批量摘要生成
用虚拟线程并发处理,不再需要手动管理线程池大小:
@Service
public class BatchSummaryService {
private final ChatClient chatClient;
/**
* 批量生成文章摘要
* 虚拟线程下,可以安全地用 parallelStream 或直接创建大量线程
*/
public List<SummaryResult> batchSummarize(List<Article> articles) {
// 虚拟线程环境下,可以直接用 stream().parallel()
// 不用担心线程池被打满
return articles.parallelStream()
.map(article -> {
try {
String summary = summarizeOne(article);
return new SummaryResult(article.getId(), summary, true);
} catch (Exception e) {
log.error("摘要生成失败: articleId={}", article.getId(), e);
return new SummaryResult(article.getId(), null, false);
}
})
.collect(Collectors.toList());
}
private String summarizeOne(Article article) {
return chatClient.prompt()
.system("你是一个专业的文章摘要助手,请用 100 字以内总结文章核心内容")
.user(article.getContent())
.call()
.content();
}
}场景二:并发 RAG 检索 + 生成
@Service
public class ParallelRAGService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
/**
* 并发从多个知识库检索,再合并生成答案
*/
public String queryMultiSource(String question, List<String> departments) {
// 并发检索多个部门的知识库
List<CompletableFuture<List<Document>>> futures = departments.stream()
.map(dept -> CompletableFuture.supplyAsync(() -> {
// 虚拟线程:这个 supplyAsync 会在虚拟线程上执行
FilterExpressionBuilder fb = new FilterExpressionBuilder();
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(3)
.filterExpression(fb.eq("department", dept).build())
.build()
);
}))
.collect(Collectors.toList());
// 等待所有检索完成
List<Document> allDocs = futures.stream()
.map(f -> {
try {
return f.get(10, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("检索超时,跳过: {}", e.getMessage());
return List.<Document>of();
}
})
.flatMap(List::stream)
.collect(Collectors.toList());
// 合并上下文,生成答案
String context = allDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
return chatClient.prompt()
.system("基于提供的资料回答问题")
.user("资料:\n" + context + "\n\n问题:" + question)
.call()
.content();
}
}场景三:带超时控制的 AI 调用
@Service
public class TimeoutAwareChatService {
private final ChatClient chatClient;
/**
* 带超时的 AI 调用
* 虚拟线程下,Thread.sleep 和 I/O 阻塞都不会占用载体线程,非常高效
*/
public Optional<String> chatWithTimeout(String message, int timeoutSeconds) {
try {
// 使用 CompletableFuture 配合超时
CompletableFuture<String> future = CompletableFuture.supplyAsync(() ->
chatClient.prompt()
.user(message)
.call()
.content()
);
return Optional.of(future.get(timeoutSeconds, TimeUnit.SECONDS));
} catch (TimeoutException e) {
log.warn("AI 调用超时 ({}s): {}", timeoutSeconds, message.substring(0, Math.min(50, message.length())));
return Optional.empty();
} catch (Exception e) {
log.error("AI 调用异常", e);
return Optional.empty();
}
}
}虚拟线程不适合的场景
说这些之前,我要先承认:虚拟线程不是银弹,有几个场景是不适合用的,我早期踩过坑。
CPU 密集型任务
这是最重要的一点。
虚拟线程的优势在于释放阻塞等待时的 CPU 资源。但如果你的任务是纯 CPU 计算(比如图像处理、矩阵运算、复杂的 JSON 序列化),虚拟线程没有任何帮助。
更糟糕的是,如果大量虚拟线程在跑 CPU 密集计算,它们会争抢少量的载体线程(默认是 CPU 核心数),反而比传统线程池的调度效率更低。
判断规则:任务里有 I/O 等待(网络、磁盘、数据库)→ 虚拟线程有收益;纯计算 → 用传统线程池。
synchronized 同步块的钉扎问题(Pinning)
这是个容易被忽视的陷阱。
当虚拟线程执行到 synchronized 同步块时,如果遇到阻塞,它不会从载体线程上卸载,而是会钉在载体线程上(pinned)。这种情况下,虚拟线程退化为传统线程,不再享有 I/O 效率优势。
// 危险:synchronized 块里有 I/O 操作
public synchronized String dangerousMethod() {
// 虚拟线程执行到这里,遇到阻塞会被钉扎
return chatClient.prompt().user("...").call().content(); // 2~3 秒的 I/O 等待
}解法是用 ReentrantLock 替代 synchronized:
private final ReentrantLock lock = new ReentrantLock();
public String safeMethod() {
lock.lock();
try {
// 虚拟线程遇到阻塞可以正常卸载
return chatClient.prompt().user("...").call().content();
} finally {
lock.unlock();
}
}检查是否有钉扎问题,可以用 JVM 参数:
-Djdk.tracePinnedThreads=full数据库连接池的配合
虚拟线程虽然可以无限创建,但数据库连接池是有限的。如果你的 AI 服务里同时有大量请求访问数据库,连接池会成为瓶颈。
# HikariCP 配置,虚拟线程环境下不建议设置太大
spring:
datasource:
hikari:
maximum-pool-size: 20 # 不需要设太大,虚拟线程等连接是高效的
connection-timeout: 3000 # 3 秒获取不到连接就超时原因:虚拟线程等待数据库连接时会正确卸载载体线程,所以不需要「多留线程来抢连接」,连接池大小根据数据库本身的并发能力设置就好。
生产环境的实际效果
我们把方案上线之后,几个关键指标:
峰值并发响应时间:从 8 秒降到了 2.5 秒
服务器 JVM 内存:反而降了 200MB(少了大量线程栈内存)
CPU 使用率:峰值从 75% 降到了 55%(减少了线程上下文切换开销)
需要的服务器数量:从 3 台缩减到 2 台(在满足 SLA 的前提下)
我们实际上什么业务代码都没改,就是加了一行配置 spring.threads.virtual.enabled=true,然后升级到了 Java 21。这个收益/投入比让我非常满意。
当然,我们的场景是典型的 I/O 密集型,如果你的 AI 应用里有大量同步计算或者用了很多 synchronized 块,效果会打折扣。
迁移建议
如果你在考虑要不要上虚拟线程:
- 先升到 Java 21:这是前提,Java 21 才是正式 GA 的版本
- 检查 synchronized 用法:在代码库里搜一遍,I/O 操作前后有
synchronized的地方要改成ReentrantLock - 检查数据库连接池配置:连接池大小不需要因为虚拟线程而调大
- 小流量灰度:先在测试环境跑压测,确认没有钉扎问题,再上生产
- 监控载体线程的钉扎:上线初期加
-Djdk.tracePinnedThreads=full观察一段时间
虚拟线程是目前我觉得最「物超所值」的 Java 21 特性,代码改动量几乎为零,但在 AI 这类 I/O 密集型应用里,性能提升是实实在在的。
