Java

线程池参数怎么按 CPU 密集和 IO 密集任务分别定?

给线程池定参数,真正难的不是记住 CPU 型和 IO 型各自的公式,而是看清任务时间花在计算、等待还是混合阶段,再据此约束线程数、队列和外部资源边界。

  • Java
  • 线程池
  • 并发
  • 性能调优
  • 工程实践
17 分钟阅读

线程池参数是 Java 工程里最容易被“口诀化”的东西之一。

很多人一聊线程池 sizing,很快就会落到几句熟悉的话:

  • CPU 密集型线程数接近核数
  • IO 密集型线程数可以开大一点
  • 队列尽量别太大
  • 不够就再加机器

这些话并不算错,但它们真正的问题是:一旦落到具体线上系统,常常不够用。

因为真实项目里的任务很少是纯粹、干净、单一的:

  • 所谓 CPU 密集,里面可能夹着锁和内存分配
  • 所谓 IO 密集,里面可能又混着序列化、聚合和重试
  • 同一个线程池里经常混跑多类任务
  • 线程池本身又和连接池、下游超时、队列、容器 CPU limit 绑在一起

结果就会出现一种很常见的情况:

  • 线程数看起来“按公式配了”
  • 线上表现还是慢
  • 一出问题,团队又开始继续加线程、加队列、改拒绝策略
  • 最后越调越像在碰运气

所以这篇文章想解决的,不是替你拍一个数字,而是把常见误区拆开:为什么同样写着“IO 密集”,有的线程池能放大,有的一放大就把数据库连接和下游一起拖垮。

我更建议把判断顺序记成一句工程话:

任务时间到底耗在计算还是等待、最先被打满的是 CPU、连接池还是下游并发,决定了线程数、队列和拒绝策略该怎么定。

这篇适合什么场景

先用这张表判断,你现在是在做参数设计,还是已经进入故障处理现场。

你现在看到的现象更像什么下一步
你正在给某类任务设计或重设线程池,想按 CPU / IO / 混合任务定参数这篇适合拿来定参数从这篇开始
线上已经 active 打满、queue 增长、reject 出现按线程池故障现场处理先看 线程池打满以后,应该先查队列、拒绝策略还是慢任务?
queue 不长,但任务还是推进得很慢先拆执行链到底卡在哪线程池队列不长但任务还是慢,常见瓶颈在哪里?
参数设计和数据库连接池容量总是互相打架直接按联合容量一起算线程池和数据库连接池的容量,为什么要一起做预算?
backlog 主要出现在异步任务、补偿和 MQ 消费先回到异步积压那条线异步任务越堆越多,问题常常不在异步本身

先把范围收在参数设计

这篇文章更适合这样的时机:你已经大致知道任务是什么类型,也知道系统最容易先卡 CPU、连接池还是下游,现在要把线程数、队列和拒绝策略落成一组像样的参数。

它不负责解释“线程池为什么已经打满”。

如果你现在还在看:

  • active 为什么已经顶满
  • queue 为什么一路涨
  • 任务到底慢在数据库、下游还是锁等待

那应该先做故障判断,再回来看参数设计。否则很容易把 sizing 文章当成急救手册,最后越调越乱。

一、为什么只背“CPU 密集 / IO 密集公式”经常不够

因为线上任务类型很少是教科书里的单一模型。

1. 纯 CPU 密集任务没那么多

典型纯 CPU 密集更像:

  • 加解密
  • 图像或文本计算
  • 大量规则计算
  • 大规模排序、聚合
  • 压缩 / 解压

这类任务的主要特征是:

  • 线程大部分时间都在 RUNNABLE
  • CPU 容易先打满
  • 多开线程不一定能提升吞吐

2. 纯 IO 密集任务也没你想得那么“纯”

很多所谓 IO 密集任务,其实长这样:

  • 查数据库
  • 调 RPC
  • 读 Redis
  • 做 JSON 转换
  • 回写状态

也就是说,它虽然大量时间在等 IO,但每一轮等待前后又夹着:

  • 序列化 / 反序列化
  • 对象转换
  • 业务逻辑判断
  • 日志和异常处理

所以它不是纯 IO,而是 等待为主的混合型任务

3. 最大的问题是:很多线程池根本混跑多类任务

例如同一个线程池里同时有:

  • 轻量接口异步任务
  • 重报表导出
  • 数据补偿任务
  • 定时扫描和回写

这时你去问“这个线程池是 CPU 密集还是 IO 密集”,问题本身就已经不够精确了。

二、第一步别急着定数字,先判断任务时间到底花在哪里

线程池 sizing 里,最该先拆开的不是“32 个线程够不够”,而是任务耗时结构。

更应该先回答的几个问题

  1. 单次任务有多少时间花在 CPU 计算?
  2. 有多少时间花在等待数据库、网络、锁或连接?
  3. 任务是稳定的单一类型,还是多类任务混跑?
  4. 吞吐瓶颈更容易先出现在 CPU、连接池、数据库,还是下游服务?

一个非常实用的分法

你可以先把任务粗分成三类:

1. CPU 密集型

主要时间花在本地计算,线程几乎一直在跑。

2. IO / 等待密集型

主要时间花在等待数据库、RPC、Redis、文件、锁等外部资源。

3. 混合型

任务前后有明显本地处理,但中间又有长等待,这类才是线上最常见的。

一旦分错类,后面的线程数和队列策略就很容易一起偏。

三、CPU 密集型线程池为什么不能贪多

CPU 密集型任务的核心约束,不在 queue,而在 CPU 本身。

这类任务的典型特征

  • CPU 使用率很容易先顶上去
  • top -Hp 里业务线程很活跃
  • 线程栈多次抓取稳定落在计算方法上
  • 吞吐不会随线程数线性增长

为什么线程数不能随便开大

因为这类任务里,多开线程通常带来的不是等比例收益,而是:

  • 更多上下文切换
  • 更多缓存失效
  • 更激烈的竞争
  • 更高的对象分配和 GC 压力

结果很可能是:

  • 线程数增加了
  • CPU 更满了
  • RT 反而更差
  • 吞吐并没明显提升

更稳的思路

CPU 密集型线程池线程数通常更接近:

  • 可用 CPU 核数
  • 容器 CPU quota 对应的实际核数
  • 再结合任务是否有少量阻塞、锁竞争和 GC 抖动做微调

重点不是背一个固定值,而是记住原则:

CPU 已经是第一瓶颈时,更多线程通常是在抢同一块算力。

四、IO / 等待密集型线程池为什么又不能无脑开大

很多人知道 CPU 密集不能乱开大线程,但会在 IO 密集型上走到另一个极端:

  • 既然线程在等,那就多开点线程

这句话也只对一半。

为什么 IO 型线程池可以比 CPU 型更大

因为线程大部分时间在等待:

  • 数据库返回
  • 下游 HTTP / RPC 响应
  • Redis / MQ / 文件 IO

在等待期间,CPU 没被真正占满,多开一些线程确实可能提高吞吐。

但为什么不能无限开

因为线程虽然在等,可它等待的资源是有限的。

例如:

  • 数据库连接池只有那么多连接
  • 下游服务并发承载有限
  • Redis、MQ、第三方接口都会有边界
  • 线程多了,超时、重试和上下文切换也会增加

如果线程池规模已经远大于这些外部资源边界,后果通常是:

  • 更多线程一起排数据库连接
  • 更多请求一起压垮下游
  • 队列延迟、超时率、重试率一起升高

也就是说,IO 型线程池不是只看“线程在等”,还要看:

线程到底在等哪个资源,而那个资源能承载多少并发。

五、真正线上最常见的是“混合型任务”,这时该怎么定

混合型任务才是最值得认真看的一类。

典型混合型任务长什么样

例如一个异步任务会:

  1. 查数据库
  2. 做对象转换
  3. 调一个下游服务
  4. 回写结果
  5. 打日志和埋点

它既不是纯 CPU,也不是纯 IO,而是:

  • 一部分时间在计算
  • 一部分时间在等待
  • 一部分时间又占着连接、锁和线程

这类任务最容易出什么问题

  • 线程数偏小时,等待阶段把吞吐压住
  • 线程数偏大时,数据库和下游又先扛不住
  • queue 一大,任务时效性和尾延迟开始变差

更稳的 sizing 思路

这类任务不该只从“线程数能开多大”想,而要从:

  • 任务平均等待比
  • 下游承载边界
  • 连接池容量
  • 任务时效要求
  • 容器 CPU 配额

一起反推。

换句话说,混合型线程池 sizing,本质上是一个多瓶颈协同问题,不是一个公式问题。

六、线程池 sizing 不能脱离这 4 个外部边界

这部分是最容易被漏掉的。

1. 容器 CPU limit / 机器核数

你给线程池配了 64 个线程,不代表服务真的有 64 个线程能有效跑。

如果容器只给了 2 核或 4 核:

  • CPU 密集型线程池就会非常容易过量竞争
  • 混合型任务里的计算阶段也会被压住

所以 sizing 一定要基于 实际可用 CPU,不是宿主机总核数的幻觉。

2. 数据库连接池

线程池开得再大,如果任务最终都要抢 20 个数据库连接,那真实并发上限往往先被连接池决定。

这就是为什么线程池参数不能脱离:

  • maximumPoolSize
  • DB 连接池大小
  • 单任务持有连接时长

一起看。

否则很容易出现:

  • 线程池 80 个线程
  • 连接池 20 个连接
  • 60 个线程都在等连接

看起来很忙,实际是在制造等待。

3. 下游服务承载能力

如果你的任务主要在调外部服务,那线程池上限很大程度上受制于:

  • 下游接口并发承载能力
  • 超时设置
  • 重试策略
  • 限流和熔断边界

否则线程池会把自己变成放大器,而不是提升器。

4. 任务时效性要求

有些任务是:

  • 允许慢一点,但不能丢
  • 有些是必须快,不能长时间排队
  • 有些是过期就没意义

这会直接影响:

  • 队列长度
  • 拒绝策略
  • 线程池隔离方式

所以 sizing 不只是“吞吐最大化”,还包括 失败方式和排队策略 的设计。

七、队列、拒绝策略和线程数必须一起定,不能只调一个

很多线程池调优失败,不是线程数算错了,而是只调了一个旋钮。

1. 线程数定太小

会出现:

  • 吞吐上不去
  • queue 涨很快
  • 等待时间主导总耗时

2. 线程数定太大

会出现:

  • CPU 竞争加剧
  • 数据库 / 下游压力被放大
  • 上下文切换和 GC 压力增加

3. queue 太大

会出现:

  • 问题被藏起来
  • 时效性变差
  • backlog 和内存成本上涨

4. queue 太小

会出现:

  • 很快进入拒绝
  • 短时抖动就暴露成失败

所以 sizing 更像是在平衡:

  • 线程数
  • queue 容量
  • 拒绝策略
  • 超时边界
  • 上游重试行为

而不是孤立看某一个参数。

八、一个更实用的 sizing 顺序

如果你在给某类任务设计线程池,我更建议沿着下面这个顺序推进。

第 1 步:按任务类型分池

不要把:

  • CPU 重计算
  • 普通 IO 调用
  • 批任务
  • 定时任务
  • 高时效异步任务

混在同一个线程池里 sizing。

第 2 步:估算任务的“计算时间 / 等待时间”占比

不用追求绝对精准,但至少要知道:

  • 更像 CPU 型
  • 更像等待型
  • 还是明显混合型

第 3 步:找真正外部上限

  • 可用 CPU
  • DB 连接池
  • 下游接口并发能力
  • 容器资源边界

第 4 步:再定线程数范围

  • CPU 型更接近可用核数
  • 等待型可以更大,但受外部资源边界约束
  • 混合型以最脆弱的外部瓶颈为主做反推

第 5 步:最后配 queue 和拒绝策略

  • 有时效要求的任务,queue 不宜过大
  • 可补偿任务可适度排队,但要控制 backlog
  • 失败方式必须和业务语义对齐

这个顺序的重点是:线程池不是独立系统,它只是整个资源模型里的一个门面。

九、一个典型例子:为什么按“IO 密集多开线程”反而会更差

假设某个异步线程池里的任务主要流程是:

  • 查数据库
  • 调下游接口
  • 更新数据库

团队判断这是 IO 密集,于是把线程从 32 提到 128。

结果线上出现:

  • 吞吐没怎么升
  • 数据库连接池 pending 大涨
  • 下游超时变多
  • 任务平均耗时反而更长

为什么?

因为这个“IO 密集”虽然成立,但真正稀缺的不是线程,而是:

  • 数据库连接数
  • 下游接口承载能力

线程变多之后,只是让更多任务更快地同时堵在这两个瓶颈前面。

这时真正该做的不是继续加线程,而是:

  • 限制并发度
  • 缩短单任务持有连接时间
  • 优化 SQL 和事务边界
  • 对下游做限速和超时收敛

十、FAQ:定线程池参数时最容易问错的几件事

1. CPU 密集型线程池是不是就等于“线程数 = 核数”?

别把它当成死公式。更接近的理解是:CPU 型线程池通常靠近实际可用核数起步,再结合容器配额、少量阻塞和 GC 抖动做小幅调整。

2. 只要是 IO 密集型,就应该把线程开得很大吗?

不行。线程确实可能大于 CPU 型,但最终能不能放大,要看数据库连接、下游并发、超时和重试会不会先失控。

3. 混合型任务最怕什么?

最怕只盯线程数,不看外部瓶颈。很多混合型任务真正先卡住的是连接池、慢 SQL、下游限流或事务持有时间。

4. 为什么很多线程池参数一改,数据库和下游先报警?

因为线程池只是把并发更快地推给下游资源。线程多了,不代表数据库连接、HTTP 连接或外部服务吞吐会同步变大。

5. 如果我还说不清任务到底偏 CPU 还是偏等待,怎么办?

先去补采样和监控,至少把 CPU 计算、数据库等待、网络等待和锁等待拆出来。任务类型没分清之前,任何公式都只是猜。

如果现场已经不是在定参数

如果你眼前已经不是“这个线程池该配多少”这种问题,而是下面这些现场,直接切过去更有效:

十一、如果你还想把这条线补完整

把任务类型和外部瓶颈大致分开以后,我一般会把后面的文章分成两组:

如果非要给一个继续往下读的顺序,我通常会这样排:

  1. 已经看到 active 打满、reject 出现,就先去《线程池打满以后,应该先查队列、拒绝策略还是慢任务?》。
  2. 如果线程没满,但数据库连接和下游先顶住,就去看连接池预算和慢 SQL 那组文章。
  3. 如果最后表现成异步积压或接口变慢,再回到异步积压和接口慢的链路文章。

十二、最后总结:线程池参数不是算出来的,是约束出来的

线程池 sizing 最容易把人带偏的地方,就是大家都想直接要一个数字。

但线上真正决定参数上限的,往往不是公式本身,而是几件更硬的约束:任务在算什么、要等什么、数据库和下游能扛多少、队列能接受多久的排队。

所以更值得记住的是:

先拆任务耗时,再找真实瓶颈,最后让线程数、队列和拒绝策略一起服务那个瓶颈。

这样定出来的参数,后面回看监控时才解释得通;线上一旦出抖动,也知道该先收哪一侧,而不是再次靠加线程碰运气。