线程池参数调优:corePoolSize 怎么设置

线程池参数调优: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% 的时间在等 IO20% 在计算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线程数参考举例纯计算≈ 0CPU 核心数 1加密、压缩轻度 IO0.5 ~ 1核心数 × 2~3本地缓存查询中度 IO1 ~ 5核心数 × 3~6数据库 CRUD重度 IO5 ~ 20核心数 × 6~20远程 API 调用、文件上传假设服务器是 4 核业务是典型的数据库 CRUDD ≈ 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 到 4intqueueCapacitycorePoolSize*2;newLinkedBlockingQueue(queueCapacity);核心线程数 12队列就设 24~48。队列满了就触发临时线程临时线程也满了就走拒绝策略。这样线程池的三个层次核心线程 → 队列 → 临时线程才能真正发挥作用。拒绝策略怎么选队列满了、临时线程也到上限了新来的任务怎么办Java 提供了四种策略选哪种取决于你的业务对丢任务的容忍度。默认用 AbortPolicy抛异常。调用方能立刻知道任务被拒了配合监控告警你能第一时间发现线程池过载。大多数场景下这是最合理的选择——系统过载时快速失败比默默排队好。不能丢任务的场景用 CallerRunsPolicy。比如订单创建、支付回调这种关键业务任务被拒绝后由提交线程自己执行。好处是不丢任务坏处是提交线程被阻塞上游调用方的响应时间变长。这是一种用延迟换可靠性的策略。允许丢任务的场景用 DiscardPolicy。比如日志采集、埋点上报丢几条不影响业务。但要注意这个策略静默丢弃不抛异常如果你没有其他监控手段任务丢了你都不知道。只关心最新数据的场景用 DiscardOldestPolicy。比如实时价格推送、状态同步旧数据留着没用丢掉队列里最老的任务腾出位置给新的。策略行为是否丢任务适用场景AbortPolicy抛异常否默认选择配合告警CallerRunsPolicy提交者执行否关键业务不能丢DiscardPolicy静默丢弃是日志、埋点等非关键DiscardOldestPolicy丢最旧的是只关心最新状态keepAliveTime 和 maximumPoolSizekeepAliveTime控制临时线程空闲多久后被回收。默认值 60 秒大多数场景不用改。如果流量有明显的波峰波谷比如白天高峰、凌晨低谷可以把 keepAliveTime 调短一点让临时线程在低谷期尽快回收节省资源。如果流量比较平稳60 秒就够了。如果设置了allowCoreThreadTimeOut(true)核心线程也会在空闲超时后被回收。这个选项要慎用如果流量有周期性波动核心线程被回收后又得重新创建会有额外开销。maximumPoolSize的上限取决于系统资源。每个线程默认占 1MB 核外内存加上 CPU 调度的开销你需要评估服务器能承受的最大线程数。一个实用的做法是maximumPoolSize corePoolSize × 2。这样峰值流量来的时候线程池有翻倍的处理能力但不会无限膨胀。intcorePoolSize12;intmaxPoolSizecorePoolSize*2;// 24longkeepAliveTime60;不要把 maximumPoolSize 设得太大。线程太多上下文切换的开销会抵消并发带来的收益。如果你发现需要设几百个线程才能扛住流量应该先想想是不是架构层面有问题——是不是该加缓存、是不是该异步化、是不是该水平扩展服务实例。一个完整的调优示例假设你在做一个订单查询接口部署在 4 核 8G 的机器上。接口的逻辑是查一次数据库约 20ms调一次远程风控服务约 80ms再查一次缓存约 5ms。总耗时约 105ms其中 IO 等待约 85msCPU 计算约 20ms。第一步算阻塞比例。D W / C 85 / 20 ≈ 4第二步算 corePoolSize。N C × (1 D) 4 × 5 20取 20 作为核心线程数。第三步设 maximumPoolSize。maximumPoolSize 20 × 2 40第四步设队列大小。接口最多接受 500ms 的排队延迟每个任务 105msqueueCapacity 500 / 105 ≈ 5取 10留一些余量。第五步选拒绝策略。订单查询不能丢任务用 CallerRunsPolicy。ThreadPoolExecutorexecutornewThreadPoolExecutor(20,// 核心线程数40,// 最大线程数60,TimeUnit.SECONDS,// 临时线程存活 60 秒newLinkedBlockingQueue(10),// 有界队列容量 10newThreadFactory(){privateintcount0;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 的上限然后压测验证、监控调优。参数设对了线程池就可以稳定高性能运行