线程池参数调优:corePoolSize 怎么设置
线程池参数调优:corePoolSize 怎么设置
目录
- 计算线程池大小的两个公式
- CPU 密集型 vs IO 密集型
- workQueue 该设多大
- 拒绝策略怎么选
- keepAliveTime 和 maximumPoolSize
- 一个完整的调优示例
- 监控与动态调整
- 小结
ThreadPoolExecutor 有七个参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。笔者已经提到过它们各自是什么、在任务提交流程里扮演什么角色(见笔者主页《Java 线程池:从参数到拒绝策略》)。本篇笔者来论述一下这些参数应该怎么去设置。
计算线程池大小的两个公式
设置线程池大小有一个经典公式,来自 Little’s Law 的变体:
CPU 密集型任务:
N = C + 1N 是线程数,C 是 CPU 核心数。多出来的一个线程是为了在某个线程因为页缺失或其他原因暂停时,CPU 不会闲着。
IO 密集型任务:
N = C × (1 + W/C)W 是等待时间(IO 阻塞时间),C 是计算时间。W/C 就是等待和计算的比值。
更直觉的写法是:
N = C × (1 + D)D = W/C,即阻塞比例。如果一个任务 80% 的时间在等 IO,20% 在计算,D = 4,线程数就是核心数的 5 倍。
为什么 IO 密集型需要更多线程?因为线程在等 IO 的时候 CPU 是空闲的,多开几个线程可以让 CPU 一直有活干。
CPU 密集型 vs IO 密集型
弄清楚任务是哪种类型,然后然后根据类型去设置参数。
CPU 密集型就是线程一直在算,几乎不等。比如图像压缩、加密解密、数学计算。这种任务开太多线程反而有害,因为 CPU 核心数是固定的,线程多了只是在频繁上下文切换,白白浪费时间。
IO 密集型就是线程大部分时间在等。比如数据库查询、远程 HTTP 调用、读写文件。线程发出一个请求就阻塞了,等结果回来才能继续。这种任务开多线程才有意义,因为等待的线程不占 CPU。
实际开发中,大多数 Web 后端服务都是 IO 密集型的:一个请求进来,查数据库、调接口、写缓存,每一步都在等 IO。
怎么判断任务类型呢?可以看 CPU 利用率。如果压测时 CPU 跑满了但 QPS 上不去,大概率是 CPU 密集型;如果 CPU 很闲但 QPS 上不去,大概率是 IO 密集型,线程都在等。
几个常见的参考值:
| 任务类型 | 阻塞比例 D | 线程数参考 | 举例 |
|---|---|---|---|
| 纯计算 | ≈ 0 | CPU 核心数 + 1 | 加密、压缩 |
| 轻度 IO | 0.5 ~ 1 | 核心数 × 2~3 | 本地缓存查询 |
| 中度 IO | 1 ~ 5 | 核心数 × 3~6 | 数据库 CRUD |
| 重度 IO | 5 ~ 20 | 核心数 × 6~20 | 远程 API 调用、文件上传 |
假设服务器是 4 核,业务是典型的数据库 CRUD(D ≈ 2),那 corePoolSize 可以设成4 × (1 + 2) = 12。
这些数值不是精确解,而是起点。需要根据实际压测结果来微调。
workQueue 该设多大
很多人用LinkedBlockingQueue的默认构造函数,不传容量。这意味着队列大小是Integer.MAX_VALUE,约 21 亿。这么大的容量来多少任务都能排得下。
看起来很安全,实际上是埋了一颗雷。队列无限大,任务永远不会被拒绝,意味着maximumPoolSize 永远不会被触发,线程池退化成了固定大小。如果队列里的任务越积越多,内存持续增长,最后就会导致内存溢出。
队列大小要设,而且要有界。设多大取决于两个因素:
1. 业务能接受多大的排队延迟。
假设每个任务平均执行 50ms,队列排了 1000 个任务,最后一个任务要等 50 秒才能执行。接口能接受 50 秒的响应时间吗?大概率不能。
最大可接受延迟(秒) ÷ 平均任务耗时(秒) = 队列最大长度如果最多接受 2 秒延迟,每个任务 50ms,队列长度就是2000 / 50 = 40。
2. 内存能撑多少任务。
每个任务对象占用的内存乘以队列长度,就是队列的内存开销。如果任务对象里带着大参数(比如上传的文件流),1000 个任务可能就是几百 MB。
综合考虑,一个常见的做法是:
// 队列大小 = 核心线程数 × 2 到 4intqueueCapacity=corePoolSize*2;newLinkedBlockingQueue<>(queueCapacity);核心线程数 12,队列就设 24~48。队列满了就触发临时线程,临时线程也满了就走拒绝策略。这样线程池的三个层次(核心线程 → 队列 → 临时线程)才能真正发挥作用。
拒绝策略怎么选
队列满了、临时线程也到上限了,新来的任务怎么办?Java 提供了四种策略,选哪种取决于你的业务对"丢任务"的容忍度。
默认用 AbortPolicy,抛异常。调用方能立刻知道任务被拒了,配合监控告警,你能第一时间发现线程池过载。大多数场景下这是最合理的选择——系统过载时,快速失败比默默排队好。
不能丢任务的场景用 CallerRunsPolicy。比如订单创建、支付回调这种关键业务,任务被拒绝后由提交线程自己执行。好处是不丢任务,坏处是提交线程被阻塞,上游调用方的响应时间变长。这是一种"用延迟换可靠性"的策略。
允许丢任务的场景用 DiscardPolicy。比如日志采集、埋点上报,丢几条不影响业务。但要注意:这个策略静默丢弃,不抛异常,如果你没有其他监控手段,任务丢了你都不知道。
只关心最新数据的场景用 DiscardOldestPolicy。比如实时价格推送、状态同步,旧数据留着没用,丢掉队列里最老的任务,腾出位置给新的。
| 策略 | 行为 | 是否丢任务 | 适用场景 |
|---|---|---|---|
| AbortPolicy | 抛异常 | 否 | 默认选择,配合告警 |
| CallerRunsPolicy | 提交者执行 | 否 | 关键业务,不能丢 |
| DiscardPolicy | 静默丢弃 | 是 | 日志、埋点等非关键 |
| DiscardOldestPolicy | 丢最旧的 | 是 | 只关心最新状态 |
keepAliveTime 和 maximumPoolSize
keepAliveTime控制临时线程空闲多久后被回收。默认值 60 秒,大多数场景不用改。
如果流量有明显的波峰波谷(比如白天高峰、凌晨低谷),可以把 keepAliveTime 调短一点,让临时线程在低谷期尽快回收,节省资源。如果流量比较平稳,60 秒就够了。
如果设置了allowCoreThreadTimeOut(true),核心线程也会在空闲超时后被回收。这个选项要慎用,如果流量有周期性波动,核心线程被回收后又得重新创建,会有额外开销。
maximumPoolSize的上限取决于系统资源。每个线程默认占 1MB 核外内存,加上 CPU 调度的开销,你需要评估服务器能承受的最大线程数。
一个实用的做法是:maximumPoolSize = corePoolSize × 2。这样峰值流量来的时候,线程池有翻倍的处理能力,但不会无限膨胀。
intcorePoolSize=12;intmaxPoolSize=corePoolSize*2;// 24longkeepAliveTime=60;不要把 maximumPoolSize 设得太大。线程太多,上下文切换的开销会抵消并发带来的收益。如果你发现需要设几百个线程才能扛住流量,应该先想想是不是架构层面有问题——是不是该加缓存、是不是该异步化、是不是该水平扩展服务实例。
一个完整的调优示例
假设你在做一个订单查询接口,部署在 4 核 8G 的机器上。接口的逻辑是:查一次数据库(约 20ms),调一次远程风控服务(约 80ms),再查一次缓存(约 5ms)。总耗时约 105ms,其中 IO 等待约 85ms,CPU 计算约 20ms。
第一步:算阻塞比例。
D = W / C = 85 / 20 ≈ 4第二步:算 corePoolSize。
N = C × (1 + D) = 4 × 5 = 20取 20 作为核心线程数。
第三步:设 maximumPoolSize。
maximumPoolSize = 20 × 2 = 40第四步:设队列大小。
接口最多接受 500ms 的排队延迟,每个任务 105ms:
queueCapacity = 500 / 105 ≈ 5取 10,留一些余量。
第五步:选拒绝策略。
订单查询不能丢任务,用 CallerRunsPolicy。
ThreadPoolExecutorexecutor=newThreadPoolExecutor(20,// 核心线程数40,// 最大线程数60,TimeUnit.SECONDS,// 临时线程存活 60 秒newLinkedBlockingQueue<>(10),// 有界队列,容量 10newThreadFactory(){privateintcount=0;@OverridepublicThreadnewThread(Runnabler){returnnewThread(r,"order-query-pool-"+count++);}},newThreadPoolExecutor.CallerRunsPolicy()// 拒绝策略);第六步:压测验证。
公式算出来的值是起点,不是终点。你需要用压测工具(比如 JMeter、wrk)模拟真实流量,观察三个指标:
- CPU 利用率:压测时 CPU 在 60%~80% 比较健康。如果 CPU 跑满了但 QPS 还上不去,说明线程数可能设多了,上下文切换太频繁。
- 队列堆积量:通过
executor.getQueue().size()监控。如果队列持续增长不下降,说明线程处理不过来,要么加线程要么加机器。 - 拒绝任务数:通过
executor.getRejectedExecutionCount()监控。如果拒绝数大于 0,说明线程池过载了。
根据压测结果微调参数,直到三个指标都在合理范围内。
监控与动态调整
线程池参数不是设完就不动了。业务在变,流量在变,参数也得跟着变。
线程池自带的监控方法:
executor.getPoolSize()// 当前线程数executor.getActiveCount()// 正在执行任务的线程数executor.getQueue().size()// 队列中等待的任务数executor.getCompletedTaskCount()// 已完成的任务数executor.getRejectedExecutionCount()// 被拒绝的任务数把这些指标接入你的监控系统(Prometheus、Grafana、SkyWalking 等),设好告警阈值。比如队列使用率超过 80% 就告警,拒绝任务数大于 0 就告警。
动态调整线程池大小:
ThreadPoolExecutor提供了运行时修改参数的方法:
// 运行时修改核心线程数executor.setCorePoolSize(30);// 运行时修改最大线程数executor.setMaximumPoolSize(60);不需要重启服务,改完立刻生效。这在应对突发流量时非常有用。不过频繁调整线程池大小本身也有开销,不建议做成自动伸缩,人工根据监控数据判断更靠谱。
小结
线程池参数调优的核心:先算后测,用测试数据去微调参数,用阻塞比例公式算出 corePoolSize 的初始值,根据业务对延迟的容忍度设队列大小,根据系统资源定 maximumPoolSize 的上限,然后压测验证、监控调优。参数设对了,线程池就可以稳定高性能运行;
