臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 线程池是什么

上游调 aboss-code 查一批码值,服务要校验参数、读缓存,必要时查数据库,再把结果返回。有人同时在后台导出码表,服务还要查数据、整理文件、上传文件。

这些代码总得有线程来执行。线程池就是把一组线程管起来,让它们不断接任务、做完再接下一个,同时规定忙不过来时怎么办。

拿项目里的码表导出来说,一个线程做完这次导出后,不必马上销毁,下次缓存刷新或导出任务可以继续由它处理。这叫线程复用。它不是给每次导出都无限开新线程,否则请求一多,数据库、内存和 CPU 都可能吃不消。

数据库:同时找它干活的人变多了。
1.queryBatch 获取码表时,如果缓存未命中,就要查数据库。
2.假设很多请求同时没命中缓存,就会同时向数据库发起查询。
3.数据库的处理能力和连接数都有上限。查询来得比完成得快,就会排队;应用拿不到数据库连接,也要等。等待又让请求更晚结束,积压进一步增加。

内存:没处理完的请求,需要一直留着相关数据。
一次批量查询会带着入参列表,处理中还会有查询结果、筛选结果等对象。一次导出还可能把较多码值读进内存,转换成导出数据。

这些数据还在被使用,就不能回收。对象创建得越多,垃圾回收(GC)的工作也可能越多;如果回收速度跟不上分配速度,就会出现内存压力。线程本身也需要栈内存,线程无限增加同样有成本。

CPU:要执行的代码更多了。
项目的 queryBatch 会逐项校验参数、获取码表,再遍历寻找目标 code。同时来的请求越多,这些工作就要重复执行越多次。数据转换、结果序列化、日志处理和 GC 也都需要 CPU。

CPU 核心数有限。线程多了以后,要轮流获得执行机会;线程之间切来切去也有成本,增加线程不等于增加算力。

1.1 线程是什么

线程,就是程序里实际执行代码的一条执行路线。

拿这个项目来说:aboss-code 启动后,是一个运行中的程序,也就是一个进程。进程里有很多线程,负责执行不同的工作,比如查码值、导出文件、消费消息。

假设上游调用 queryBatch,服务会安排一个线程执行这次查询:

一个线程
  → 校验参数
  → 读取码表缓存
  → 缓存未命中时查询数据库
  → 找到需要的码值
  → 返回结果

这些步骤要有人执行,这个“执行者”就是线程。方法是要做什么,线程是沿着这些方法把事情做下去的执行路线。

如果同时来了多个请求,就可以由多个线程分别处理:

aboss-code 进程
├─ 线程 A:处理一次码值查询
├─ 线程 B:处理另一次码值查询
└─ 线程 C:执行码表导出

线程在等数据库返回时,虽然可能没怎么用 CPU,但它手上的请求还没处理完,也就不能回到池里接下一个任务。 很多线程都这样等着,后面的请求就可能积压。

所以这三个概念可以连起来记:

概念在项目中的意思
进程启动起来的 aboss-code 程序
线程程序中执行查询、导出等代码的执行路线
线程池管理一组线程,让它们做完任务后继续接下一项工作

1.2 线程与任务

在这个项目里,可以这样记:导出一份码表是任务,执行查询和文件上传的那条执行路线是线程。

对象放到 aboss-code 里怎么理解
任务执行一次 BasicCodesExportExe.execute,完成一次导出
工作线程执行这个任务的线程,例如 Async-Executor-1
线程池管理这一组工作线程的 taskExecutor
任务队列所有工作线程都忙时,让后续导出、缓存刷新任务先等着

可以把它想成办公室:线程是办事的人,任务是待办事项,队列是还没轮到的待办清单。这个比喻只用来理解分工;真实线程会共享 CPU,也可能大部分时间在等数据库或网络。

1.3 为什么用线程池

用户点了导出后,接请求的线程先把导出任务交给后台线程。交出去以后,它就可以结束这次请求,去处理其他任务;后台线程继续负责把文件生成并上传。

这里弄个简单的例子

比如你在页面点了一下“导出码表”,请求到了项目的 CodeFacadeImpl.exportCodes(接口)。
假设负责处理这个请求的是线程 A。如果所有工作都让 A 做:

线程 A:
接到导出请求
  → 查数据库
  → 整理导出数据
  → 上传文件
  → 告诉页面处理结果

如果整个过程需要 15 秒,A 就要负责到这 15 秒结束。期间其他请求可以由其他线程处理,但 A 自己还没空接下一项任务。

项目现在把工作分给了另一个线程。关键代码是:

// 这个方法带有 @Async("taskExecutor")。
// 正常情况下,Spring 会把导出任务交给后台线程池。
basicCodesExportExe.execute(basicCodesExportCmd);
 
// 当前请求可以先返回 true。
resultModel.setData(true);

假设后台线程池安排线程 B 做导出:

线程 A:接到请求 → 把导出任务交出去 → 返回 true → 可以接下一项任务
                         ↓
线程 B:              查数据库 → 整理数据 → 上传文件 → 更新操作状态

A 提前返回的意思是“导出任务已经交办”,不是“文件已经导出完成”。 文件可能还在由 B 生成,最终成功还是失败,要看后台任务更新的操作状态。

基础概念可以接着看 Java/基础/线程。下面我们就沿着“上游查码值”和“用户导出码表”两条真实路径往下看。

1.4 一个 Pod 里完全可以有多个线程池

一个 Pod
└─ aboss-code 的 Java 进程
   ├─ RPC 线程池
   │  ├─ 工作线程
   │  └─ 工作线程……
   │
   ├─ 业务异步线程池 taskExecutor
   │  ├─ Async-Executor-1
   │  └─ Async-Executor-2……
   │
   └─ 其他线程,例如 GC 线程、消息消费线程等

Pod 是部署程序的运行环境,可以有多个线程池。 线程池是在 Java 程序内部创建和管理的,一个程序可以根据用途创建多个池。

不过,多个池仍然共享这个 Pod 可用的 CPU、内存,以及应用使用的数据库等资源。 分成不同池,能避免所有任务直接抢同一批工作线程,但不会凭空增加机器的处理能力。

比如后台导出把自己的 10 个线程全占了,RPC 池可能还有空闲线程;但如果导出同时把 CPU 或数据库连接占得很紧,RPC 查询仍然可能变慢。

二、🧭 项目中的线程池

2.1 RPC 请求处理

项目通过 SOFA Bolt 暴露远程接口。比如 CodeRPCFacadeImpl 上就有下面这段声明,节选自现有代码:

// @SofaService:把这个实现暴露为远程服务。
// bindingType = "bolt":上游通过 Bolt 协议调用。
@SofaService(interfaceType = CodeRPCFacade.class,
    bindings = {@SofaServiceBinding(bindingType = "bolt")})

上游请求进来时,有一组线程负责执行 RPC 查询;项目后台导出接口又用了另一组线程。(这是俩组不同的线程池)

上游查码值
  → RPC 框架安排线程
  → 执行 queryBatch
  → 返回查询结果

用户导出码表
  → RPC 框架安排线程接收请求
  → 将导出任务提交给 taskExecutor
  → 后台线程继续执行导出

2.2 业务异步任务

项目在 AsyncConfig.java 创建了一个名叫 taskExecutor 的 Bean。下面是配置节选,便于对照阅读:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程:随任务到来逐步创建,正常情况下主要由这组线程接活。
executor.setCorePoolSize(5);
// 队列放不下时允许继续增加线程,但池内最多到 10 个。
executor.setMaxPoolSize(10);
// 暂时轮不到执行的任务,最多可以在这里等 100 个。
executor.setQueueCapacity(100);
// 抓线程快照时,可以用这个前缀找到业务异步池。
executor.setThreadNamePrefix("Async-Executor-");
// 池和队列都接不住时,让提交任务的线程自己执行。
executor.setRejectedExecutionHandler(
    new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());

哪些业务会用它?看方法上的 @Async("taskExecutor") 就能找到:

项目中的类或方法交给后台做什么
BasicCodesExportExe.execute查询码表码值、整理导出数据、上传文件、更新操作状态
ImportAreasDataExe.execute导入地区数据
ExportAreasDataExe.execute导出地区数据
SyncCodesCacheByTypesExe.execute按类型刷新缓存
SyncAssociationCodeCacheExe.execute刷新关联关系缓存
CodeAssociationGatewayImpl 中多处异步方法执行相应关联关系的后台处理

它们共享这个池。 不是导出有 10 个线程、刷缓存另有 10 个线程,而是这些任务一起使用最多 10 个池内线程。

2.3 配置是否生效

application.properties 还写着另一组值:核心 10、最大 200、队列 1000。但刚才的 AsyncConfig 直接写了 5、10、100,并没有读取这组属性。

所以,就当前代码创建的 taskExecutor 而言,修改配置文件里的 200,不会把它变成 200 个线程。 是否有外部依赖使用这些属性,还没有确认;也不能据此说 RPC 池就是 200。

项目还有 MQ 消费、调度任务各自的执行资源,例如数据中台消息消费者设置了消费线程数 1。它们与这次的重点不同,排查时只要记住:一个 Java 服务里通常不止一个池。

三、⚙️ 请求处理流程

3.1 批量查询

假设上游要查询 3 组 type + code,一次性调用 queryBatch。这里的 3 组只是示例输入,接口实际接收一个列表。

上游调用 CodeRPCFacade.queryBatch
  → SOFA RPC 接收并安排执行
  → CodeRPCFacadeImpl.queryBatch 校验这批参数
  → BasicCodeBatchQryExe.execute 逐项处理
      → 根据 type 获取码表列表
      → 从列表中寻找所需 code
      → 收集查到的结果
  → 返回这一批结果

BasicCodeBatchQryExe 里用的是普通 stream().forEach,不是并行流,也没有 @Async。所以这里不会“3 组参数就分配 3 个异步线程”,而是在这次调用中逐项处理。

获取码表时会经过缓存逻辑。BasicCodeGatewayImpl.queryCacheByType(String) 配置了缓存注解;缓存命中可以复用结果,未命中时方法体会按类型查数据库。有 status 参数时,还会对取得的列表过滤。

这条路径上,业务处理期间执行请求的线程一直被占用:在计算时被占用,在等待数据库或缓存响应时也被占用。等这次处理结束,它才有机会接下一件事。

回到上游的报错

如果上游报的是查询 RPC 繁忙,首先要沿这条查询链看线程卡在哪。仅仅看 Async-Executor-* 是否忙,可能看错了池。

3.2 异步导出

导出入口是 CodeFacadeImpl.exportCodes。它在业务处理回调中调用另一个 Bean:

// basicCodesExportExe 是注入的 Spring Bean。
// execute 上有 @Async("taskExecutor"),通过代理调用后交给指定执行器。
basicCodesExportExe.execute(basicCodesExportCmd);
// 设置接口返回值。这里的 true 不等于文件已生成成功。
resultModel.setData(true);
resultModel.setStatus(Status.S.getValue());

这是现有方法内部的节选,参数和 resultModel 都来自外层方法。正常提交成功后,可以把流程理解成两条路线:

接请求的线程:校验参数 → 提交导出任务 → 返回 true
                              ↓
后台工作线程:记录处理中 → 查码表和码值 → 整理数据
             → 上传文件 → 更新成功或失败状态

所以,接口返回成功与导出任务最终成功是两件事。 导出过程中上传失败,后台代码还会记录失败状态;这时最初的接口可能已经返回了。

@Async 的作用也有前提:调用要经过 Spring 异步代理。这里是一个 Bean 调用另一个 Bean;如果改成同一个对象内部直接调自己的方法,默认代理模式通常不会触发异步。

3.3 任务分配

现在假设后台来了很多导出和缓存刷新任务,而且前面的任务一直没有结束。按照这个项目的 5 / 10 / 100 配置,过程是:

提交到哪一步池里发生什么
第 1~5 个任务逐步创建 5 个核心线程,开始执行
第 6~105 个任务进入队列,最多排 100 个
第 106~110 个任务队列已满,继续增加线程,池内最多到 10 个
第 111 个任务池和队列都接不住,进入 CallerRunsPolicy

这张表有一个必要前提:过程中没有任务完成,也没有队列位置被腾出来。 真实线上任务不断完成、入队和出队,不会永远按这个编号发展。

还要注意:核心线程不是指“此刻正在忙的线程数”。已有工作线程空闲时可以领取排队任务,通常也不会在一有请求时就直接创建到最大值。

3.4 饱和后的处理

项目选的策略叫 CallerRunsPolicy,翻成大白话就是:“你提交的,你自己做吧。”

如果这次是接收导出 RPC 的线程在提交任务,任务就可能直接由它执行。于是本来只负责交办的线程,也要亲自查数据、整理文件、等待上传,直到这段任务结束,调用才能继续往下走。

这就解释了一个看起来奇怪的现象:平时导出接口很快返回,忙的时候却突然等很久。它可能已经从“交给后台做”变成“提交者自己做”。是否确实如此,需要看现场堆栈,不能只凭耗时变长断定。

按照 JDK 规则,这个策略在池已关闭时会丢弃被拒绝任务,因此也不能把它理解成任何情况下都保证执行。

四、🔎 线程池为什么会满

4.1 线程被长期占用

假设导出线程正在等文件上传返回。它此时可能没怎么用 CPU,但仍然没办法接下一个缓存刷新任务。其他线程也各自等着数据库、缓存或网络,后来的任务就只能排队。

所以“CPU 不高,怎么还会线程池满”并不矛盾。要看的其实是:线程什么时候才能把手里的任务做完、腾出来。

请求多也好,任务变慢也好,只要在一段时间内进来的工作持续超过完成的工作,就会积压。

4.2 批量查询变慢

结合当前代码,可以具体检查这几个地方:

  • 一批参数多。 入口逐项校验,业务也逐项查码值。批次变大,一次调用要完成的工作通常随之增加。
  • 同一个 type 重复出现。 当前代码每个条目都会调用码表获取逻辑并筛选。即使缓存命中,也仍有读取和遍历成本;不能把重复调用直接当成重复查数据库。
  • 缓存或数据库变慢。 未命中要查数据库;命中了也要考虑实际缓存读取、转换等耗时。是否发生需要监控证明。
  • 附带 status 过滤,或码表列表较大。 过滤和逐项寻找也会花时间,是否构成瓶颈取决于数据规模和现场堆栈。

这些是从代码找出的排查方向,不是已经确认的事故根因。假如一个查询原来很快,后来因为这些因素迟迟不返回,就会有越来越多请求占着 RPC 线程,后面的请求只能等待或被框架拒绝。

RPC 池到底能排多少、何时拒绝,取决于它自己的生效配置。上一节的“第 111 个任务”只是在解释业务异步池,不能直接套在查询 RPC 上。

4.3 后台任务积压

BasicCodesExportExe 中,一次任务要查数据、转换对象、补充数据、调用文件服务上传,最后更新操作状态。在这些步骤结束前,工作线程仍被这次任务占用。

如果碰上多次导出或地区导入,又有人触发缓存刷新,任务会一起挤进 taskExecutor。前面的长任务没有做完,后面的短任务也要等。

接下来就可能出现:

导出、导入或缓存刷新变慢
  → 任务占用线程更久
  → 等待队列积压到 100
  → 工作线程扩到 10,仍然忙不过来
  → 后续提交者自己执行任务
  → 提交这些任务的请求也可能变慢

这是一条代码机制允许出现的路径。它不代表 queryBatch 本身使用了这个异步池,也不证明历史 RPC 故障就是它引起的。

4.4 如何定位

Java/工作实践/压测下RPC接口大量失败排查记录 中,2026-10-28 的调用方日志记录了:

InvokeServerBusyException: Server thread pool busy!
被调用方:midaboss-code
方法:CodeRPCFacade.queryBatch
目标:10.197.136.170:13305

这份历史证据指向当时目标实例的 RPC 服务端繁忙。对应排查顺序是:先找到那台 code 实例,再看查询线程正在做什么。当前报错是否同一类,需要用完整异常、时间和目标地址对照。

实际排查时,我会先拿这几样东西:

先拿到什么用来回答什么问题
完整异常、接口方法、时间、traceId、目标实例到底是谁的哪个池报忙
同一实例间隔采集的几份线程快照线程是卡在查询、数据库、上传还是锁上
对应线程池的活跃数、队列长度、完成速度是瞬间忙一下,还是持续堆积
同期请求量、批量条数、缓存/数据库耗时、GC为什么这一段时间突然处理不过来

对 taskExecutor 而言,如果活跃线程接近 10、队列接近 100,而且持续不下降,就很值得追。RPC 池要看它自己的容量和指标。线程数量、活跃数等都是动态观测值,单个快照不能说明整个故障过程。

别只凭 WAITING 就说线程卡死:工作线程在等新任务时也会是等待状态。要看它在等队列里的新活,还是业务执行到一半在等别的资源。

下次再看到“线程池打满”

先沿这条线问下去:哪一个池?哪些任务占着线程?这些任务为什么还没结束? 回到项目的具体调用链,才知道该优化查询、处理慢调用、拆开长短任务,还是评估扩容。

五、🛠️ 实践验证

5.1 准备与用途

下面的独立实验只需要 JDK 8 或以上,不依赖 Spring、数据库和业务项目。用途是亲眼看见“池满后,提交任务的线程会自己执行”。配置缩小为 1 / 2 / 1,方便快速观察;它不模拟 SOFA RPC 的容量或拒绝规则。

现场诊断则需要对应实例的访问权限、真实 Java 进程号,以及匹配的 JDK 工具。项目是否已有线程池监控接口尚未确认,不能直接假定某个监控地址可访问。

5.2 本地复现

把以下代码保存为本地实验目录中的 PoolSaturationDemo.java。为了快速观察,将配置缩小为核心 1、最大 2、队列 1;机制与项目有界队列相同。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
 
public class PoolSaturationDemo {
    public static void main(String[] args) throws InterruptedException {
        // 让池内任务暂时无法结束,稳定构造“线程忙、队列满”。
        CountDownLatch release = new CountDownLatch(1);
        AtomicInteger sequence = new AtomicInteger();
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
            1, 2, 60, TimeUnit.SECONDS,
            // 有界队列:这里只允许一个任务排队。
            new ArrayBlockingQueue<Runnable>(1),
            // 给工作线程起名,便于和提交任务的 main 线程区分。
            task -> new Thread(task, "demo-worker-" + sequence.incrementAndGet()),
            // execute 无法提交时,直接在调用线程执行。
            new ThreadPoolExecutor.CallerRunsPolicy());
 
        Runnable blockingTask = () -> {
            System.out.println("池内任务开始:" + Thread.currentThread().getName());
            try {
                // await:等待 main 释放,期间任务占用工作线程。
                release.await();
            } catch (InterruptedException e) {
                // 保留中断状态,让上层仍能知道线程被要求中断。
                Thread.currentThread().interrupt();
            }
        };
 
        try {
            pool.execute(blockingTask); // 第一个任务创建核心线程。
            pool.execute(blockingTask); // 第二个任务进入唯一的队列位置。
            pool.execute(blockingTask); // 队列满,第三个任务创建第二个线程。
            System.out.printf("提交第四个任务前:pool=%d, queue=%d%n",
                pool.getPoolSize(), pool.getQueue().size());
 
            // 第四个任务不等待 release,避免 main 自己阻塞而无法释放任务。
            pool.execute(() -> System.out.println(
                "第四个任务执行线程:" + Thread.currentThread().getName()));
            System.out.println("第四个任务结束后,execute 才返回");
        } finally {
            // countDown:放行阻塞的任务,让排队任务也能完成。
            release.countDown();
            // shutdown:停止接收新任务,但继续执行已提交任务。
            pool.shutdown();
            // awaitTermination:等待工作线程退出;超时则发出中断请求。
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                pool.shutdownNow();
            }
        }
        System.out.println("池内完成任务数:" + pool.getCompletedTaskCount());
    }
}

进入保存代码的目录,编译并启动:

# -encoding UTF-8:确保中文注释和输出字符串正确编译。
javac -encoding UTF-8 PoolSaturationDemo.java
# 本地验证输入就是 main 中连续提交的四个任务,无需 HTTP 请求。
java PoolSaturationDemo

关键预期结果如下,工作线程输出的先后顺序可能变化:

提交第四个任务前:pool=2, queue=1
第四个任务执行线程:main
第四个任务结束后,execute 才返回
池内完成任务数:3

第四个任务由池外的 main 完成,不计入线程池自身的完成任务数。因此监控仅统计池内完成量时,可能漏看调用线程承担的工作。

若提示找不到 javac,检查是否安装完整 JDK;若提示找不到主类,确认位于 .class 所在目录。若自己改代码后一直不退出,检查是否让第四个任务也等待 release,导致负责放行的 main 被卡住。

5.3 现场排查

如果你拿到了目标实例的诊断权限,可以在对应主机或容器内采线程快照。进程号要替换成真实值,容器内外的 PID 可能不同:

# 查看当前环境中的 Java 进程。
jcmd -l
# 示例 12345 需要替换;输出线程调用栈及锁信息。
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-1.txt
# 间隔采集,判断同一批线程是否一直停在相似位置。
sleep 10
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-2.txt
sleep 10
jcmd 12345 Thread.print -l > /tmp/aboss-code-threads-3.txt

看业务池时找 Async-Executor-*;看 RPC 池时以现场真实线程名和调用栈为准。采集会有短暂诊断开销,不需要不停地抓。

如果很多线程都停在 BasicCodeBatchQryExe 这条链上,就接着看批次和码表处理。如果都在等数据库连接,就对照连接池等待数和获取连接耗时。如果都在文件上传,就沿文件服务查。线程快照给你的是“现在停在哪”,还要结合多次快照和监控解释“为什么停这么久”。

六、📚 代码与资料

本篇按 2026-11-04 工作区代码整理。原始创建时间保留在文件属性中。项目声明 Spring Boot 2.1.13.RELEASE、SOFA Boot 3.4.20,部署版本及配置需与实际实例核对。

在 /Volumes/data/ideaWorkSpace/aboss-code 中,可以按类名直接搜索:

想接着看什么找哪个文件
业务异步池怎么建AsyncConfig.java
上游批量查询入口CodeRPCFacadeImpl.java 的 queryBatch
每个查询参数怎么处理BasicCodeBatchQryExe.java
缓存获取、未命中查询、status 过滤BasicCodeGatewayImpl.java
导出入口怎样提交任务CodeFacadeImpl.java 的 exportCodes
导出任务具体做什么BasicCodesExportExe.java
哪些方法共享异步池搜索 @Async("taskExecutor")

线程规模、排队、扩线程、拒绝处理的顺序参考 JDK 文档;Spring 文档补充解释项目的异步注解和代理。以下为此前实际查阅过的官方资料,本次沿用其中与当前代码有关的机制:

@Async 的配置、调用过程及独立示例,见 如何使用@Async实现异步导出。