4. 线程池原理与调优:ThreadPoolExecutor核心参数、拒绝策略、线程池大小估算方法

线程池这东西,说白了就是「复用线程」。

我见过太多项目,每次来任务就 new Thread(),结果系统直接被打崩。你想想看,线程的创建和销毁是有代价的——上下文切换、内存分配、内核态切换,这些开销加起来,高并发下就是灾难。

所以 Java 1.5 开始提供了 ThreadPoolExecutor。今天我就带你把它彻底搞明白。

4.1 核心参数:七个参数,一个都不能少

ThreadPoolExecutor 的构造函数有七个参数。我习惯把它们分成三组来理解:

参数 含义 我的经验
corePoolSize 核心线程数 常驻线程,即使空闲也不回收
maximumPoolSize 最大线程数 线程池能容纳的线程上限
keepAliveTime 空闲线程存活时间 超过 corePoolSize 的线程空闲多久后被回收
unit 时间单位 一般用 TimeUnit.SECONDS
workQueue 任务队列 存放等待执行的任务
threadFactory 线程工厂 给线程起名字,方便排查问题
handler 拒绝策略 队列满了、线程数达到上限时怎么办

核心流程:

  1. 提交任务 → 检查当前线程数是否小于 corePoolSize → 是则新建线程执行
  2. 否则 → 尝试放入 workQueue 等待
  3. 队列满了 → 检查是否小于 maximumPoolSize → 是则新建线程执行
  4. 否则 → 执行拒绝策略

嗯,这里要注意:corePoolSize 不是硬性下限。如果设置了 allowCoreThreadTimeOut(true),核心线程也会被回收。我一般只在需要动态缩容的场景才开这个开关。

4.2 拒绝策略:四种选择,各有千秋

当线程池「扛不住」了,就会触发拒绝策略。JDK 内置了四种:

  • AbortPolicy(默认):直接抛 RejectedExecutionException。我个人觉得这策略太粗暴,线上环境慎用。
  • CallerRunsPolicy:谁提交的任务,谁自己跑。说白了就是让调用线程去执行。我在项目中遇到过,这个策略能起到「反压」效果——调用方变慢了,自然就不会疯狂提交任务了。
  • DiscardPolicy:悄悄丢弃任务,不抛异常。嗯,这个我基本不用,丢任务连个日志都没有,排查问题会疯掉。
  • DiscardOldestPolicy:丢弃队列里最老的任务,然后重新提交当前任务。适合允许「过期任务」被丢弃的场景。

我的建议:线上环境优先考虑 CallerRunsPolicy。它不会丢任务,还能自动限流。我曾经在一个支付系统中用过,效果很好。

4.3 线程池大小估算方法:别再拍脑袋了

「线程池设多大?」这是面试高频题,也是实际项目里最容易踩坑的地方。

我见过有人直接写 200、500,问为什么,答「感觉差不多」。嗯,这不行。

正确的估算方法,要看任务是 CPU 密集型还是 IO 密集型:

  • CPU 密集型:线程数 = CPU 核心数 + 1。为什么加 1?因为总有少量页面缺页、缓存未命中,多一个线程能利用这些「空档」。
  • IO 密集型:线程数 = CPU 核心数 × (1 + 等待时间 / 计算时间)。这个公式来自《Java 并发编程实战》,我用了很多年,很靠谱。

举个例子:

// 假设 CPU 是 8 核
// 任务计算耗时 10ms,IO 等待耗时 90ms
// 那么:8 × (1 + 90/10) = 8 × 10 = 80
// 所以线程池大小建议设为 80

注意:这个公式只是起点。实际项目中,我建议先按公式算出一个值,然后做压测,观察 CPU 利用率、响应时间、吞吐量,再微调。我曾经在一个网关项目中,公式算出来是 64,压测后发现 48 效果更好——因为线程太多反而增加了上下文切换开销。

4.4 核心知识体系

下面这张图,是我自己总结的线程池调优全景图:

线程池调优核心知识体系 核心参数 • corePoolSize:常驻线程数 • maximumPoolSize:最大线程数 • keepAliveTime:空闲存活时间 • workQueue:任务队列 • threadFactory:线程工厂 拒绝策略 • AbortPolicy:抛异常 • CallerRunsPolicy:调用者执行 • DiscardPolicy:静默丢弃 • DiscardOldestPolicy:丢弃最老 估算方法 • CPU密集型:Ncpu + 1 • IO密集型:Ncpu × (1+W/C) • 压测验证:观察CPU/响应时间 • 动态调整:根据负载变化 调优流程:公式估算 → 压测验证 → 微调参数 → 监控告警 常见坑点 • 队列选型:LinkedBlockingQueue 无界队列会导致线程数永远达不到 maxPoolSize • 线程命名:不设置 threadFactory,排查问题时只能看到 pool-1-thread-1

4.5 实战建议:我踩过的坑

最后分享几个我实际项目中踩过的坑:

  • 队列选型要谨慎:LinkedBlockingQueue 如果不设置容量,默认是 Integer.MAX_VALUE。这意味着任务会无限堆积,线程数永远达不到 maxPoolSize。我曾经在一个消息处理系统中用了无界队列,结果内存直接爆了。
  • 一定要给线程命名:用 threadFactory 给线程起个有意义的名字,比如「order-process-%d」。这样出问题时,jstack 一看就知道是哪个线程池在搞鬼。
  • 监控不能少:线程池的 activeCount、queueSize、completedTaskCount 这些指标,建议都暴露到监控系统。我习惯用 Micrometer 或者直接写个定时任务打印日志。
  • 动态调整:setCorePoolSize 和 setMaximumPoolSize 可以在运行时调用。我建议在管理后台留个接口,方便线上紧急调整。

一句话总结:线程池调优没有银弹。先按公式估算,再压测验证,最后持续监控。别偷懒,也别迷信某个固定值。

可可精品资料库