☰
CPU Load 高但使用率低?线程数与 CPU 核数匹配及线程池调优实战
2026/10/22 18:33:18 网站建设 项目流程

1. 从一条告警说起:CPU Load 高到底在说什么

凌晨两点,监控大屏上弹出一条告警:某核心服务的 CPU Load 持续 15 分钟超过阈值。值班的同事第一反应是“CPU 不够用了,赶紧扩容”,但登上机器一看,top里 CPU 使用率才 40% 出头,us和sy都不高,id还有一大截。这就很反直觉了——CPU 明明没跑满,为什么 Load 会飙到几十?

这个问题我在过去几年里被问过不下十次,每次排查下来,根因几乎都落在同一个地方:线程数和 CPU 的关系没理清楚。很多人把 CPU Load 等同于 CPU 使用率,其实这俩完全是两码事。Load 统计的是“正在运行 + 正在等待运行”的进程/线程数量,它反映的是系统的排队压力,而不是 CPU 的忙碌程度。一个线程如果卡在磁盘 IO、网络 IO 或者锁竞争上,它照样会被算进 Load,但它根本没消耗多少 CPU 时间片。

所以这条告警真正想问的,不是“CPU 够不够”,而是“谁在排队,为什么排队”。这篇文章我就把这次排查的完整思路拆开讲:从 Load 的计算原理,到线程数与 CPU 核数的匹配关系,再到线程池参数怎么定、怎么压测验证。适合正在做后端服务、中间件调优,或者被类似告警折腾过的同学参考。哪怕你只写过简单的多线程代码,看完也能建立起一套可复用的判断框架。

2. 先把概念掰开:Load、CPU 使用率、线程数三者到底什么关系

2.1 Load Average 的真实含义与计算口径

Linux 上的 Load Average 有三个值,分别是 1 分钟、5 分钟、15 分钟的平均值。它的本质是运行队列长度 + 不可中断睡眠任务数的指数移动平均。运行队列里装的是“就绪但还没轮到 CPU”的线程,不可中断睡眠(D 状态)装的是“卡在 IO 上、连信号都不响应”的线程。

这里有个关键点:Load 是按“任务数”算的,不是按“百分比”算的。一台 4 核机器,Load 等于 4 意味着每个核上刚好有一个任务在跑,系统处于满负荷但不排队的理想状态;Load 等于 8 意味着平均每个核上有两个任务在抢,有一半在等。所以判断 Load 高不高,必须先除以核数,得到“每核负载”。

我习惯用这个口径快速判断:

每核 Load 区间系统状态处理建议
0 ~ 0.7健康,有冗余正常观察
0.7 ~ 1.0接近饱和关注趋势,准备预案
1.0 ~ 2.0明显排队排查阻塞点
大于 2.0严重排队立即介入

注意这个表只是经验值,IO 密集型服务和 CPU 密集型服务的容忍度完全不同。一个做日志落盘的服务,Load 常年 1.5 但响应依然很快,因为大部分任务在 D 状态等磁盘,CPU 其实很闲。

2.2 CPU 使用率的统计维度:us、sy、wa、id 各代表什么

top里那行 CPU 百分比,拆开看是好几段:us是用户态时间,sy是内核态时间,wa是等待 IO 的时间,id是空闲时间,还有hi、si处理硬软中断,st是被虚拟化层偷走的时间。

很多人只看id低不低,其实wa高才是 Load 高的常见元凶。如果wa占到 30% 以上,说明 CPU 大量时间在等 IO,这时候加 CPU 核数基本没用,得去查磁盘、查网络、查下游依赖。反过来,如果us和sy加起来接近 100%,wa很低,那才是真正的 CPU 算力瓶颈,扩容或者优化算法才有意义。

我排查时有个固定动作:先看wa,再看us + sy,最后看 Load 和核数的比值。三步下来,方向基本就定了。

2.3 线程数与 CPU 核数的匹配逻辑

线程和 CPU 的关系,可以用一个生活场景类比:CPU 核数是收银台数量,线程数是排队结账的顾客。如果顾客全是“拿了商品直接结账”(纯计算任务),那收银台数量和顾客数量 1:1 时效率最高,多了反而要来回切换、增加管理成本。如果顾客里有大量“要去仓库取货再回来结账”(带 IO 的任务),那一个收银台可以同时服务好几个顾客,因为取货期间收银台是空闲的。

这就是线程数设置的核心原则:CPU 密集型任务,线程数约等于核数;IO 密集型任务,线程数可以远大于核数。具体大多少,取决于任务的“等待时间占比”。业界有个经验公式:

最佳线程数 = 核数 × (1 + 等待时间 / 计算时间)

比如一个任务计算耗时 10ms,等待下游响应 90ms,等待占比 90%,那 4 核机器上最佳线程数大约是 4 × (1 + 9) = 40。这个公式不是精确解,但能给你一个量级参考,避免拍脑袋定成 4 或者 400。

3. 线程池参数怎么定:从理论公式到落地配置

3.1 核心线程数、最大线程数、队列容量的三角关系

Java 的 ThreadPoolExecutor 有三个关键参数:corePoolSize、maximumPoolSize、workQueue。它们的协作逻辑是:任务先占核心线程,核心满了进队列,队列满了才创建非核心线程,直到达到最大线程数,再满就触发拒绝策略。

这里最容易踩的坑是队列容量设得太大。如果队列是无界的(比如 LinkedBlockingQueue 默认容量 Integer.MAX_VALUE),那maximumPoolSize永远不会生效,因为队列永远填不满。结果就是核心线程数成了实际并发上限,任务在队列里越堆越多,接口响应时间线性上涨,但 CPU 可能还很闲——Load 却因为大量线程在等锁或等结果而飙升。

我一般会这样配:核心线程数按公式估算,最大线程数设为核心的两倍左右,队列容量设成“能容忍的突发量”,比如核心线程数的 2 到 4 倍。这样突发流量来时能快速扩容,而不是全塞进队列。

3.2 不同任务类型的参数计算实例

假设一个 8 核机器上的服务,任务分两类。

第一类是纯计算,比如加解密、序列化。等待时间几乎为 0,那线程数就设 8 到 10,队列给个小容量比如 20,最大线程数 16。这样 CPU 能被吃满,又不会因为线程过多导致上下文切换开销。

第二类是调下游 HTTP 接口,本地计算 5ms,下游平均响应 45ms。等待占比 90%,按公式 8 × (1 + 9) = 80。但 80 个线程同时打下游,下游可能扛不住,所以实际要结合下游容量压测。我通常先设 40,压测看下游 RT 和错误率,再往上加。

任务类型核数计算耗时等待耗时理论线程数建议初值
纯计算810ms088~10
调下游85ms45ms8040
混合型820ms20ms1616~24

3.3 队列选型与拒绝策略的取舍

队列选型直接决定系统的“缓冲能力”和“响应延迟”。ArrayBlockingQueue 有界、内存连续,适合对延迟敏感的场景;LinkedBlockingQueue 吞吐高但默认无界,必须手动指定容量;SynchronousQueue 不存储任务,直接交给线程,适合高并发短任务,但要求最大线程数足够大。

拒绝策略我一般不用默认的 AbortPolicy(直接抛异常),而是用 CallerRunsPolicy,让提交任务的线程自己跑。这样能形成天然的背压,上游发现变慢了就会减少提交,而不是把任务丢掉。当然,如果业务不允许阻塞上游,那就得配合降级逻辑,记录被拒任务并返回兜底结果。

提示:队列容量和最大线程数要一起调。只调其中一个,往往解决不了问题,还会掩盖真实瓶颈。

4. 一次完整的排查实录:从告警到定位到验证

4.1 现场信息采集:top、vmstat、pidstat 怎么配合看

回到开头那次告警。我上机后的操作顺序是这样的:

第一步,top看整体。发现 Load 1 分钟 32,5 分钟 28,15 分钟 20,呈上升趋势。CPU 核数 16,每核 Load 约 2,确实偏高。但us只有 25%,sy10%,wa高达 40%。

第二步,vmstat 1看趋势。r列(运行队列)平均 20 左右,b列(阻塞进程)平均 12,wa稳定在 35 到 45 之间。说明大量线程卡在 IO。

第三步,pidstat -d 1看磁盘。发现某数据目录的读写 await 超过 200ms,明显异常。

第四步,jstack抓线程栈。统计发现 60 多个线程停在文件写入的write调用上,还有 20 多个在等数据库连接。

到这里结论就清楚了:不是 CPU 不够,是磁盘写入变慢导致线程堆积,进而推高了 Load。根因是那块盘的 IO 被另一个批量任务打满了。

4.2 线程栈分析:怎么从 jstack 里读出阻塞点

jstack的输出很长,但有几个固定套路。先搜java.lang.Thread.State,统计各状态的线程数。RUNNABLE 多说明在跑,BLOCKED 多说明锁竞争,WAITING 或 TIMED_WAITING 多说明在等资源。

然后看具体堆栈。如果大量线程停在同一个方法上,比如FileOutputStream.write,那瓶颈就在那。如果停在SocketInputStream.read,那就是下游响应慢。如果停在ReentrantLock.lock,那就是锁粒度太粗。

我那次抓到的栈里,有 60 多个线程的栈顶是FileChannelImpl.write,下面跟着业务代码的日志落盘方法。这就直接指向了磁盘 IO。

4.3 参数调整与压测验证:改完怎么确认有效

定位到根因后,做了两件事。短期把批量任务挪到低峰期,磁盘 await 降回 20ms 以内,Load 半小时内回落到 8。长期则优化了日志落盘策略:改成异步批量写,队列容量 2000,核心线程 2,最大线程 4,拒绝策略用 CallerRunsPolicy。

改完必须压测验证。我用压测工具模拟了 1.5 倍日常流量,观察三个指标:Load 每核是否低于 1,接口 P99 是否在 200ms 内,磁盘 await 是否低于 50ms。跑了 30 分钟,三项都达标,才算收工。

验证指标调整前调整后目标值
每核 Load2.00.6< 1.0
接口 P99800ms150ms< 200ms
磁盘 await220ms18ms< 50ms

5. 常见问题与排查技巧实录

5.1 Load 高但 CPU 闲,优先查什么

按这个顺序查,基本不会漏:先看wa,高就是 IO 问题,查磁盘和网络;再看b列,高就是阻塞任务多,查锁和连接池;再看线程数,如果线程数远超核数且大量 WAITING,就是线程池参数不合理;最后看是否有死循环或频繁 GC,用jstat确认。

5.2 线程数设大了反而更慢的原因

线程过多会带来三个开销:上下文切换、内存占用、锁竞争加剧。上下文切换可以用vmstat的cs列观察,如果cs超过 10 万且sy很高,说明切换太频繁。内存方面,每个线程默认栈 1MB,1000 个线程就是 1GB。锁竞争则表现为 BLOCKED 线程增多。所以线程数不是越大越好,要卡在“刚好能覆盖等待时间”的点上。

5.3 容器环境下 Load 和核数的特殊处理

容器里top看到的核数可能是宿主机的,不是容器的 CPU limit。这时候 Load 除以宿主机核数会严重低估压力。正确做法是看 cgroup 的cpu.cfs_quota_us和cpu.cfs_period_us,算出实际可用核数,再用 Load 除以它。另外容器里wa的统计也可能不准,要结合宿主机的 IO 指标一起看。

注意:容器环境排查 Load,第一步永远是确认“看到的核数是不是真实可用的核数”。

5.4 快速排查速查表

现象可能原因排查命令处理方向
Load 高,wa 高磁盘/网络 IO 瓶颈iostat、pidstat -d优化 IO 或错峰
Load 高,b 列高锁竞争或连接池不足jstack、连接池监控调锁粒度、扩连接池
Load 高,cs 高线程过多vmstat、jstack 统计降线程数
Load 高,us+sy 高真 CPU 瓶颈top、perf扩容或优化算法
Load 高,GC 频繁内存不足jstat、GC 日志调堆或查泄漏

6. 我在实际调优中踩过的几个坑

第一个坑是盲目扩容。早期遇到 Load 高就加机器,结果 IO 瓶颈没解决,新机器一样卡,白白浪费资源。后来养成习惯,先看wa再决定加不加 CPU。

第二个坑是线程池用默认值。Executors 工厂方法创建的线程池,要么无界队列,要么无界线程,生产环境基本不能用。必须手动 new ThreadPoolExecutor,把每个参数都想清楚。

第三个坑是忽略下游容量。线程数按公式算出来 80,结果下游只能扛 30 并发,一压测全超时。后来学乖了,线程数上限由“下游能承受的并发”和“本机等待时间”两者取小。

第四个坑是压测流量太理想。用均匀流量压测通过,上线后遇到突发流量还是出问题。后来压测会加突发场景,比如 10 秒内流量翻三倍,观察线程池和队列的反应。

这些经验没法从文档里直接抄,都是被告警叫醒几次之后攒下来的。核心就一句话:Load 是排队指标,不是算力指标,看到它高,先找谁在排队,再决定加什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询