本文摘要:一个常见的场景:在基于Spring Boot 3.2的订单服务中,集成了一个调用外部LLM API的@Async Agent服务,用于实时生成商品描述。
一、问题与结论
一个常见的场景:在基于Spring Boot 3.2的订单服务中,集成了一个调用外部LLM API的@Async Agent服务,用于实时生成商品描述。单体测试正常,但上线后生产环境的端到端延迟骤增,P99从200ms飙升至600ms以上,且伴随服务内存持续增长,数小时后触发OOM重启。
结论 :问题根源在于:1) Spring Boot 3.2中,@Async方法与底层虚拟线程/平台线程池的混用不当,造成阻塞传染和线程池耗尽。2) AI模型流式响应的处理未正确管理Servlet响应缓冲区和客户端连接,导致内存泄漏。
二、排查与选择依据
1. Spring Boot异步模型冲突与内存泄漏
冲突的核心在于spring-boot-starter-web(基于Servlet同步模型)与@Async任务对线程资源的争夺,以及未处理流式响应的内存边界。
排查工具 :jcmd <pid> Thread.print查看线程堆栈;jcmd <pid> GC.heap_dump <file>分析堆内存。
典型故障代码与修复 :
未优化代码可能包含这样的流式处理端点:
java
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
SseEmitter emitter = new SseEmitter(60000L);
// 模拟异步推送AI流式结果
taskExecutor.execute(() -> {
try {
for (String token : aiService.streamGenerate()) {
emitter.send(SseEmitter.event().data(token));
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
这段代码在客户端连接慢或异常断开时,emitter.send可能阻塞,导致执行此任务的线程被长期占用,且内存中的事件无法释放。
修复方向:为线程池任务设置超时,并正确配置SseEmitter的回调与缓冲区。
java
// 修复示例:增加生命周期回调与缓冲区设置
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
SseEmitter emitter = new SseEmitter(60000L);
emitter.onTimeout(() -> emitter.complete()); // 超时后完成,释放资源
emitter.onError(e -> emitter.complete()); // 错误后完成
taskExecutor.execute(() -> {
try {
for (String token : aiService.streamGenerate()) {
emitter.send(SseEmitter.event().data(token));
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
替代方案与取舍
| 方案 | 选择条件 | 代价与边界 |
|---|---|---|
| 本文方案:混合同步+受控异步 | 主服务为同步Spring MVC,仅将少量Agent调用异步化。技术栈迁移成本低。 | 需精细管理线程池、超时和缓冲区,复杂度高。不适用于全链路高并发流式场景。 |
| 全面反应式(WebFlux) | 全新项目或能承受全栈重写,需处理大量并发长连接流。 | 学习曲线陡峭,与大量现有阻塞式库(如JDBC)不兼容,改造风险高。 |
| 虚拟线程直阻塞(Java 21) | 代码以阻塞式I/O为主,且能升级到Java 21。 | 可大幅简化代码,但要求所有依赖库是虚拟线程友好的。无法解决流式响应的背压问题。 |
三、关键原理
- 虚拟线程与@Async的交互 :当
spring.threads.virtual.enabled=true时,Spring Boot会尝试用虚拟线程执行@Async任务。但如果该任务内部调用了不兼容虚拟线程的、基于平台线程池的阻塞库(如旧版HTTP客户端),虚拟线程会"固定"在平台上,失去轻量优势,并可能耗尽平台线程。 - 内存泄漏根源 :Servlet容器的
SseEmitter或AsyncContext在异步处理中,如果开发者忘记设置响应缓冲区大小,或未正确处理客户端断开事件(emitter.onTimeout/emitter.onError),底层关联的响应流和数据队列将无法被垃圾回收。

四、可运行示例
环境 :JDK 21, Spring Boot 3.2.5, Maven。
示例目标 :复现因@Async任务阻塞导致的请求排队。
AgentService.java
java
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Service
public class AgentService {
// 模拟一个调用外部AI服务或进行计算的"Agent任务"
@Async
public CompletableFuture<String> invokeAgentTask(String input) {
// 陷阱:此处如果调用了同步阻塞的I/O库(如旧的HTTP客户端),
// 即使方法是@Async,也会占用虚拟线程或平台线程池的线程。
// 模拟一个可能阻塞的同步网络调用
try {
TimeUnit.SECONDS.sleep(2); // 假设这是同步HTTP调用
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return CompletableFuture.completedFuture("Processed: " + input);
}
}
ApiController.java
java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
@RestController
public class ApiController {
private final AgentService agentService;
public ApiController(AgentService agentService) {
this.agentService = agentService;
}
// 同步的REST端点,调用异步服务
@GetMapping("/process")
public String process(@RequestParam String query) throws Exception {
// 等待异步任务完成
CompletableFuture<String> resultFuture = agentService.invokeAgentTask(query);
String result = resultFuture.get(); // 阻塞等待,可能引发性能问题
return result;
}
}
application.properties
properties
# 启用虚拟线程 (需要 Java 21)
spring.threads.virtual.enabled=true
# 自定义异步执行器(可选,用于对比)
spring.task.execution.pool.core-size=4
spring.task.execution.pool.max-size=8
运行与观察:
- 启动应用,使用JMeter或
wrk发送100个并发请求/process。 - 预期输出:所有请求几乎同时到达,但响应时间应呈阶梯状,后续请求等待时间累加。
- 实际输出:在未限制异步线程池大小时,耗时远超2秒,表明请求被串行化在有限的线程上。
- 常见失败 :若未在
application.properties中限制线程池spring.task.execution.pool.max-size,虚拟线程的创建可能不受控,掩盖了资源竞争问题。修复方法是为关键的异步执行器配置有界队列和明确的拒绝策略。
五、失败场景与验证
失败场景:@Async异常丢失导致线程池耗尽
场景描述 :AI Agent调用链中的一个异步步骤因第三方服务故障而抛出异常,但由于异常处理不当,线程资源未被释放,最终拖垮整个系统。
失败代码片段:
java
@Async
public CompletableFuture<String> riskyAgentCall(String input) {
// 假设此方法调用了一个不稳定的外部API
if (Math.random() > 0.5) {
throw new RuntimeException("External API failed"); // 抛出未检查异常
}
return CompletableFuture.completedFuture("OK");
}
调用方(错误方式):
java
@GetMapping("/risk")
public String riskEndpoint() throws Exception {
CompletableFuture<String> future = riskyAgentCall("test");
// 如果riskyAgentCall内部抛出异常, future.get() 会抛出 ExecutionException
try {
return future.get(5, TimeUnit.SECONDS); // 设置超时
} catch (Exception e) {
// 此处捕获了异常,但线程池中的任务状态可能已损坏
return "Error: " + e.getMessage();
}
}
失败机制与排查:
- 症状:应用运行一段时间后,响应变慢,最终大量请求超时。
- 排查 :通过线程堆栈(
jstack)或监控发现,spring-task-execution线程池中的线程数量达到最大值,且大部分线程处于WAITING或TIMED_WAITING状态。 - 根本原因 :
@Async方法抛出未捕获的RuntimeException时,Spring的默认异步异常处理器会将其记录为日志,但该异步任务对应的CompletableFuture可能处于一个异常的完成状态。如果后续的Future.get()超时或未被妥善处理,并且线程池没有适当的清理或驱逐机制,资源(线程、连接)可能会泄漏。 - 修复方向 :在
@Async方法内部做好完整的异常捕获和资源清理;配置全局的AsyncUncaughtExceptionHandler;优先考虑使用CompletableFuture链式调用(.exceptionally(),.handle())来显式处理异常。
验证结果
- 内存泄漏 :修复流式处理并增加
emitter.onTimeout(() -> {})回调后,通过jmap -dump对比,堆中SseEmitter相关实例数量在压测后稳定,不再持续增长。 - 异常处理 :实现全局
AsyncUncaughtExceptionHandler后,线程池资源在异常发生后能被及时回收,应用稳定性提升。
参考资料
Java 21 Virtual Threads Specification (JEP 444)