适用场景: 告警系统上报 CPU 使用率超过阈值, 需逐层定位至代码行级 前置知识: Linux 进程/线程模型, CPU 时间片调度, JVM 线程模型
一、为什么需要四层诊断模型
1.1 理论依据: 故障定位的"层级原则"
一个 CPU 告警可能由多个层面的问题引起, 每一层需要不同的工具来观测:
系统层 (kernel) → /proc/stat, vmstat — 看调度队列和 CPU 时间分配
进程层 (process) → top, /proc/<pid>/stat — 看进程级别的 CPU 消耗
线程层 (thread) → top -H, /proc/<pid>/task — 看线程级别的 CPU 消耗
代码层 (code) → jstack, perf, Arthas — 看具体的执行方法/指令
选择依据: 每一层只看上一层无法提供的信息。系统层告诉你"CPU 在忙", 但不告诉你是谁; 进程层告诉你"是 PID 172 在忙", 但如果你直接跳到代码层(比如盲目 jstack), 你拿到 30 个线程的栈, 却不知道该看哪一个——这就是为什么需要线程层: 先找到具体的线程 TID, 再精准定位。
1.2 工具选择矩阵
| 层面 | 工具 | 核心指标 | 为什么用它而不用其他 |
|---|---|---|---|
| 系统-时间分配 | top | us/sy/wa/st/id | 直观分解 CPU 时间去向, 一步锁定瓶颈类型 |
| 系统-调度队列 | vmstat | r, b, cs | 唯一能直接观测运行队列长度和上下文切换率的工具 |
| 进程 | top -p <pid> | %CPU, RES | 确认具体进程的资源占用是否持续 |
| 线程 | top -H -p <pid> | %CPU per thread | 将进程的 CPU 时间拆解到线程粒度 |
| 代码 | jstack <pid> | 线程栈, 线程状态 | 连接 OS 线程 ID 和 Java 方法调用栈的唯一桥梁 |
二、分层操作指导
2.1 第一层: 全局观测(top)
操作
top -bn1 | head -20 # 非交互式, 抓取单次快照
top -d 1 # 交互式, 每秒刷新
输出详解
第一行 — 系统概览
top - 14:32:05 up 120 days, 3:15, 2 users, load average: 8.50, 6.20, 3.80
| 字段 | 数据来源 | 含义 | 异常判据 |
|---|---|---|---|
up 120 days | /proc/uptime | 系统持续运行时间 | 重启后 uptime 归零, 排查时注意是否是重启后冷启动压力 |
load average | /proc/loadavg | 过去 1/5/15 分钟的平均可运行线程数 + 不可中断睡眠线程数 | 持续 > CPU 核数 = 系统过载; 三个数字递增说明问题在恶化, 递减说明在恢复 |
load average 的深层含义:
Linux 内核在每次时钟中断时统计以下三类线程的数量, 累加后取指数移动平均:
TASK_RUNNING— 正在运行或在运行队列中等待的线程TASK_UNINTERRUPTIBLE— 等待磁盘 I/O、内核锁等的线程(不可被信号打断)
因此 load average 实际上衡量的是"想要 CPU 时间的线程数"——包括正在用的和排队等的。它不是 CPU 使用率, 而是 CPU 的"饥饿指数"。
第三行 — CPU 时间分解(最核心)
%Cpu(s): 95.2 us, 3.1 sy, 0.0 ni, 1.0 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
| 指标 | 统计来源 | 含义 | 升高时的排查方向 |
|---|---|---|---|
us | 用户态指令执行时间 | 应用程序代码消耗 | → top -H + jstack 定位方法; 或 perf top 定位热点函数 |
sy | 系统调用 + 内核代码执行时间 | 内核态消耗 | → strace -c -p <pid> 统计系统调用频率和耗时; 大量 futex → 锁竞争; 大量 epoll_wait → 网络 I/O |
wa | 至少一个 I/O 在进行时 CPU 空闲的时间 | 等待磁盘/网络 I/O | → iostat -x 1 定位具体磁盘; iotop 定位进程; 检查 swap si/so |
id | 完全空闲 | CPU 空闲 | 正常值应该 > 0; 持续为 0 说明 CPU 已打满 |
hi/si | 硬件中断/软件中断处理 | 中断处理消耗 | si 持续 >5% → 网络包处理密集; hi 突增 → 硬件问题 |
st | 虚拟机被宿主机"偷走"的时间 | 虚拟化环境 CPU 争抢 | 只在虚拟化环境中出现; >0 说明宿主机超卖或有 noisy neighbor |
典型场景判断速查:
| us | sy | wa | st | 诊断结论 |
|---|---|---|---|---|
| >80% | <10% | ≈0% | 0% | 应用代码 CPU 密集型计算 |
| >60% | >30% | ≈0% | 0% | 大量系统调用, 可能是锁竞争或频繁 I/O 操作(futex/read/write) |
| <30% | <10% | >40% | 0% | 磁盘 I/O 瓶颈, 某进程等待磁盘 |
| <30% | <10% | ≈0% | >20% | 虚拟化环境 CPU 被宿主机限制 |
| 正常 | 正常 | 正常 | 正常 | CPU 不是瓶颈, 考虑内存/网络/应用逻辑 |
进程列表关键列
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4823 tomcat 20 0 8.2g 4.1g 18m S 295.3 13.1 1245:32 java
| 列 | 含义 | 编号多核环境 |
|---|---|---|
%CPU | 进程的 CPU 使用百分比 | 可以超过 100% —— 295% 表示占用了约 3 个 CPU 核心 |
S | 进程状态 | R=Running, S=Sleeping, D=Uninterruptible sleep, Z=Zombie |
TIME+ | 进程累计 CPU 时间 | 如果这个数字增长极快, 说明进程长期占用 CPU |
RES | 实际驻留物理内存 | 结合 free 判断是否是内存问题 |
2.2 第二层: 调度队列验证(vmstat)
为什么需要这一步
top 回答"CPU 在做什么", 但不回答"CPU 是否够用"。这是运维中最常见的判断失误: CPU 使用率 90% 不一定需要扩容, 使用率 30% 也不一定就不需要。CPU 是否够用的唯一判据是: 有没有人在排队。
操作
vmstat 1 5 # 每秒采样一次, 共 5 次
vmstat 1 # 持续采样, Ctrl+C 停止
vmstat -s # 显示自系统启动以来的累计统计(看趋势)
输出详解
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
9 1 708096 2150400 4194304 8388608 0 0 12 150 35000 85000 94 3 2 1 0
procs — 进程调度指标(最重要的两列)
| 列 | 含义 | 数据来源 | 异常判据 |
|---|---|---|---|
r | 运行队列长度: 正在运行 + 等待 CPU 的进程数 | 内核调度器统计 | 持续 > CPU 核数 = CPU 瓶颈, 需要扩容或优化代码 |
b | 不可中断睡眠进程数: 等待 I/O、内核锁等的进程数 | 内核调度器统计 | 持续 > 5 = I/O 瓶颈或锁竞争严重 |
r 列的深层含义:
Linux 的 CFS(完全公平调度器)为每个 CPU 维护一个运行队列。r 是所有 CPU 运行队列上的任务总数。当一个 CPU 密集任务被调度器分配了时间片, 它从队列中移除; 时间片用完后重新入队。如果队列长度持续大于 CPU 核数, 意味着总有线程在等待 CPU 时间片——这就是用户感知到的"系统响应变慢"。
为什么不用 load average 替代 r?
load average 是平滑后的指数移动平均值, 且包含了不可中断睡眠进程(b), 无法区分"在等 CPU"还是"在等磁盘"。vmstat 的 r 列是瞬时裸数据, 且与 b 分开统计, 可以精准定位瓶颈类型。
system — 系统级活动指标
| 列 | 含义 | 异常判据 |
|---|---|---|
in | 每秒中断次数 | 突然大幅波动 → 检查硬件或网络 |
cs | 每秒上下文切换次数 | > 50000/核 = 线程过多或锁竞争严重 |
cs 的深层含义:
上下文切换涉及保存/恢复寄存器、刷新 TLB、切换页表, 每次切换消耗约 1-5μs。如果每秒切换 10 万次, 仅开销就占 10%~50% 的 CPU 时间。cs 高 + sy 高 → 锁竞争(大量 futex 系统调用); cs 高 + sy 正常 → 线程数过多, 考虑减少线程池大小或使用协程。
swap — 内存换页指标
| 列 | 含义 | 异常判据 |
|---|---|---|
si (swap in) | 每秒从交换区读入内存的 KB 数 | 持续 > 0 = 严重内存不足, 系统在从磁盘换回页面, 性能会急剧下降 |
so (swap out) | 每秒写出到交换区的 KB 数 | 持续 > 0 = 物理内存不足, 系统被迫将内存页面写出到磁盘 |
关键判断: si/so 都为 0 不代表内存无压力。swpd(已使用的 swap 总量) > 0 说明历史上发生过内存压力, 需要结合 free 命令进一步分析。
io — 磁盘 I/O 指标
| 列 | 含义 | 异常判据 |
|---|---|---|
bi | 每秒从块设备读入的块数(KB) | 持续 > 10000 → 大量读 I/O |
bo | 每秒写出到块设备的块数(KB) | 持续 > 10000 → 大量写 I/O |
注意: vmstat 的 bi/bo 是粗略指标, 看到异常后应使用 iostat -x 1 做进一步精确定位(wait, svctm, %util 等指标)。
2.3 第三层: 线程级分解(top -H)
理论依据: 进程与线程的关系
在 Linux 中, 线程是调度的最小单位。一个 Java 进程(对应 JVM 实例)内部有数十到数百个线程: 业务线程(Tomcat worker)、GC 线程、JIT 编译线程、RMI 线程等。进程级别的 %CPU 是所有这些线程的总和。top -H 将进程展开为线程列表, 每个线程独立一行。
操作
top -H -p <pid> -bn1 | head -20 # 非交互式, 一次快照
top -H -p <pid> # 交互式, 按 CPU 排序
# 在交互式 top 中, 按 f 选择排序列, 按 J 按 CPU 排序
输出解读
PID USER PR NI S %CPU %MEM TIME+ COMMAND
5021 tomcat 20 0 R 98.2 13.1 42:15 java
5022 tomcat 20 0 R 97.8 13.1 41:58 java
5023 tomcat 20 0 R 96.5 13.1 41:32 java
4823 tomcat 20 0 S 0.3 13.1 1245:32 java ← 主线程
| 观察点 | 含义 | 结论 |
|---|---|---|
3 个线程状态都是 R | 它们在运行队列中(正在执行或排队等 CPU) | 业务线程而不是 GC 线程 |
| 每个线程 %CPU ≈ 99.9% | 每个线程占满一个核心 | CPU 密集, 不是间歇性的锁等待 |
| 线程名(later via jstack) | 如果是 http-nio-* → Tomcat 请求线程; 如果是 GC task → GC | — |
| 主线程(PID=进程PID) %CPU ≈ 0% | 主线程负责调度, 不执行具体业务 | 正常 |
关键区分: 业务线程 vs GC 线程
GC 线程的 CPU 使用是间歇性的——GC 触发时短暂飙升, GC 结束后立即回落。如果 top -H 观察到持续的 100% CPU(持续超过 30 秒), 基本不是 GC。验证方法: jstat -gc <pid> 1000 观察 YGC/FGC 的频率和 FGCT 是否有同步的尖峰。
2.4 第四层: 代码行定位(jstack)
理论依据: OS 线程 ID 与 Java 线程的映射
JVM 的每一个 Java 线程(Thread 对象)在底层对应一个 OS 原生线程(pthread)。jstack 输出中的 nid(Native Thread ID)就是该原生线程在操作系统中的线程 ID (以十六进制表示)。top -H 显示的 PID (实际上是 TID, Thread ID) 是十进制的 OS 线程 ID。
映射关系: top -H 的 TID (十进制) = jstack 的 nid (十六进制)
操作
# 1. 从 top -H 获取线程 TID
TID=5021
# 2. 十进制转十六进制
printf "%x\n" $TID
# 输出: 139d
# 3. 导出线程栈
jstack <pid> > /tmp/jstack_<pid>.txt
# 4. 精确匹配目标线程
grep -A100 "nid=0x139d" /tmp/jstack_<pid>.txt
jstack 线程栈解读
"http-nio-8080-exec-15" ← 线程名 (Tomcat NIO 工作线程 #15)
#67 ← 线程编号 (JVM 内部)
daemon ← 是守护线程
prio=5 ← Java 线程优先级 (1~10)
os_prio=0 ← OS 线程优先级
tid=0x00007f8a0010b800 ← Java 层面的线程对象地址
nid=0x139d ← ★ OS 原生线程 ID (十六进制), 对应 top -H 的 TID=5021
runnable ← 线程状态
java.lang.Thread.State: RUNNABLE ← JVM 线程状态
at java.util.regex.Pattern$Curly.match(Pattern.java:4248)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4672)
at java.lang.String.replaceAll(String.java:2223)
at com.example.demo.utils.DataSanitizer.sanitize(DataSanitizer.java:21)
at com.example.demo.controller.ReportController.exportReport(ReportController.java:30)
...
读栈方向: 从下往上。最下面是入口(HTTP 请求进入), 最上面是当前执行点(正则引擎内部)。
JVM 线程状态速查表
| 状态 | JVM 定义 | OS 层表现 | 排查意义 |
|---|---|---|---|
RUNNABLE | 线程在 JVM 中正在执行 | 可能在真正执行, 也可能在等 OS 分配 CPU 时间片 | CPU 高 → 计算密集, 看栈顶方法; CPU 不高 → 可能是等 OS 资源(network/socket accept) |
BLOCKED | 等待获取 monitor 锁(synchronized) | 睡眠, 等待锁释放 | 看 waiting to lock <0x...>, 然后搜索持有该锁的线程(locked <0x...>) |
WAITING | Object.wait() / LockSupport.park() 无超时 | 睡眠 | 自行 wait 不算异常; 但大量 WAITING 在同一对象 → 线程泄漏 |
TIMED_WAITING | sleep(ms) / wait(ms) / parkNanos() | 睡眠, 定时唤醒 | 通常是线程池空闲线程或定时任务, 正常; 但如果数量远超线程池配置 → 线程泄漏 |
如何识别正则回溯的栈签名
正常的方法调用在栈上是树状结构, 不会出现大量重复。正则回溯的特征是同样的几个类在栈上循环重复数十到数百次:
Pattern$Loop.match ←
Pattern$GroupTail.match │
Pattern$Curly.match │ 这 4 个类重复出现
Pattern$GroupHead.match │ 几十到几百次
Pattern$Loop.match ← 这是 NFA 引擎在尝试每一种分组合并
Pattern$GroupTail.match
Pattern$Curly.match
...
一旦看到这种重复模式, 立即可以确认是正则回溯导致 CPU 打满。
三、完整证据链模板
每次故障定位应产出如下格式的证据链:
[告警时间] 2026-06-10 14:30, node-03, CPU > 90% 持续 5 分钟
[Step 1: 全局观测] top -bn1
→ us=95.2%, wa=0%, sy=3.1%, id=1.0%
→ 结论: 纯用户态 CPU 问题, 排除磁盘/虚拟化/系统调用风暴
[Step 2: 调度验证] vmstat 1 5
→ r=9~12 (> CPU 核数 8), b=0~2, si/so=0, cs=~85000
→ 结论: 运行队列严重堆积, CPU 过载, 排除 I/O 和内存换页
[Step 3: 线程定位] top -H -p 4823
→ 线程 5021/5022/5023 各占 ~97% CPU, 状态 R
→ 结论: 3 个业务线程 (http-nio-*) 是 CPU 消费者
[Step 4: 代码定位] jstack 4823 | grep "nid=0x139d"
→ DataSanitizer.sanitize(:21) → String.replaceAll()
→ 回溯签名: Pattern$Loop→GroupTail→Curly→GroupHead 循环
→ 结论: 正则回溯导致 CPU 打满
[根因] DataSanitizer.sanitize() 中 String.replaceAll() 使用回溯正则 (a+)+b,
3 个用户同时调用 Excel 导出接口时触发了灾难性回溯
[修复] 1.预编译 Pattern; 2.限制输入长度; 3.换用非回溯方式匹配
四、常见误区
| 误区 | 正确理解 |
|---|---|
| load average 高 = CPU 不够 | load average 包含了等待 I/O 的进程, 也可能是磁盘瓶颈。必须结合 vmstat r/b 拆开看 |
| us 低 = CPU 没问题 | wa 高时 CPU 同样饱和, 只是瓶颈不在计算而在 I/O; sy 高时是内核态瓶颈 |
| wa 高 = 磁盘坏了 | wa 表示"CPU 空闲但同时有 I/O 进行中", 如果 CPU 忙于处理其他进程, wa 可能很低但磁盘依然繁忙 |
| r 列偶尔高一下没关系 | 对延迟敏感的服务(tomcat http handler), r>CPU核数 的每一秒都在增加 P99 延迟 |
| jstack 看到 RUNNABLE = 正常运行 | RUNNABLE 只是说线程可以被调度, 不代表正在执行——它可能在运行队列里排队等 CPU |
| jstack 一次就够了 | 高 CPU 场景应连续抓 3 次, 间隔 2 秒。如果三次栈顶都是同一个方法, 才是稳定热点; 如果在不同方法间跳动, 需要分析整体调用链 |
五、附录: 命令速查卡片
# === 全局 ===
top -bn1 | head -20 # CPU 宏观时间分布 + 最耗 CPU 进程
nproc # 查看 CPU 核数 (判断 load/r 是否过载的前提)
# === 调度 ===
vmstat 1 5 # CPU 调度队列 + 上下文切换 + 换页 + I/O
# === 线程 ===
top -H -p <pid> -bn1 | head -15 # 进程展开为线程, 找高 CPU 线程
# === 代码 ===
printf "%x\n" <tid> # 十进制 TID → 十六进制 nid
jstack <pid> | grep -A60 "nid=0x<hex>" # OS 线程 → Java 方法栈
# === 辅助 ===
jstack <pid> > /tmp/js.txt # 导出线程栈到文件, 避免频繁拉取
jstat -gc <pid> 1000 5 # GC 活动监控 (排除 GC 导致的 CPU 尖峰)