开了几年 Java 后端,线程池这块我见过太多“能用就行”的写法了。业务一上线没事,流量一冲就出问题:CPU 打满、任务积压、日志刷屏、接口超时。每次复盘到最后,基本都是线程池参数拍脑袋定的。写个 newFixedThreadPool(10) 谁都会,但为什么是 10?队列为什么选 LinkedBlockingQueue?拒绝策略为什么用 AbortPolicy?如果你回答不上来,这篇文章就是给你准备的。
我会从 ThreadPoolExecutor 的核心参数出发,把线程数估算、阻塞队列选型、拒绝策略这几个最容易拍脑袋的环节,全部拆开讲清楚,最后用一个真实业务场景完整演算一遍。看完之后,你应该能做到:拿到任何业务需求,都能在十分钟内给出一套有理有据的线程池配置。
1. 为什么要自己定制:Executors 的快捷方法只是“能用”,不是“可控”
很多人习惯直接调 Executors 工具类,newFixedThreadPool、newCachedThreadPool 一行代码搞定。快捷方法的价值是帮你省掉配置过程,但代价是你完全失去了对线程池行为的控制。快捷方法在某些极端场景下,是真的会拖垮业务的。
1.1 快捷方法背后的隐患,不只是一行代码的事
拿 newFixedThreadPool 来说,它内部用的是无界队列 LinkedBlockingQueue,核心线程数等于最大线程数。这意味着:当核心线程全部繁忙时,新任务会无限积压在队列里。你设置了 10 个线程,理论上队列没有上限,10 万个任务全部排队等待。内存被撑爆只是时间问题,更麻烦的是接口响应越来越慢,最后客户端超时重试,重试又产生新任务,雪球越滚越大。
newCachedThreadPool 则是另一条极端路线,核心线程数为 0,最大线程数是 Integer.MAX_VALUE,队列用的是 SynchronousQueue。它不会积压任务,但线程可以无限创建。高并发尖峰一来,线程数可能瞬间冲到几千甚至上万,每个线程都要占内存和上下文切换的开销,机器很快就废了。
我见过一个真实事故:一个数据同步服务用了 newCachedThreadPool,某个大促活动触发批量回放,线程数直接飙到 8000 多,CPU 被打到 100%,最后只能重启。事后看线程 dump,大部分线程都在做无意义的切换等待。
1.2 反直觉的调度顺序:先排队,后加线程
定制线程池之前,你必须先理解 ThreadPoolExecutor 的完整调度路线,因为它的执行顺序和多数人猜的不一样。很多人以为线程不够了就先扩线程,队列满了才拒绝,但实际执行顺序是这样的:
- 提交任务时,如果当前线程数小于 corePoolSize,直接创建新线程执行任务。
- 如果线程数不小于 corePoolSize,先把任务丢进阻塞队列。
- 如果队列满了,且线程数小于 maximumPoolSize,才创建新线程执行任务。
- 如果队列满了,线程数也到 maximumPoolSize 了,才走拒绝策略。
注意第二步,只要核心线程还没满额,或者说核心线程繁忙,任务首选的去处是队列,而不是立刻扩容线程。这涉及一个关键设定:队列容量。如果你定义了一个核心线程数为 10、最大线程数为 20、队列容量为 100 的池子,那么前 10 个任务由核心线程执行,接下来 100 个任务进队列,再接下来 10 个任务触发扩容线程,从第 121 个任务开始才会触发拒绝策略。整个链路里的顺序,直接决定了你调参的思路。
这个机制的底层逻辑其实是生产者消费者模型:线程是消费者,队列是缓冲区。线程数解决的是吞吐能力,队列解决的是缓冲区能容纳多少“稍后再处理”的请求。所以定制线程池的核心命题就两个:消费能力开多大,缓冲区开多大,这两者匹配什么业务特性。
2. 线程数量计算:区分 CPU 密集与 IO 密集,别再用一个公式糊弄所有人
网上流传的“corePoolSize = CPU 核数 + 1”这类说法,只对纯计算场景成立,到了真实业务里基本不适用。因为大多数服务的线程不是一直在烧 CPU,而是在等数据库、等 Redis、等远程接口返回。这种等待行为决定了线程数上限可以远超 CPU 核数。
2.1 CPU 密集型任务:n+1 的适用范围和边界
当任务几乎不阻塞,一直在做计算、加解密、序列化这类操作时,线程数压到 CPU 核数附近最合理。典型公式是:
- corePoolSize = CPU 核数 + 1
多出来的这 1 个线程,主要用来应对偶发的内存页缺失或系统调用导致的短暂停顿,避免某个线程卡住时明明还有 CPU 空闲却没人用。
真实环境里,CPU 密集型线程池的核心线程数不建议超过 CPU 核数的 1.5 倍。超了之后收益是负的:上下文切换开销会超过多线程带来的并行收益,响应时间反而变差。
2.2 IO 密集型任务:等待时间占比才是关键
只要任务里有网络调用、磁盘读写、数据库操作,它就是 IO 密集型。IO 密集型的核心逻辑是:线程大部分时间在等待,等待的时候不占 CPU,所以可以用更多线程去填补等待间隙。
业界常用的估算公式是:
- 线程数 = CPU 核数 * (1 + 平均等待时间 / 平均计算时间)
举个例子,一个订单任务,调用外部支付接口平均耗时 800ms,业务计算只花 200ms,那么单核环境下建议线程数就是 1 * (1 + 800/200) = 5。四核机器就是 20。
这个公式的本质,是把 CPU 和 IO 的利用率做一个平衡。如果任务在 IO 上等得越久,线程数就可以开得越大。最典型的场景是爬虫和调用大量外部 API 的服务,一个线程等 5 秒,期间其他线程可以跑 20 个计算任务,总吞吐量就是靠这个拉起来的。
2.3 别忘了内存和下游系统的承受能力
上边公式给出的只是理论值,真正落地前还要问自己两个问题。第一,每个线程的调用栈和任务对象占多少内存?假设每个任务构建的对象链是 2MB,同时 1000 个任务在排队和运行,光任务对象就是 2GB。第二,下游系统能不能扛住?线程池扩容到 100 个,等于同时打出 100 个数据库连接,数据库连接池如果上限只有 50,超出的请求就全堵在数据库连接池上,数据库不会崩溃,但你自己的线程全卡死了。
所以线程数一定是一个结合业务指标、资源限制、下游承受能力的综合判断,不能只信公式。公式是出发点,不是终点。
3. 阻塞队列选型:定制线程池最容易出错、也最值得细抠的一环
线程池的队列选择,很多人的做法是“不选”,直接默认 LinkedBlockingQueue 拉倒。但队列恰恰是整个线程池定制里最影响系统行为的部分。队列容量和队列类型决定了:你的系统到底允许多少任务堆积,堆积之后是先扩容线程还是先拒绝任务。
3.1 四种常用队列的核心能力差异
Java 里常见的阻塞队列,和线程池的适配关系非常直接,我帮你梳理成一张表格:
| 队列类型 | 是否有界 | 特性 | 适用场景 |
|---|---|---|---|
| LinkedBlockingQueue | 默认无界,可指定容量 | 链表结构,吞吐较高,锁机制分离 | 通用任务队列,适合大多数业务 |
| ArrayBlockingQueue | 有界 | 数组结构,容量固定,单锁实现 | 需要严格控制队列长度的场景 |
| SynchronousQueue | 不存储任务 | 直接交接,不排队 | 希望任务立刻交给线程执行的场景 |
| PriorityBlockingQueue | 无界 | 支持优先级排序 | 需要优先处理重要任务的场景 |
每一个队列都对应一种设计意图。SynchronousQueue 的逻辑是“不设缓冲”,来一个任务就必须有一个空闲线程立即接手,所以配合它使用的线程池,最大线程数必须设得很大,否则提交任务会被立即拒绝。这正好是 newCachedThreadPool 的行为方式。
PriorityBlockingQueue 则适合那种“不是所有任务都平等”的业务,比如网络请求里需要优先处理支付回调,普通数据统计任务延后没关系。但要注意优先级队列是无界的,没有容量上限,传进来的每个任务都要占内存。
3.2 无界队列不是“永远不拒绝”,而是“等内存彻底兜不住”
无界队列最大的卖点是任务永远不会因为排不下被拒绝,网上很多教程甚至拿这一点当优点讲。但真的遇到高并发尖峰,无界队列就是定时炸弹。任务积压导致内存飙升,触发 Full GC,Full GC 让所有线程停顿,用户侧体验就是接口卡死。最坏的情况是任务继续提交,内存继续膨胀,最后 OOM,整个进程退出。你说这种设计是“优雅地削峰”,还是“把压力全线往后传导”?我认为绝大多数订单、支付、交易类业务,都不该用无界队列。
有界队列加拒绝策略,才是可控的方案。积压到上限就立刻触发拒绝,你可以在拒绝策略里做降级、告警或者重试,而不是让内存默默扛下所有。
3.3 “核心线程数-队列-最大线程数”三者必须联动看
我在定队列容量时,总是把三件事放在一起算:
- 任务到达速率是多少,每秒平均多少个?
- 下游或业务可接受的延迟是多少,任务最多能等多长时间?
- 单个任务平均执行耗时是多少?
假设每秒来 100 个任务,任务平均耗时 200ms,单线程每秒只能处理 5 个,那么用 10 个核心线程就能扛住每秒 50 个的吞吐,不够的部分就会进队列。如果业务能接受 5 秒延迟,队列容量设 500 就够了,再大的容量也只是把问题往后拖延。
比较反直觉的一点是:核心线程数和最大线程数的差距,不应该设置太大。如果核心线程 5、最大线程 100,队列容量 1000,那么大部分任务其实都在队列里排队,线程数永远只在 5 附近波动,扩容路径几乎用不上,因为队列挤满了才会扩容,但队列容量设得又很大,扩容自然就迟迟不触发。这个状态会让整个池子质量上不去,真实的处理能力远低于预期。关于这一点我自己的设计原则是:要么就把队列设小一点,让它更快触发扩容;要么就把核心线程设高一点,让真正干活的人足够多,不要指望队列帮你缓很久。
3.4 队列容量和任务积压时间的换算方法
队列容量不是拍脑袋定的,它是延迟预算的体现。我要估算一个任务可以接受在队列里等多久,用延迟预算除以单任务平均耗时,就能得到大概可排队的任务数量。
举一个具体例子。一个异步通知任务,业务要求从提交到执行完成的整体延时不超过 3 秒,任务自身平均耗时 500ms。如果线程池处于饱和状态,一个任务最多能在队列里等 2.5 秒。那么队列容量大致可以设定为:
- 队列容量 = 最大可排队时间 / 单任务平均耗时 = 2500 / 500 = 5
这个算法很粗糙,但能给你一个基准。真实生产环境,我会在这个基础上再加一半余量,然后用压测去验证,而不是直接照搬理论值。
4. 拒绝策略:所有配置的兜底,也是系统崩坏的最后一堵墙
很多人定完线程数和队列就完事了,拒绝策略随手选个默认,回头出了问题一头雾水。拒绝策略是线程池在超负荷状态下发出的最后一个信号,它决定了业务在极限情况下的表现。我一般用一句话提醒自己:线程池满的时候,才能看出一个系统的设计水平。
4.1 四种内置策略的实际表现差异
Java 内置了四种拒绝策略,我在这里把行为和冲击讲透:
- AbortPolicy:直接抛 RejectedExecutionException,这是默认策略。对调用方来说相当于同步失败,处理逻辑必须在上层捕获这个异常,否则请求就断在那个地方,日志不仔细看很难发现。
- CallerRunsPolicy:任务不再交给线程池,而是由调用方线程自己去执行这个任务。这是一种反向背压机制,调用方自己跑任务时,就腾不出手继续提交新任务了,提交速率自然就降下来。
- DiscardPolicy:静默丢弃任务,不报错不提示。对业务来说等于白丢,全链路没有任何感知,非常危险。
- DiscardOldestPolicy:丢弃队列里最早的任务,然后重新尝试提交当前任务。相当于给队列吐出一个空位,给新任务腾地方,适合对消息新鲜度敏感的业务。
4.2 CallerRunsPolicy 的代价:不是所有场景都能用
CallerRunsPolicy 看上去百利无一害,因为它至少没丢任务。但你要想清楚一件事情:调用方线程通常是用户请求线程,如果它被用来跑线程池的任务,那么原本它应该做的事就会停滞。举个例子,Tomcat 的工作线程本来要处理和响应这个 HTTP 请求,结果它跑去执行线程池里的通知任务,这个 HTTP 请求就会被长时间挂在那一环,用户看到的依然是不响应。
所以 CallerRunsPolicy 只适合那种能接受调用方线程被占用的场景,比如后台批处理、内部异步任务。面向外部请求的同步接口,用它往往是错的选择。
4.3 自定义拒绝策略的正确姿势:记录、降级、做可观测
生产环境我几乎不用纯内置策略,几乎总是写一个自定义的 RejectedExecutionHandler,把任务数据、队列状态、线程池实时指标全部存下来,再走降级逻辑。
核心思路是:拒绝不能是终点,必须变成可观测事件。下面是一个我在订单业务里用过的精简版本:
public class OrderRejectedHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof OrderNotifyTask) { OrderNotifyTask task = (OrderNotifyTask) r; // 降级:先落库,后续由补偿任务扫描处理 orderTaskRespository.save(task.getOrderId(), task.getPayload()); // 记录监控指标,触发告警 Metrics.counter("order.task.rejected").inc(); log.warn("order notify rejected, orderId={}, queueSize={}, activeThreads={}", task.getOrderId(), executor.getQueue().size(), executor.getActiveCount()); } else { // 其他类型任务,落到死信队列等待人工处理 deadLetterQueue.offer(r); } } }关键一点:日志里必须把订单号、任务内容、队列大小、活跃线程数全部打出来。以后排查积压和丢失问题时,这些信息能省掉你大量的时间。拒绝策略是系统的最后一道护栏,但它也应该是一条自我暴露问题的通道。
5. 一个真实案例:从零定制订单通知服务的线程池
理论讲了这么多,还是不够直观。下面我用一个真实项目里的场景,完整演算一遍我是如何从业务需求推导出线程池配置的。这个服务叫“订单通知服务”,职责是:
- 订单创建后,将订单信息推送给下游仓储系统和财务系统。
- 日订单量峰值约 50 万,晚高峰 20:00 到 22:00 集中爆发,峰值 QPS 约 500。
- 一次推送平均耗时 600ms,其中网络 IO 占 500ms,本地业务计算和序列化占 100ms。
- 业务要求延迟小于 2 秒,积压过多时允许丢弃非核心营销类通知,但支付成功通知一条不能丢。
5.1 第一步:先识别任务类型,确定线程数
推送任务绝大部分时间都在等下游响应的网络 IO,整体是 IO 密集型。机器是 4 核 CPU,单任务等待时间跨度和计算时间跨度分别是 500ms 和 100ms。按公式估算:
- 线程数 = 4 * (1 + 500 / 100) = 24
24 个线程,我并没有直接用,而是先看下游承受能力。仓储系统和财务系统都是自己的服务,连接池分别设了 20 和 15,那么线程池峰值并发最多 35,24 这个数字没有超过下游承受上限,可以用。
因为推送实时性要求高,希望高峰一上来线程就能快速顶满,我最终将 corePoolSize 定为 16,maximumPoolSize 定为 24。前 16 个线程常驻,剩下 8 个线程只有在队列堆积时再临时创建,用完回收。
5.2 第二步:用延迟预算决定队列容量
延迟要求 2 秒,线程处理一次任务耗时 600ms。理论可等待时长约为 2s - 0.6s = 1.4s,1.4s 内大约能排队 2 到 3 个任务。真实环境单队列上并发 500 QPS,除以 16 个核心线程后,每个线程大概分摊 31 个任务/秒。要让任务在队列里等的时间不超过 1.4 秒,队列容量大约需要:
- 队列容量 = 每秒每个线程多余的任务数 * 1.4 = (31 - 1/0.6) * 1.4 ≈ 27
上面的算法偏保守,因为线程平均处理时间会和到达速率动态平衡。我最后取了一个比较宽松但有界的安全值:队列容量 = 200。这个容量让瞬时流量不至于立刻打满拒绝,又不会积压太久导致用户等待超时。如果用无界队列,200 个任务很快突破到几千,系统延迟就彻底失控了。这里只有一个原则:有界,并且界要和延迟预算对得上。
5.3 第三步:确定拒绝策略并写降级逻辑
订单通知业务分两类,支付成功通知是必须保证送达的,营销通知可以容忍丢失。我写的拒绝策略就是前面讲的 OrderRejectedHandler:营销类直接丢弃并计数,支付成功通知落库,由离线补偿任务每 5 分钟扫描一次,补推失败的通知。
5.4 第四步:线程工厂命名,别让自己的排查难度翻倍
线程工厂这个细节,很多人会忽略。默认线程名是 pool-1-thread-1,线上出问题看线程 dump 的时候完全没法知道这个线程是哪条链路的。我强烈建议用自定义 ThreadFactory 命名线程:
ThreadFactory namedThreadFactory = 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(true); return t; } };线上排查线程池问题,第一步永远是找线程名。拥有了这类命名习惯,jstack 里看到 order-notify-pool-3 你就立刻知道这是推送任务线程,直接定位到业务链路,不用再翻代码确认线程池归属。
线程池完整代码我整理成下面这一段,可以直接复用:
ThreadPoolExecutor orderNotifyExecutor = new ThreadPoolExecutor( 16, 24, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), namedThreadFactory, new OrderRejectedHandler() ); orderNotifyExecutor.allowCoreThreadTimeOut(false);allowCoreThreadTimeOut(false) 是我刻意保留的。订单通知这种流量集中在晚高峰的业务,空闲期如果核心线程全部回收,高峰到来时需要重新创建线程,白白增加创建延迟和周期。让核心线程常驻,用 60 秒空闲回收只针对那 8 个临时扩充的线程,才是合理的折中。
6. 上线之后才是真正的开始:监控指标与动态调优
配置不是写完就完了。线程池运行一段时间后,各项指标会告诉你之前的假设是否正确。我每次上线线程池,都会在日志和监控平台里配上下面几项指标,缺一样我都觉得心里没底。
6.1 五个必须监控的指标
- activeCount:活跃线程数,能看出线程池是否经常打满。
- queue.size:队列积压数量,这个指标是最灵敏的压力信号,积压上涨说明处理能力跟不上。
- completedTaskCount / taskCount:完成率和丢弃率的计算基础。
- maximumPoolSize 实际触发的次数,判断扩容路径是否真的被用到。
- rejectedCount:拒绝次数,只要这个数值有波动,就说明高峰期已经摸到系统上限了。
队列积压我通常每天晚高峰会盯一次。如果积压长期保持在接近队列容量的高位,那么有两个应对方向:一是加大核心线程数,让消费者更多;二是优化任务本身的耗时,比如把一次下游调用拆成批量调用,减少单个线程的处理时长。优先做第二个,因为单纯加线程只会把压力转移给下游和机器,不是真正的优化。
6.2 支持运行期动态调整参数的 ThreadPoolExecutor
ThreadPoolExecutor 内置了几个运行期调整方法,非常实用:
- setCorePoolSize(int):在运行期动态调整核心线程数,范围必须小于等于 maximumPoolSize。
- setMaximumPoolSize(int):动态调整最大线程数,范围必须大于等于 corePoolSize。
- setRejectedExecutionHandler(handler):运行期替换拒绝策略。
我实际用过一次做应急处理。某天流量突然涨到平时的 5 倍,队列积压快速逼近阈值,当时我先通过运维脚本把 corePoolSize 从 16 升到 24,再配合流量入口的限流开关,五分钟内就把积压压回去了。整个过程不用重启服务,这是线程池定制里非常实用的一环。你在设计阶段就要考虑到,你的监控系统和运维工具链是否支持这种热变更,如果不支持,提前把动态调整的脚本写好,别等出事的时候再手忙脚乱。
6.3 根据监控数据做第二轮调参的流程
我一般会在上线后第 3 天和第 7 天各做一次完整复盘,方法很简单:
- 拉取高峰期的 activeCount、queue.size、completedTaskCount 数据。
- 如果 activeCount 一直顶着 maximumPoolSize,同时 queue.size 持续上涨,说明线程不够,需要加并发。但如果线程数已经接近下游数据库连接池上限,那就要回到应用层做削峰,而不是无脑加线程。
- 如果 activeCount 长期只有 30% 左右,queue.size 也基本是 0,说明参数保守了,可以适当缩减核心线程数,减少空转资源。
- 如果 rejectedCount 偶尔冒出几个,并且都是可丢弃的营销任务,说明配置在本轮是合适的,不需要改动。
给我最大的感触是:超配和缺配都有明确的特征,几乎不需要猜。一个能看监控、能调参数的线程池,才真正算得上“定制”完成。反过来说,如果你上线之后从没看过这些指标,那之前定参数用的所有公式和假设,都只是在赌运气。
6.4 最后留给大家的三个自查问题
你可以拿你这套线程池配置来自检一下:
- 队列是否无界?如果是,你有足够的机制保证任务量永远不会超过内存阈值吗?
- 拒绝策略是默认的吗?如果高峰期真的触发拒绝,你的业务方会收到什么反馈,补偿链路在哪里?
- 线程名是否可识别?现在线上随便抓一个线程 dump,你能不能在三秒钟内判断出它属于哪个业务?
这三个问题能答清楚,你的线程池基本就是“按需定制”了。答不清的,建议重新走一遍这篇文章的思路,把配置里每个数字的来历都写清楚。我再强调一次:线程池定制不是靠一个公式套出来的,而是靠业务需求、资源预算、和线上反馈反复校准出来的。把这套方法内化成习惯,你以后写线程池的速度和信心,都会和现在完全不一样。