☰
线程池参数、执行流程与拒绝异常排查实战
2026/10/9 3:31:40 网站建设 项目流程

线程池这块,很多人有一种错觉——觉得会用new ThreadPoolExecutor(...)就算掌握了。可真到线上某个服务突然报RejectedExecutionException,最慌的往往就是那群把核心参数配置背得滚瓜烂熟的人。我自己就被半夜的报警电话叫起来过,日志里清清楚楚写着java.util.concurrent.RejectedExecutionException,盯着 corePoolSize、maximumPoolSize、workQueue 这几个参数看了半天,却怎么也解释不了“线程池明明没满,任务为什么被拒了”。

那次之后我才意识到,线程池的参数不是“填数字”,而是“做权衡”。它每一次扩容、排队、拒绝,背后都是一整套设计逻辑。而且绝大多数人踩的坑,其实都集中在两个点上:一是没搞懂 execute 的执行顺序,二是阻塞队列选得过于随意。今天这篇,我想把线程池从参数语义、执行流程、队列选型到线上排查完整串一遍。内容不深奥,但都是能直接拿去用的经验,适合刚接触线程池的开发者,也适合被线上问题折腾过几轮的老手。

1. 线程池不是“线程复用”这么简单:它到底在限什么

先聊一个容易错的地方。很多教程讲线程池,开口就是“省去频繁创建线程的开销,提高响应速度”。这话没错,但只是表面。线程池真正的价值,是把“并发量”变成了一个可配置、可观测、可控制的指标。

设想一个没有线程池的应用:请求进来了,new Thread去处理,处理完就丢。在低流量下确实没问题,但一旦流量上来,线程数量会跟着请求数量一路飙升。线程本身要占用内存,切换线程要消耗CPU,当几百个线程同时抢CPU时,系统吞吐量不升反降,响应时间开始急剧变长,最后整个进程可能因为资源耗尽而挂掉。

线程池做的,就是给“同时执行的任务数量”设了一个天花板,让系统在极限流量下仍然保持可控。比如maximumPoolSize=16,就意味着不管有多少请求涌进来,同时干活的最多只有16个线程,剩下的任务要么排队,要么按策略拒绝。这个“拒绝”听上去残酷,但比把系统拖垮要好得多。

这里就要引出核心参数配置的本质了。你配置的每一项,都是在回答一个问题:你希望这个系统在压力下呈现出什么行为?是宁可让请求多等一会儿,也不丢弃任何任务?还是能丢就丢,优先保住核心接口的响应速度?没有任何一组参数是“标准答案”,只有“符合业务预期”的答案。

这就是为什么线程池的参数看起来不难,但实际配置起来很容易翻车。因为它不是一个纯技术问题,而是一个业务策略问题。我见过不少团队,线程池参数是从网上抄来的,连RejectedExecutionHandler都用的默认值,从来没人想过:这个池子满了之后,业务上到底应该怎么办?

所以,与其把线程池当作一个“线程复用工具”,不如把它理解为“并发配额管理器”。它同时管了三样东西:并发执行的上限、任务等待的缓冲空间、以及超出后的处理策略。这三样分别对应 maximumPoolSize、workQueue 和 RejectedExecutionHandler,也是后面要展开的核心。

1.1 线程池和连接池、内存池:同为“池”,但控制逻辑完全不同

不少人拿连接池来类比线程池,认为都是“从池里取一个资源,用完再还回去”。概念上有点像,但控制逻辑其实差别很大。

连接池、对象池这类池子,资源是相对独立的,拿一个就是独占一个,数量上限就是资源上限。线程池里放的是线程,线程是要跑任务的。它不只决定“有多少资源”,还决定“这些资源怎么分配去执行不同的任务”。换句话说,线程池的工作不只是“给资源”,还包括“调度”。

举个例子,连接池满了一般就意味着“需要更多连接”或“连接没释放”,线程池满了则可能有很多原因:任务真的太多了、单个任务执行时间变长了、队列太小了、或者线程被外部IO阻塞了。所以排查线程池问题,不能只盯着线程池本身看,还要看下游系统的负载情况。这一点在后面讲排查案例时还会重点提到。

1.2 为什么核心参数配置这么容易踩坑:它不是一个“点”,而是一组联动

还有一个让线程池难掌握的客观原因:它的几个参数是联动变化的,而不是独立的。

比如,很多人单独理解 corePoolSize 是“常驻线程数”,maximumPoolSize 是“最大线程数”,workQueue 是“排队容量”,听起来都懂。但你一旦把它们放在一起,会发生什么?当核心线程全部忙碌时,新任务是先入队,还是直接开新线程?这个顺序很多人没仔细想过。如果队列容量很大,maximumPoolSize 可能一辈子都用不上;如果队列容量为0,只要核心线程满了,新任务就会立刻尝试创建新线程。

参数之间的这种耦合,导致“调整一个参数,可能引发完全意想不到的连锁反应”。这也是为什么同样一套配置,在A服务跑得好好的,直接复制到B服务就出事——因为两个服务的任务耗时、流量峰值、下游系统的处理能力都不一样。

所以我接下来的内容,会先帮你把参数一个个讲透,再讲它们组合之后的行为。要知道为什么踩坑,先得知道水底下的地形。

2. 核心参数的语义逐个拆解:六个参数背后都有代价

ThreadPoolExecutor 的构造方法里,最常用的那个有六个参数:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

逐个说,但重点放在它们“为什么是这样”上。

2.1 corePoolSize 和 maximumPoolSize:不是“最小最大线程数”的关系

教科书上常讲 corePoolSize 是最小线程数,maximumPoolSize 是最大线程数。这其实不够准确,更准确的说法是:corePoolSize 是“我们希望平时保留的线程数”,maximumPoolSize 是“在压力下最多能扩张到的线程数”。

当任务到来时,如果当前线程数小于 corePoolSize,会直接创建核心线程去执行任务。核心线程在任务执行完后不会销毁,会一直存活等你给它新任务。

当线程数已经等于 corePoolSize,但仍有新任务进来时,任务会优先丢进队列。只有当队列也满了,线程池才会尝试把线程数扩展到 maximumPoolSize。也就是说,maximumPoolSize不是设置在那里就会被用满的,它只在“核心线程忙 + 队列已满”这个组合条件下才会触发。

两个参数常见的坑有两个:

第一个是把 maximumPoolSize 设得非常大,觉得这样能扛住突发流量。可是如果队列容量也很大,线程池根本没机会扩张到那个值。你以为自己设置了 200 个线程,实际同一时刻可能只有 8 个在干活,其他任务全在队列里排队。

第二个是把 corePoolSize 也设得很大,比如 CPU 是8核,corePoolSize 直接填64。最终结果是,即使平时请求不多,也会有64个线程在池子里空转。线程空转也有代价,每个线程要占栈内存(默认约1MB),CPU 还要处理它们的上下文切换。

线程数到底怎么定?我试过比较多的经验公式是:

  • CPU 密集型任务:核心线程数约等于 CPU 核数 + 1
  • IO 密集型任务:核心线程数约等于 CPU 核数 × 2,或者更精确一点,CPU 核数 × (1 + 等待时间 / 计算时间)

注意这只是起点,不是终点。真实的线程数要靠压测去修正,因为 JVM 的底层调度、任务对锁的竞争、GC 停顿都会影响实际最优值。

2.2 keepAliveTime:非核心线程的“存活倒计时”

keepAliveTime 控制的是非核心线程空闲多久后被回收。默认情况下,核心线程是“永久存活”的,不会因为空闲被回收。只有调用了allowCoreThreadTimeOut(true),核心线程才会也适用这个超时时间。

这个参数在配置里经常被忽略,大家随手填个 60 秒就完事。但它在流量有明显波峰波谷的服务里作用很大。比如说白天高峰期需要20个线程,晚上流量低下来,如果 keepAliveTime 设得太长,那十几个非核心线程会一直占着资源;如果设得太短,比如10秒,晚上没问题,但第二天早高峰流量一来,线程池需要频繁地创建线程、销毁线程,一样有开销。

我习惯的做法是,先按业务的“波谷持续时长”来估:如果低峰期持续几分钟以上,keepAliveTime 可以设成 2~5 分钟,让非核心线程在低峰期慢慢退场,也不至于高峰一波动就反复重建。如果服务流量比较平稳,这个参数反倒不重要了。

2.3 workQueue:真正的缓冲水位线

很多人在配置时只把队列当成一个“放任务的地方”,实际上它才是决定线程池行为模式的关键。

workQueue 的类型和容量,直接影响了任务在“排队”和“开新线程”之间的分界。比如用的是无界队列LinkedBlockingQueue,那么任务永远不会触发maximumPoolSize扩张,线程池实际上退化成“固定大小线程池 + 无限缓冲”,风险是任务堆积可能导致内存暴涨。用的是SynchronousQueue,队列根本存不了任务,任务要么被核心线程处理,要么直接开新线程,要么被拒绝,线程池的弹性会非常明显。

队列容量本身,也是一个权衡:队列越长,任务等待越久,但拒绝的概率越低;队列越短,任务被快速分发给线程,但超载时拒绝得更快。

我见过一个很典型的崩溃现场:某服务把队列设置为Integer.MAX_VALUE,也就是new LinkedBlockingQueue()无界队列。平时流量不高没问题,一次营销活动直接把几百万个任务塞进队列,内存直线飙升,最后 OOM。线程池反而成了隐形炸弹。

关于队列如何选,后面专门有章节展开,这里先记住一个原则:除非你非常清楚自己在干什么,否则不要用无界队列。

2.4 threadFactory 和 handler:排查故障时最容易被忽略的两位

threadFactory 用来设置线程池创建线程的方式。默认的工厂创建出来的线程名字格式是pool-1-thread-1,在只有一个线程池的服务里还行,一旦有多个线程池,出了问题你都不知道异常是从哪个池冒出来的。

这里我强烈建议配置线程名:

ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "order-notify-pool-" + seq.getAndIncrement()); t.setDaemon(false); return t; } };

线程名写清楚业务含义,排查问题时配合jstack一眼就能定位。

handler 则是线程池满之后的“应急预案”。JDK 自带了四种策略:

策略行为适用场景
AbortPolicy(默认)直接抛出 RejectedExecutionException适合让调用方立刻感知并处理异常
CallerRunsPolicy在调用者线程里执行被拒任务适合做背压,让上游放慢速度
DiscardPolicy静默丢弃任务适合可丢弃的任务,比如日志
DiscardOldestPolicy丢弃队列里最老的未执行任务适合追求最新数据、允许丢弃旧任务

很多人一直用默认的 AbortPolicy,没意识到线上高峰期一个异常抛出来可能导致调用链雪崩。用什么策略取决于业务,但至少要“明确选过”,而不是“不知道有这回事”。

3. execute() 的执行顺序:一个反直觉的设计,也是一大半坑的根源

这次要把源码的逻辑直接摆出来。ThreadPoolExecutor.execute()做的事情,简化一下就是这个顺序:

public void execute(Runnable command) { int c = ctl.get(); // 情况一:线程数少于核心线程数 if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } // 情况二:线程数已达到核心线程数,尝试入队 if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (!isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } // 情况三:入队失败,尝试创建非核心线程 else if (!addWorker(command, false)) reject(command); }

这里最反直觉的一点是:核心线程满了之后,新任务不是立刻创建新线程,而是先尝试入队。只有队列也满了,才会去创建非核心线程。

为什么 Java 的设计者要把“入队”放在“创建新线程”前面?因为线程是比内存对象更稀缺的资源。创建一个线程,意味着要分配栈、注册到调度器、参与上下文切换。如果任务堆一点就让线程数往上长,那线程池和裸用new Thread也就没什么区别了。用队列缓冲任务,远比无限扩展线程数要廉价得多,而且可控得多。

但这套逻辑在实际使用中带来了两个非常常见的坑。

3.1 坑之一:队列选太大,弹性线程成了摆设

服务里配置了corePoolSize=8,maximumPoolSize=100,queue=LinkedBlockingQueue(10000)。看起来是“最多能扩到100个线程”,逻辑上没毛病。但根据 execute 的顺序,只有当队列也满了,线程数才会超过8。也就是说,要等队列攒够10000个任务,线程池才开始扩容。

日常流量下,根本攒不到那么多任务,于是这100个线程始终是摆在那里。等到流量真的大到能填满10000个队列时,任务已经排队排到超时了,业务上早就崩了。

这种配置的结果就是:线程池的“弹性”永远没被用到,它实际上退化成一个固定8线程的池子 + 一个巨大的缓冲队列。解决方向是明确自己想用“队列缓冲”还是“线程弹性”,二者往往只能优先一个。

3.2 坑之二:被拒绝的不一定都是“任务太多”

还有一类的坑,是单元任务耗时变长,导致线程占用时间远超预期。假设 corePoolSize=8、queue=100、maximum=16,本来一个任务耗时20毫秒,后来因为下游数据库慢,耗时变成200毫秒,那么线程的“占有时间”涨了10倍。这意味着每个线程能处理的单位时间任务数大幅下降,队列迅速被填满,然后拒绝开始发生。

从监控上看,可能并发量并没有明显涨多少,只是任务耗时变长了。这时候你要是只盯着线程池参数去调,把 maximumPoolSize 从16调到32,问题也许会暂时缓解,但下游如果已经接近极限,加大并发只会让数据库更慢,形成恶性循环。

所以排查线程池问题,永远要先问一句:任务是变多了,还是变慢了?这两个方向的解法完全不同。

4. 阻塞队列选择:从 LinkedBlockingQueue 到 SynchronousQueue 的实战对比

阻塞队列是线程池参数的灵魂。这一节直接对比四种常用队列,并给出适合的业务场景。

队列类型是否可有界特性适合场景
LinkedBlockingQueue默认无界,可设容量链表结构,FIFO,吞吐量较好大部分异步消费场景,但一定要显式设容量
ArrayBlockingQueue有界数组结构,FIFO,可指定公平锁需要严格控制队列长度的场景
SynchronousQueue不存任务无缓冲,任务必须立即被线程拿走需要快速弹性扩容,不希望任务堆积
PriorityBlockingQueue无界按优先级出队任务有优先级区分,但注意低优先级可能饿死

有界队列和无界队列的区别,不只是一个“容量数字”,而是决定了线程池在极端情况下是“优雅排队”还是“溢出失控”。接下来我讲两个实际踩过的场景。

4.1 无界队列吞掉弹性的那一幕

之前有一个内部系统,负责接收前端的异步消息,线程池用的是:

new ThreadPoolExecutor( 4, 20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), // 无界队列 namedFactory("msg-handler-pool"), new ThreadPoolExecutor.CallerRunsPolicy() )

表面上看 core=4、max=20,弹性充足。但因为队列无界,所有任务在核心线程忙的时候全部进了队列,线程数永远停留在4,maximumPoolSize=20 就是个摆设。随着任务堆积,队列越来越大,内存占用不断上涨。因为CallerRunsPolicy作为拒绝兜底也没机会触发——无界队列永远不会“满”,自然也不会拒绝。

最后改成了new LinkedBlockingQueue<>(200),并调整了 corePoolSize,让流量突增时线程能真正扩上去,同时设置allowCoreThreadTimeOut(true)让低峰期线程数自动回落。这个过程让我意识到,参数配置不是“填得越大越稳”,而是要让每一项都在自己的“应该起作用”的区间里。

4.2 SynchronousQueue 的反直觉之处

SynchronousQueue 是一个不存任务的队列。用它的线程池,任务不会被缓冲,而是直接尝试交给一个线程。如果核心线程空闲,就交给核心线程;如果核心线程都忙,就直接尝试建新线程;如果线程数已经到了 maximumPoolSize,就触发拒绝策略。

这个队列的优点是响应快,任务不会被积压。但坑也比较隐蔽:如果 maximumPoolSize 设置得不够大,高峰期的任务会立刻被拒绝,几乎没有缓冲余地。很多人第一次用 SynchronousQueue 时,以为它和“无界队列”一样能扛,结果高并发下大量请求直接 RejectedExecutionException。

所以在选 SynchronousQueue 之前,要想清楚业务是否真的允许“快速拒绝”。它适合的是那种宁可拒绝也不愿意让任务排队的场景,比如即时推送、实时计算。

4.3 常见的参数组合参考

我根据自己的经验,列几种常见参数组合,可以参考但不要照抄:

场景组合思路
常规异步处理,允许少量排队core=4,max=8,queue=LinkedBlockingQueue(200),AbortPolicy
高并发且任务耗时可预测,希望弹性扩容core=8,max=16,queue=SynchronousQueue,CallerRunsPolicy
需要控制内存,避免任务堆积core=8,max=8,queue=ArrayBlockingQueue(100),DiscardOldestPolicy
对延迟敏感,宁可丢弃也不排队core=按CPU核数,max=core×2,queue=SynchronousQueue,DiscardPolicy

配置完之后,必须通过压测验证。线程池参数不是“配好就行”,而是要在一个流量波峰一个波谷的真实曲线里反复调。

5. 一次 RejectedExecutionException 的完整排查链路

理论讲再多,不如看一次真实事故的排查过程。下面这个案例是几年前我参与排查的一个订单通知服务。

5.1 现象:先别急着改参数,先确认异常类型

某天晚上订单服务开始频繁报错,堆栈提示:java.util.concurrent.RejectedExecutionException: Task ... rejected from java.util.concurrent.ThreadPoolExecutor。

很多人的第一反应是:并发量太高了,把线程池改大一点。但如果先冷静下来,第一条线索就藏在异常本身里:它被拒的时候,采用的是 AbortPolicy,所以异常赤裸裸地抛给了调用方。如果用的是 CallerRunsPolicy,可能系统只是变慢,但不会直接报错。

接下来,我做了两件事:

  1. 看这个线程池配的是什么队列。当时配置是core=8,max=16,queue=LinkedBlockingQueue(1000)。
  2. 看拒绝发生的时间点,以及当时的监控指标。

5.2 取证:用 jstack 和线程池统计信息定位

登录服务器,第一步是用jstack抓线程快照。因为我们给线程池配了清晰的命名规则,可以很快过滤出这个线程池有哪些线程,每个线程在干什么:

"order-notify-pool-1" #24 daemon prio=5 os_prio=0 cpu=123.42ms java.lang.Thread.State: TIMED_WAITING (parking) at java.util.concurrent.locks.LockSupport.parkNanos at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire ... at java.sql.DriverManager.getConnection

连续抓了几次,发现这个线程池的线程大多卡在数据库连接获取上。再配合监控平台看指标:activeCount在拒绝发生时已经持续停在 16,queueSize一直在 1000 附近打满,taskCount还在持续上升,completedTaskCount增长缓慢。

到这里,原因基本浮出水面:不是任务变多了,而是单个任务变慢了。数据库连接池被打满,订单通知任务里拿不到连接,每个线程都在等待,线程被长时间占住,队列很快塞满,新任务只能被拒绝。

5.3 修复:调整参数只是表面,核心是解决下游耗时

如果这时候只是把线程池的 maximumPoolSize 从 16 调到 32,会发生什么?线程是多了,但数据库连接池还是那么点,32 个线程全部去抢有限的连接,等待更久,线程池线程占用率会继续升高,情况只会更糟。

我们当时的修复分了三步:

  1. 先把数据库连接池的压力降下来。排查发现慢 SQL 和一批未加索引的查询,优化之后,单任务的耗时从 200ms 附近降回 30ms 左右。
  2. 调整线程池参数,让core=8,max=12,queue=ArrayBlockingQueue(200)。队列容量缩小,是因为如果下游再次变慢,我们希望系统更快暴露问题,而不是让1000个任务在队列里堆积造成更大的延迟。
  3. 把拒绝策略从默认的 AbortPolicy 换成 CallerRunsPolicy。这样万一真的出现短时超出容量,调用方线程会帮忙执行任务,起到天然背压的作用,也不会直接抛异常导致调用链断裂。

验证阶段,我们用压测脚本模拟了和当晚一样的流量曲线,观察了四个指标:拒绝次数归零、队列水位保持在安全区间、线程活跃数不再打满、P99 延迟回落到基线。

这次排查给我最大的体会是:线程池的异常往往是“果”,不是“因”。你可以通过调参暂时压制症状,但如果下游系统的能力不恢复,线程池迟早会用另一种方式再次报警。

最后再分享一个我现在一直沿用的实践:每个线程池都要有名字、有指标监控(线程活跃数、队列长度、拒绝次数、任务耗时),调整参数时一次只改一个变量,改完压测对比前后曲线。踩过几次坑之后你会发现,线程池最危险的地方不是“不会配”,而是“配完没人管、没人看”。它就像一个被丢在角落的限流器,平时不声不响,等到流量真正起来的那一天,才会告诉你之前的配置到底合不合理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询