接口开始变慢,日志里偶尔出现 TaskRejectedException,监控上的线程数却没有明显增加。把最大线程数调大以后,排队仍然存在,这类问题应该从哪里查起?
先把三个问题分开:任务交给了哪个执行器,任务在队列里等了多久,工作线程拿到任务以后又卡在哪里。线程池只是任务经过的一站,数据库连接、外部 HTTP 调用和锁都可能让它失去处理能力。
本文以 JDK 17/21 的平台线程、ThreadPoolExecutor 为主;Spring 示例适用 Spring Boot 3.5 / Spring Framework 6.2。线程采样命令使用 Linux Bash,需要目标 JVM 支持 Attach。虚拟线程执行器、ForkJoinPool 和 Web 容器请求线程不能直接套用同一组池参数。下面的配置和输出均用于说明方法,没有在本站生产服务上执行扩容、压测或故障注入。
先确认任务究竟交给了哪个池
同一个 Java 进程中,可以同时有 Tomcat 请求线程、业务异步线程池、定时调度器和数据库连接池。它们有各自的容量和等待位置。
先沿着一次慢请求找到任务提交点,记录执行器的 Bean 名或对象来源、线程名前缀、实例标识以及部署版本。对 @Async,检查注解是否指定执行器、调用有没有经过代理;对 CompletableFuture,检查异步方法是否显式传入 Executor。没有指定执行器的异步方法通常使用公共 ForkJoinPool,并不自动进入你配置的业务池,低并行度还有独立线程的例外,见 JDK CompletableFuture 文档。
如果依赖 Spring Boot 自动配置的普通任务执行器,下面是一组仅作排队顺序说明的配置:
spring:
task:
execution:
thread-name-prefix: biz-task-
pool:
core-size: 8
max-size: 16
queue-capacity: 200
这不是通用容量推荐。已有自定义执行器时,要检查这些属性是否真的作用于当前任务;不要仅凭配置文件存在就认定生效。Java 21+ 开启 spring.threads.virtual.enabled=true 时,Boot 自动配置会使用虚拟线程的 SimpleAsyncTaskExecutor,上述普通线程池容量配置不适用。自动配置和自定义 Bean 的选择规则以 Spring Boot 3.5 任务执行文档为准。
理清 core、queue、max 的先后关系
对普通 ThreadPoolExecutor,忽略关闭和线程创建失败等分支后,新任务的接纳顺序可以简化为:
工作线程数少于 core → 尝试增加工作线程
达到 core → 优先尝试入队
队列不能接纳任务 → 尝试扩到 max
达到 max 且不能入队 → 执行拒绝策略
因此,上例在八个工作线程都忙时,会先利用队列,不能把 max-size: 16 理解成“第九个任务到来就立即使用第九个线程”。如果队列没有容量上限,通常会一直积压在核心线程后面,单独提高最大线程数未必产生预期效果。完整语义见 ThreadPoolExecutor 的排队与容量说明。
反过来,SynchronousQueue 不储存待处理任务,队列长度为零也不能证明没有接纳压力。判断队列容量和排队行为时,先确认实际队列类型;Spring ThreadPoolTaskExecutor 的正数 queueCapacity 使用有界 LinkedBlockingQueue,非正数使用 SynchronousQueue,见 Spring API。
在同一时间窗口看处理能力和积压
不要只盯着 active 一条曲线。至少把任务完成量、排队数量、排队耗时、执行耗时和拒绝量与请求延迟放在一起,且使用同一实例、同一执行器、同一时间范围。
| 信号 | 它能说明什么 | 不能直接得出的结论 |
|---|---|---|
| pool / active | 当前线程规模、正在执行任务的近似数量 | active 高就等于 CPU 高 |
| queue size | 当前留在该队列里的任务数量 | 一次采样就能说明积压趋势 |
| completed 的区间增量 | 工作线程在这段时间内完成了多少任务 | 完成就等于业务成功 |
| 排队耗时 / 执行耗时 | 慢发生在接纳后等待,还是任务本身 | 单看执行耗时就能覆盖用户全部等待 |
| 拒绝量和失败量 | 容量不足、关闭等接纳失败 | 没有异常日志就等于没有丢任务 |
若还没有监控,可以把下面的只读快照方法接入已有的低频诊断入口。代码使用已有、仍在运行的业务 ThreadPoolExecutor,不能为了采样再新建一个池;调用方自行控制频率和访问权限。
import java.util.concurrent.ThreadPoolExecutor;
public final class PoolSnapshot {
private PoolSnapshot() {}
public static String read(String poolName, ThreadPoolExecutor pool) {
return String.format(
"pool=%s size=%d active=%d core=%d max=%d " +
"queued=%d completed=%d shutdown=%s",
poolName,
pool.getPoolSize(),
pool.getActiveCount(),
pool.getCorePoolSize(),
pool.getMaximumPoolSize(),
pool.getQueue().size(),
pool.getCompletedTaskCount(),
pool.isShutdown()
);
}
}
Spring ThreadPoolTaskExecutor 可在初始化完成后通过 getThreadPoolExecutor() 取得底层池。各字段是并发状态下分别读取的快照,不是事务性的一致视图;完成任务数也是近似累计值。计算区间增量时,遇到实例重启或池重建要切断区间,不能把计数归零算成吞吐下降。
这个方法不提供排队耗时或拒绝计数。排队耗时需要在任务被接纳到开始执行之间单独观测,执行耗时则从开始到结束计算。若用任务包装器补指标,应保留异常、取消、上下文传递语义,先在测试环境验证,并避免给每个任务打印同步日志。
已有 Actuator/Micrometer 时,先检查现成指标。Boot 可对符合条件的 ThreadPoolTaskExecutor / ThreadPoolTaskScheduler Bean 自动采集,并用执行器 Bean 名区分;原生自行创建的池不能因此假定已经自动纳管。指标实际名称、可用标签和端点暴露范围应在当前环境确认,见 Spring Boot 指标文档。
取三份线程栈,找工作线程在等谁
队列持续增加,同时 completed 的增长变慢,下一步要看工作线程正在执行什么。下面只采三份线程栈,每次间隔五秒;先确认 PID 和诊断文件目录,不要照搬进程号。
# Linux Bash;以目标 JVM 相同的有效用户和组运行
APP_PID=12345 # 替换为已确认的业务 JVM PID
TASK_STAMP=$(date +%Y%m%d-%H%M%S)
TASK_DIR="./thread-pool-$APP_PID-$TASK_STAMP"
umask 077
mkdir "$TASK_DIR" || exit 1
jcmd "$APP_PID" VM.version || exit 1
jcmd "$APP_PID" help Thread.print || exit 1
for TASK_SAMPLE in 1 2 3; do
jcmd "$APP_PID" Thread.print -l > "$TASK_DIR/threads-$TASK_SAMPLE.txt" || exit 1
if [ "$TASK_SAMPLE" -lt 3 ]; then sleep 5; fi
done
容器环境还需要确认 PID 命名空间和 Attach 所需路径。使用与目标环境匹配、带有 jcmd 的 JDK;连接失败先处理身份、环境或 Attach 限制。Thread.print 的开销随线程数量变化,官方标为中等影响,不能当作无成本的高频监控。命令和参数见 JDK 21 jcmd 文档。
按已确认的线程名前缀阅读工作线程,保留栈顶和业务调用链,对比三份样本。文件可能包含业务类名和锁信息,采样目录不要放进公开下载目录。
| 多份样本中的现象 | 优先追查 |
|---|---|
| 工作线程停在获取 JDBC 连接附近 | 数据库连接池等待、长事务、连接占用 |
| 多个工作线程停在 HTTP 客户端读取附近 | 下游响应、读取超时、调用并发和重试 |
| 同一业务锁附近反复等待 | 持锁路径、锁的拥有者、临界区耗时 |
| 业务线程等待同一个池中子任务的 Future | 池内相互依赖导致的线程饥饿 |
| 工作线程在队列 take / getTask 等待 | 可能是空闲取任务,需确认是否看错池或错过故障窗口 |
RUNNABLE 不能单独证明在消耗 CPU,WAITING 也不能单独证明发生故障。状态含义和操作系统线程状态并不一一对应,见 Thread.State 文档。要沿着业务调用链和连续样本判断,不能只统计状态名称。
区分任务变慢、接纳过量和线程饥饿
把指标与线程栈对齐后,再决定处理方向。
任务执行耗时上升,完成量下降。 如果多数工作线程在等数据库连接,应继续看连接池耗尽排查;如果卡在外部读取,继续看Java HTTP 调用超时。扩大业务线程池可能只是让更多线程一起等待同一个下游。
执行耗时变化不大,但提交增长超过完成能力。 这更像接纳量超过处理预算。先分清是短时突发、批任务抢占,还是重试让同一业务被重复提交。若请求已经超时,任务却仍留在内存队列中执行,还要检查任务有效期、取消传播和写操作幂等,避免故障恢复后集中执行过期任务。
父任务占着池中的线程,等待同一池里的子任务。 当所有工作线程都被这种等待占住,排队的子任务没有线程执行,即使未出现 JVM 锁死锁报告也可能无法推进。处理方向是消除同池内阻塞等待、改成合理的异步组合,或按依赖关系隔离执行资源;单纯延长 Future 的等待时间不会提供处理能力。若有明确锁循环,再沿线程死锁定位继续检查。
如果慢请求窗口同时伴随 CPU 或 GC 异常,再分别对照 CPU 100% 定位和 GC 停顿排查。不要因为线程池告警出现得早,就把它当成唯一根因。
查清拒绝策略,不只搜索异常
原生 ThreadPoolExecutor 的默认 AbortPolicy 在拒绝时抛出 RejectedExecutionException,见 AbortPolicy 文档。Spring 包装执行器还可能以 TaskRejectedException 向调用方报告。拒绝不仅可能发生在容量耗尽时,也可能发生在池已经关闭之后,所以要同时核对生命周期、发版时间和实际配置。
CallerRunsPolicy 会让提交任务的线程直接运行任务;如果提交者是请求线程,压力可能转成接口变慢。如果提交者承担事件循环等职责,阻塞影响会扩大。它不是新增容量,而且池关闭时会丢弃任务,见 CallerRunsPolicy 文档。
丢弃类策略可能没有异常,因此要核对实际的 handler,而不能把日志安静当作任务都已成功。调用方也不能吞掉接纳失败后仍返回“处理成功”。需要区分接纳失败、执行失败和业务成功,重要任务还要有可核对的持久化状态。
拒绝后立即无限重试会放大流量。是否可重试,应由业务幂等、任务有效期和有界退避共同决定。正在停止服务时,先处理新提交与关闭顺序,不要用扩池掩盖生命周期问题。
根据等待预算止血,再调整容量
先减少没有必要的提交和重试,限制失控的批量入口,并处理已经确认的下游慢点。需要隔离慢任务时,为不同依赖保留独立预算;拆成多个池后,数据库和外部服务的总并发仍要一起计算。
队列容量应该与允许的等待时间和任务占用内存一起评估。一个只有几千项的队列,也可能因为每项捕获大对象而带来明显内存压力;把队列放大往往只是把拒绝推迟成超时或内存问题。
可以先用稳定窗口的平均值做量级估算:若平均处理速率为每秒 80 个任务、希望平均队列等待控制在 0.1 秒附近,则平均排队数量对应约 8 个任务。这是“平均排队数 ≈ 到达速率 × 平均排队时间”的预算思路,依赖稳定状态,不能直接拿来保证 P99,更不能据此把硬容量机械设成 8。这里的数字是算例,没有性能实测含义。
提高线程数前,至少确认 CPU 配额、线程内存、数据库连接预算和下游并发限制都还有余量。一次只改一类参数,记录旧值和回退条件;线程数、队列、超时一起修改,会让结果难以解释。
复测要看到队列消退和业务结果
在同一实例和同一执行器上,对比变更前后的提交量、完成量、拒绝量、队列趋势、排队与执行耗时,以及请求 P95/P99、错误率和下游负载。测试应包含常规负载、短时突发以及下游变慢后的恢复过程,不只看轻载时队列为零。
验收时还要确认:队列能够在预期窗口内消退,超时任务不会继续无界积压,拒绝或取消能反馈给正确的调用方,重要任务没有漏执行或重复写入。线程池的 completed 增长并不能替代这一步。
不要为了清空告警直接重启:普通内存队列不是持久化任务系统,停止进程可能丢失尚未执行的任务。若确需变更或重启,先按业务要求处理停止接纳、在途任务和可恢复任务记录。
资料于 2026-11-03 按上述 JDK 与 Spring 官方文档核对。本文示例用于建立排查顺序,参数值、采样时长和变更步骤需要结合实际环境选择。更多问题可从 Java 后端排障专题继续查找。
Java 线程池打满、任务排队怎么查
https://java.li/archives/java-thread-pool-saturation-troubleshooting
评论