Spring AI + CompletableFuture 引发假死锁:两种机制深度排查
Spring AI + CompletableFuture 引发"假死锁":调试步步能过,运行直接卡死
一、问题现象
Spring Boot 3.5 + Spring AI 1.1.4 做了一个 AI 智能搜索功能。用户输入自然语言 → ChatClient 调 LLM → LLM 通过 Tool Calling 调用 ShopSearchTools 查数据库 → 返回结果。
为了方便异步处理,用 CompletableFuture.supplyAsync() 包裹了 ChatClient 调用。结果遇到了诡异问题:
直接运行 → 线程卡死。调试时一步步走 → AI 成功回复了内容。换成
@Async→ 问题消失。
当时的分析是:
线程0 创建 CompletableFuture 产生线程1 → 线程1 调用 chatClient.call()(阻塞)→ call() 内部产生线程2 执行 @Tool 方法 → 线程2 需要从线程1 拿 ThreadLocal 上下文 → 线程1 阻塞在 call() 上 → 死锁。@Async 有 Spring 管理 ThreadLocal 上下文传播,所以不死锁。
结论(@Async 解决问题)是正确的,但归因不够精确。 本文通过测试验证,拆解这个案例背后可能并存的两套机制。
二、技术背景
先看涉及的组件和调用链:
Tomcat 线程
→ AISearchServiceImpl.search()
→ CompletableFuture.supplyAsync() ← ForkJoinPool.commonPool()
→ ChatClient.call() ← 阻塞 HTTP 调 LLM
→ LLM 返回 Tool Calls
→ 执行 @Tool 方法 (ShopSearchTools) ← MyBatis-Plus 查 DB
→ 工具结果回传 LLM
→ LLM 生成最终回复
→ future.join() ← Tomcat 线程阻塞等待
核心代码(简化):
// 问题代码
public SearchResponse search(String query, Double x, Double y) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return searchChatClient.prompt() // ← 默认用 ForkJoinPool.commonPool()
.user(query)
.call() // ← 阻塞 HTTP 调用
.content();
});
return new SearchResponse(Collections.emptyList(), future.join()); // ← 阻塞等待
}
// LLM 会调用的 Tool 方法
@Component
public class ShopSearchTools {
@Tool(description = "根据条件搜索商户")
public List<ShopSummaryDTO> searchShops(
@ToolParam(description = "商户类型ID") Long typeId,
@ToolParam(description = "商户名称关键词") String keyword,
@ToolParam(description = "商圈") String area,
@ToolParam(description = "最低评分") Double minScore,
@ToolParam(description = "最高人均价格") Long maxPrice
) {
// MyBatis-Plus 查数据库
LambdaQueryWrapper<Shop> wrapper = new LambdaQueryWrapper<>();
if (typeId != null) wrapper.eq(Shop::getTypeId, typeId);
if (keyword != null) wrapper.like(Shop::getName, keyword);
// ...
return shopService.list(wrapper).stream()
.map(s -> ShopSummaryDTO.builder()...build())
.toList();
}
}
涉及的线程池:
| 线程池 | 归属 | 线程数 | 特点 |
|---|---|---|---|
| Tomcat 线程池 | Spring Boot 内嵌容器 | 200 (默认) | 处理 HTTP 请求 |
| ForkJoinPool.commonPool() | JVM 共享 | CPU核数-1 (本机23) | parallelStream、无参CompletableFuture共用 |
| boundedElastic (Reactor) | Spring AI/WebClient内部 | 核数×10 | 处理响应式流的阻塞操作 |
| Netty EventLoop | WebClient HTTP客户端 | 核数×2 | 非阻塞网络 I/O |
三、两种可能的卡死机制
通过测试和分析,这个场景下可能有两种不同的机制导致卡死,取决于具体条件。下面逐一分析。
机制A:ThreadLocal 上下文丢失 → @Tool 异常 → LLM 重试循环(逻辑卡死)
触发条件:@Tool 方法直接或间接访问了 RequestContextHolder、SecurityContextHolder 等 Spring ThreadLocal 上下文。
调用链:
主线程(Tomcat):RequestContextHolder 设置有值
│
└→ CompletableFuture.supplyAsync() → ForkJoinPool 线程
│ RequestContextHolder.get() = null ← 上下文已丢失!
│
└→ ChatClient.call()
├─ ① HTTP请求 → LLM 返回 Tool Calls
├─ ② 执行 @Tool 方法
│ → RequestContextHolder.currentRequestAttributes()
│ → IllegalStateException("No thread-bound request found")
├─ ③ Spring AI 捕获异常 → 包装为 Tool Response 发给 LLM
├─ ④ LLM 看到错误 → 认为临时故障 → 返回 Tool Calls 重试
├─ ⑤ 再次执行 @Tool → 还是 null → 还是异常
└─ ⑥ LLM 再次重试... 无限循环 → 表现为"卡死"
测试验证:
场景: 模拟 ThreadLocal 上下文丢失 → 重试循环
已重试 20 次,每次都因上下文丢失失败...
已重试 40 次,每次都因上下文丢失失败...
已重试 60 次,每次都因上下文丢失失败...
已重试 80 次,每次都因上下文丢失失败...
已重试 100 次,每次都因上下文丢失失败...
结果: FAILED_AFTER_MAX_RETRIES(100)
>>> 由于 ThreadLocal 永远为 null,每次 @Tool 调用都失败 <<<
>>> LLM 无限重试 → CPU 空转 → 表现为卡死 <<<
如果调试时 AI 能成功回复,说明实际没有进入这个机制。 但如果 @Tool 方法间接触发了 Spring 上下文的懒加载(比如某些 AOP 切面、事务代理),就可能在某些运行环境触发,在调试环境不触发(因为断点暂停改变了上下文初始化的时序)。
真实性:取决于你的 @Tool 链路是否依赖 ThreadLocal。在本次实际案例中,结合调试回显判断,不是主因。
机制B:ForkJoinPool.commonPool() 饱和饥饿(真实卡死)★ 主因
触发条件:ForkJoinPool.commonPool() 的有限线程全部被阻塞 I/O 占用,新任务无法执行。
原理:
CompletableFuture.supplyAsync() 无参版本使用 ForkJoinPool.commonPool(),这是一个整个 JVM 共享的线程池,并行度 = CPU 核心数 - 1。
核心问题:socket I/O 阻塞(HTTP 调 LLM)不会触发 ForkJoinPool 的 ManagedBlocker 补偿机制。ManagedBlocker 只在显式调用 ForkJoinPool.managedBlock() 时生效(比如 CompletableFuture.join() 内部)。普通 socket read 不在此列。
调用链:
Tomcat 线程池 (200线程) ForkJoinPool.commonPool() (23线程)
│ │
├─ 请求1: future.join() 阻塞等待 ├─ 线程1: chatClient.call() → socket read (5-30s)
├─ 请求2: future.join() 阻塞等待 ├─ 线程2: chatClient.call() → socket read
├─ 请求3: future.join() 阻塞等待 ├─ 线程3: chatClient.call() → socket read
├─ ... ├─ ...
├─ 请求20: future.join() 阻塞等待 ├─ 线程20: chatClient.call() → socket read
├─ 请求21: future.join() 阻塞等待 ├─ 线程21: parallelStream 操作
├─ 请求22: future.join() 阻塞等待 ├─ 线程22: 其他 CompletableFuture
├─ 请求23: future.join() 阻塞等待 ├─ 线程23: 框架内部使用
│ │
│ ← 请求24: CompletableFuture 提交 │ ← 所有线程饱和,无空闲!
│ ← 新任务进入工作窃取队列 │
│ ← future.join() 永远等不到完成 │ ← socket read 不会触发 ManagedBlocker
│ │ ← 池子不会自动扩容
▼ 卡死 ▼
为什么调试时能过?
调试器中只处理单个请求,逐步执行。ForkJoinPool 绝大部分时间处于空闲状态,任务能立即获取线程。HTTP 调用的等待在断点间隙自然完成,没有线程争抢。
为什么 @Async 能解决?
本质上是把任务从"整个 JVM 共享且不可扩的 ForkJoinPool"迁移到了"应用专属、可配置的独立线程池"。不是 ThreadLocal 传播的问题,是池隔离的问题。
测试验证1——ManagedBlocker 在极度饱和时兜不住:
ForkJoinPool 并行度: 23
已占满 23 个 ForkJoinPool 线程(全部进入阻塞等待)
>>> 补偿线程未及时创建/执行!ForkJoinPool 在极度饱和时兜不住 <<<
测试验证2——有界池死锁 vs 无界池正常:
=== newFixedThreadPool(1) 有界池 ===
>>> 死锁确认!有界池嵌套提交 = 死锁 <<<
=== newCachedThreadPool() 无界池(模拟 SimpleAsyncTaskExecutor)===
结果: done
>>> 无界池:不死锁,内部任务总能拿到新线程 <<<
测试验证3——ThreadLocal 上下文在 ForkJoinPool 线程确实丢失:
CompletableFuture 线程中 ThreadLocal 值: 上下文丢失!
>>> ForkJoinPool 线程看不到主线程的 ThreadLocal <<<
InheritableThreadLocal 值: 丢失
>>> InheritableThreadLocal 只在创建子线程时复制一次,池化后不再更新 <<<
四、两种机制的对比
| 维度 | 机制A:LLM重试循环 | 机制B:ForkJoinPool饥饿 |
|---|---|---|
| 卡死类型 | 逻辑卡死(CPU空转) | 线程饥饿(任务排队) |
| jstack 线程状态 | RUNNABLE(疯狂循环) | WAITING(parking) |
| 触发条件 | @Tool依赖ThreadLocal上下文 | ForkJoinPool全部线程阻塞I/O |
| 调试时会发生吗 | 会(@Tool还是拿不到上下文) | 不会(单请求无争抢) |
| 本次案例匹配度 | ❌ 低(调试时AI成功回复了) | ✅ 高(调试串行化,运行时并发竞争) |
| 修复方案 | @Async + TaskDecorator传播上下文 | @Async + 独立线程池(池隔离) |
五、修复方案:@Async + 独立线程池
无论真实根因是 A 还是 B,@Async + 正确配置的线程池都能兜住。修改如下:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean("aiSearchExecutor")
public Executor aiSearchExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
// 过载时回退到调用线程,避免 RejectedExecutionException
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
// 传播 Spring ThreadLocal 上下文 → 解决机制A
executor.setTaskDecorator(task -> {
RequestAttributes ctx = RequestContextHolder.getRequestAttributes();
SecurityContext secCtx = SecurityContextHolder.getContext();
return () -> {
try {
if (ctx != null) RequestContextHolder.setRequestAttributes(ctx);
SecurityContextHolder.setContext(secCtx);
task.run();
} finally {
RequestContextHolder.resetRequestAttributes();
SecurityContextHolder.clearContext();
}
};
});
executor.initialize();
return executor;
}
}
@Service
public class AISearchServiceImpl implements IAISearchService {
@Autowired
private ChatClient searchChatClient;
@Async("aiSearchExecutor")
public CompletableFuture<SearchResponse> searchAsync(String query, Double x, Double y) {
String content = searchChatClient.prompt()
.user(query)
.call()
.content();
return CompletableFuture.completedFuture(
new SearchResponse(Collections.emptyList(), content));
}
}
这个修复解决了两个层面的问题:
| 问题 | 原代码 (ForkJoinPool) | 修复后 (专用池) |
|---|---|---|
| 池隔离(机制B) | JVM共享池,竞争不可控 | 应用专有池,独立调度 |
| 上下文传播(机制A) | 不传播,Tool拿不到 | TaskDecorator主动传播 |
| 阻塞补偿 | socket I/O不触发ManagedBlocker | CallerRunsPolicy兜底 |
六、为什么 ForkJoinPool 不适合跑阻塞 I/O
这是本次问题的核心知识点。用表格说清楚:
| 场景 | 适合的池 | 原因 |
|---|---|---|
| CPU密集(parallelStream、fork/join计算) | ForkJoinPool | 工作窃取 + ManagedBlocker 补偿 |
| 阻塞 I/O(HTTP调用、DB查询、文件读写) | ThreadPoolExecutor | 池大小可控,队列策略可控 |
| 混合(一段计算 + 一段I/O) | 独立 ThreadPoolExecutor | 不要污染 JVM 共享池 |
一句话:CompletableFuture.supplyAsync() 无参 = ForkJoinPool.commonPool(),只适合 CPU 密集型拆分。带阻塞 I/O 的任务务必传自定义 Executor。
七、排查方法论
这个案例的排查过程本身值得复盘:
第一轮分析(事后复盘的偏差)
现象:CompletableFuture + ChatClient 卡死,@Async 解决
↓
分析:@Async 能传播 ThreadLocal → 所以问题是 ThreadLocal 丢失导致死锁
↓
偏差:把"修复有效"反向推导为"根因就是修复针对的问题"
“修复有效 ≠ 归因正确”。@Async 同时解决了池隔离和上下文传播两个问题,不能因为修复有效就直接认定根因是后者。
正确的排查路径
1. 确认线程状态 → jstack <pid>
├─ RUNNABLE 但 CPU 100% → 无限循环 → 查业务逻辑
└─ WAITING (parking) → 等待通知 → 查线程池/Future
2. 确认是死锁还是饥饿
├─ jstack 输出 Found 1 deadlock → 经典死锁
└─ 大量线程 WAITING,无死锁报告 → 池饥饿
3. 利用调试器的反直觉行为
"调试能过运行卡死" = 并发/池问题的强烈信号
调试器串行化执行 → 消解竞争 → 问题消失
4. 最小化复现
剥离 Spring AI、LLM、DB,只保留线程池模型
如果简化模型能复现 → 问题是线程池层面的
jstack 速查
# 获取 Java 进程 PID
jps -l | grep HmDianPing
# 打印所有线程状态(含死锁检测)
jstack <pid> > thread_dump.txt
# 统计线程状态
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c
关键看一眼:
BLOCKED→ 锁竞争WAITING (parking)→ CountDownLatch / LockSupport / Future.get / CompletableFuture.joinWAITING (object monitor)→ Object.waitTIMED_WAITING (sleeping)→ Thread.sleepRUNNABLE+ CPU 高 → 可能是死循环
本案例用 jstack 会看到:主线程 WAITING (parking) 在 CompletableFuture.join(),ForkJoinPool 线程 RUNNABLE 在 socketRead。
核心测试场景:
CompletableFutureHangTest:
✓ scenario1 — ForkJoinPool 全饱和测试
✓ scenario2 — call() 内部线程切换时 ThreadLocal 丢失
✓ scenario3 — 完整 AI 搜索流程模拟(20并发)
✓ scenario4 — LLM 重试循环模拟
✓ scenario5 — ManagedBlocker 极限压力测试
ThreadPoolDeadlockTest:
✓ classicBoundedPoolDeadlock — 1线程有界池经典死锁
✓ forkJoinPoolBehavior — ManagedBlocker 补偿机制
✓ simulateAISearchInBoundedPool — 模拟嵌套提交死锁
✓ unboundedPoolNoDeadlock — 无界池不触发死锁
✓ threadLocalContextLoss — ThreadLocal 上下文验证
RealWorldHangTest:
✓ forkJoinPoolSaturationWithBlockingIo — ForkJoinPool 阻塞 I/O 饱和
✓ tomcatThreadBlockedOnJoin — Tomcat 线程阻塞等待
✓ concurrentAiSearchRequests — 50 并发 AI 搜索
✓ managedBlockerCompensationThreadBehavior — 补偿机制兜底能力
✓ dedicatedThreadPoolNoContention — 专用池对比
八、总结
-
别在 ForkJoinPool.commonPool() 上跑阻塞 I/O。 它设计给 CPU 密集的 fork/join 用,不会被 socket I/O 触发补偿。
-
带阻塞 I/O 的异步操作用自定义 ThreadPoolExecutor。 池大小、队列策略、拒绝策略全部可控。
-
"修复有效 → 归因正确"是常见认知陷阱。 @Async 同时修复了池隔离和上下文传播,不能直接推断根因是后者。
-
"调试能过运行卡死"是并发/池问题的诊断标志。 遇到这个信号,不要纠结代码逻辑,直接查线程池配置。
-
工欲善其事,必先 jstack。 看一眼线程状态分布,比猜一个小时管用。
环境:Spring Boot 3.5.7 + Spring AI 1.1.4 + JDK 21 + 嵌入式 Tomcat。不同版本内部实现或有差异,但 ForkJoinPool + 阻塞 I/O 的池饥饿问题是 JVM 层面的通用问题。
更多推荐



所有评论(0)