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 EventLoopWebClient HTTP客户端核数×2非阻塞网络 I/O

三、两种可能的卡死机制

通过测试和分析,这个场景下可能有两种不同的机制导致卡死,取决于具体条件。下面逐一分析。


机制A:ThreadLocal 上下文丢失 → @Tool 异常 → LLM 重试循环(逻辑卡死)

触发条件:@Tool 方法直接或间接访问了 RequestContextHolderSecurityContextHolder 等 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不触发ManagedBlockerCallerRunsPolicy兜底

六、为什么 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.join
  • WAITING (object monitor) → Object.wait
  • TIMED_WAITING (sleeping) → Thread.sleep
  • RUNNABLE + 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 — 专用池对比

八、总结

  1. 别在 ForkJoinPool.commonPool() 上跑阻塞 I/O。 它设计给 CPU 密集的 fork/join 用,不会被 socket I/O 触发补偿。

  2. 带阻塞 I/O 的异步操作用自定义 ThreadPoolExecutor。 池大小、队列策略、拒绝策略全部可控。

  3. "修复有效 → 归因正确"是常见认知陷阱。 @Async 同时修复了池隔离和上下文传播,不能直接推断根因是后者。

  4. "调试能过运行卡死"是并发/池问题的诊断标志。 遇到这个信号,不要纠结代码逻辑,直接查线程池配置。

  5. 工欲善其事,必先 jstack。 看一眼线程状态分布,比猜一个小时管用。


环境:Spring Boot 3.5.7 + Spring AI 1.1.4 + JDK 21 + 嵌入式 Tomcat。不同版本内部实现或有差异,但 ForkJoinPool + 阻塞 I/O 的池饥饿问题是 JVM 层面的通用问题。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐