服务跑着跑着开始抛RejectedExecutionException,一分钟内刷出几百条,紧接着接口全线超时。报错原文长这样:
java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@6d06d69ca rejected from java.util.concurrent.ThreadPoolExecutor@4b1c1ea0[Running, pool size = 10, active threads = 10, queued tasks = 1024, completed tasks = 18342]
现场参数已经全打印在报错里了,不用额外猜。
先把报错串里的四个数字读出来
pool size = 10是当前线程数,已经到上限;active threads = 10说明十个线程全在忙,没有一个闲着;queued tasks = 1024是队列长度,也满了,而且1024这个数字说明代码里给队列设了界;completed tasks = 18342表示池子之前一直在正常干活,不是刚启动就被打满。四个数字放一起看,结论很直接:任务提交速度长期高于处理速度,堆到队列撑不住为止。这种情形下把maximumPoolSize往上调通常没用,堆积本身就说明瓶颈在下游,比如数据库连接池打满或者外部接口变慢。
execute()的四级流转
ThreadPoolExecutor收到任务后的判定顺序是固定的:当前线程数小于corePoolSize时直接新建线程执行,不管有没有空闲线程;达到corePoolSize后任务进workQueue;队列满了才会继续扩容到maximumPoolSize;线程数也到顶且队列仍然满,交给RejectedExecutionHandler。
坑就在第三步:只有队列已满才会扩容到最大线程数。Executors.newFixedThreadPool用的是无界LinkedBlockingQueue,容量Integer.MAX_VALUE,第三步永远不会发生,maximumPoolSize等于白设。见过不少配置里maximumPoolSize写200、实际线程数长期卡在8的情况,原因就在这。同理newSingleThreadExecutor也是无界队列,newCachedThreadPool反过来,队列是SynchronousQueue不存任务,maximumPoolSize是Integer.MAX_VALUE,堆积时表现不是拒绝而是线程数暴涨。
四种拒绝策略怎么挑
AbortPolicy是默认策略,直接抛RejectedExecutionException,任务丢掉但日志留痕。CallerRunsPolicy让提交任务的那个线程自己把活干完,压力回压给上游,Tomcat的http-nio线程被占住后接口立刻变慢,相当于一层天然限流。DiscardPolicy静默丢弃,什么都不记,排查时最难发现,线上不推荐。DiscardOldestPolicy把队列头部那个任务扔掉再重试提交,只在允许丢旧数据的场景用。
实测下来Web服务里CallerRunsPolicy比AbortPolicy好用,因为上游会马上感知到慢,而不是甩给用户一堆500。后台批处理反过来,AbortPolicy配合告警更合适,任务丢了至少知道丢了多少。
Spring Boot的默认线程池是个隐形陷阱
Spring Boot 2.1.0之前的版本,@Async默认走SimpleAsyncTaskExecutor,每个任务新建线程,不复用也不限数量,突发流量下直接把线程数打爆。从Spring Boot 2.1.0开始(2018年发布),TaskExecutionAutoConfiguration会注册一个名为applicationTaskExecutor的ThreadPoolTaskExecutor,核心线程8,最大线程Integer.MAX_VALUE,队列容量Integer.MAX_VALUE,拒绝策略AbortPolicy。看着像池化了,但队列无界意味着永远触发不了拒绝策略,只会一直堆内存。Spring Boot 2.7.18、3.0.0、3.2.0、3.5.0这条默认行为都没变。
Spring Boot 3.2.0起可以开spring.threads.virtual.enabled=true,applicationTaskExecutor会换成基于虚拟线程的SimpleAsyncTaskExecutor,3.5.0里仍然是默认关闭。想让拒绝策略真正生效,得自己定义Executor并显式设queueCapacity:
@Bean("reportExecutor")
public ThreadPoolTaskExecutor reportExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(4);
e.setMaxPoolSize(16);
e.setQueueCapacity(200);
e.setThreadNamePrefix("report-");
e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
e.initialize();
return e;
}
有个反直觉的点:ThreadPoolTaskExecutor即使设了maxPoolSize,队列没满之前也不会扩容,行为和原生ThreadPoolExecutor一致。之前有个项目queueCapacity设成10000、maxPoolSize设成64,压测时线程数始终是8,查了半天才发现卡在这一条上。
怎么确认是哪一类任务堆住了
自定义线程池一定要设threadNamePrefix,jstack里能一眼分辨出来:
jstack -l 12345 > jstack.log
awk -F'"' '/^"/ {print $2}' jstack.log | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn | head
某个前缀的线程数长期等于maxPoolSize、状态全是RUNNABLE且堆栈落在同一行代码,基本就能定位到具体任务。长期观测靠打点,把getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount()定时写进日志,或者接Micrometer把executor指标推给Prometheus,不用自己造轮子。
几个容易认错的变体
报错里是[Terminated]而不是[Running],说明线程池已经shutdown或shutdownNow还在被提交,多见于应用关闭期、Bean销毁顺序有问题。
newCachedThreadPool堆积时最终不是RejectedExecutionException,而是OutOfMemoryError: unable to create new native thread,因为每个平台线程默认占1MB栈,几千个就把内存吃光了。
CompletableFuture不带Executor参数时走ForkJoinPool.commonPool,并行度是CPU核数减1。之前遇到过一次全站变慢,查下来是commonPool被一批批量计算占满,同进程里所有CompletableFuture一起排队,这种必须显式传自定义Executor。
还有一类是submit()把异常吃掉。用submit()提交的任务,抛出的异常被封装进Future里,不调get()就永远看不到,日志上只表现为任务"莫名没执行"。排查时先确认提交方式,execute()会直接把异常打到日志。异步方法里主动加try-catch并记日志,比事后翻Future靠谱得多。
线程数给多少
没有通用公式。CPU密集型按核数或核数加1给;IO密集型要看等待比例,粗略按核数除以(1减去阻塞系数)估。稳一点的做法是先给一个有界队列(200到1000之间)+一个能观测的拒绝策略,压测时盯队列水位和单任务耗时,再回头调参数。接手过的一个服务把队列从Integer.MAX_VALUE改成500之后,超时请求反而少了,因为上游很快收到回压,不再无限等待。
数据截至2026年10月,Spring Boot版本参考2.1.0、2.7.18、3.0.0、3.2.0、3.5.0,行为在JDK 8到JDK 21上一致。