告警同时亮三条:CPU 打到 90%、老年代曲线只涨不落、订单接口 P99 从百毫秒级掉到秒级。三个症状、一条时间线,先看哪个?答案不是"哪个最严重",而是"哪个证据最便宜、且最不容易被误读"。诊断次序由两个变量决定:取证成本(要不要停、要不要重启、要不要改代码)与判别力(这份证据能否把两种相反的原因分开)。次序错了的代价,是把一次可逆的排查升级成一次不可逆的变更。
1. 四类症状与各自的第一手证据
| 症状 | 第一手证据 | 采集方式 | 典型误判 |
|---|---|---|---|
| CPU 高 | 线程级 CPU 归属 | top -H -p + 栈对齐 |
把锁自旋当纯计算 |
| 内存涨 | 回收后水位是否回落 | GC 日志 / 类直方图 | 把缓存增长当泄漏 |
| 线程阻塞 | 栈状态分布与锁持有者 | 连续三次线程栈 | 把池等待当业务卡死 |
| 延迟毛刺 | 停顿时间分布而非均值 | GC 停顿 + 请求耗时直方图 | 用平均值掩盖 P99 |
四类症状的采集成本递增顺序是:线程栈 < 类直方图 < GC 日志(需预先开启)< 堆快照 < JFR 录制。先取便宜的那几份,往往已经足够把假设收敛到一条。
2. 采集顺序:把便宜的证据先取完
TS=$(date +%H%M%S); D=/tmp/diag-$TS; mkdir -p $D
PID=$(jps -l | awk '/app\.jar/{print $1}')
top -H -p $PID -b -n1 > $D/top.txt # 线程级 CPU 快照
jcmd $PID Thread.print -l > $D/t1.txt; sleep 5
jcmd $PID Thread.print -l > $D/t2.txt; sleep 5
jcmd $PID Thread.print -l > $D/t3.txt # 三次栈才有判别力
jcmd $PID GC.class_histogram > $D/histo1.txt; sleep 60
jcmd $PID GC.class_histogram > $D/histo2.txt # 两次直方图看增量
jcmd 全套子命令在目标 JDK 上以 jcmd <pid> help 的输出为准;不同版本子命令名有差异,动手前先核对该版本。
3. 工具选择的判据:能不能"现在、这台机器上"用
| 工具 | 侵入性 | 是否需重启 | 单次代价 | 生产可用 | 最擅长回答 |
|---|---|---|---|---|---|
jstack / Thread.print |
低(安全点停顿,毫秒级量级) | 否 | 极小 | 是 | 谁在等、谁持锁 |
jcmd GC.class_histogram |
低 | 否 | 一次 STW,与堆规模相关 | 是 | 哪类对象在涨 |
jmap -dump / GC.heap_dump |
高 | 否 | 全堆 STW,秒级到数十秒 | 谨慎 | 谁持有引用 |
| JFR | 低(默认模板开销在个位数百分比量级) | 需启动参数或 jcmd JFR.start |
磁盘与少量开销 | 是 | 事件时间线、分配、锁 |
| async-profiler | 中(采样,可关闭) | 否(attach) | CPU 采样低,分配采样较高 | 是(受内核 perf 限制) | 混合栈、分配热点、内联后视角 |
判据只有一句:能在不停机、不改配置的前提下拿到的证据,永远先拿。需要重启才能开的开关(-XX:+HeapDumpOnOutOfMemoryError、JMX 端口),属于"下次事故的准备工作",不是当下的手段。
4. 纪律:改参数之前先留快照
# 反例:先调 -Xmx / 先重启,症状消失,证据永久丢失
# 正例:先把四件套落盘,再谈变更
cp -a $D /data/incident/2026-xx-xx/ # 快照归档
jcmd $PID VM.flags > $D/flags.txt # 当前生效参数(含默认值与命令行覆盖)
jcmd $PID VM.command_line > $D/cmdline.txt # 真实启动命令行
改参数、重启、扩副本都能"让症状消失",但消失不等于修好。没有快照的处置,事后只能靠回忆复盘;下次同样的曲线出现,仍然从零开始。
5. 线程栈的读法
| 栈状态 | 真实含义 | 常见误读 |
|---|---|---|
RUNNABLE |
在跑,或在等 socket/native 返回 | 当作"正在消耗 CPU" |
BLOCKED |
等 monitor 进入锁 | 与"业务逻辑慢"混为一谈 |
WAITING |
无超时的 park/wait/join |
当作卡死 |
TIMED_WAITING |
带超时的等待、池取任务 | 忽略超时值异常 |
RUNNABLE 不等于在干活:读 socket、写文件、调 native 库都表现为 RUNNABLE。判别方法是把它和 top -H 的线程 CPU 对齐——同一 nid(十六进制)在两次采样里都高,才是真占 CPU。
池化线程的噪声要主动剔除:http-nio-*、lettuce-*、HikariPool-*、ForkJoinPool.commonPool-* 长期停在 park/take 属正常。三次栈里始终停在同一条业务帧的线程才是目标;只抓一次,会把恰好在等连接的线程当成元凶。
# 按状态聚合,先看分布再看个体
jcmd $PID Thread.print -l | grep -c "java.lang.Thread.State: BLOCKED"
awk '/nid=/{nid=$0} /State:/{print $2, nid}' $D/t1.txt | sort | uniq -c | sort -rn | head
6. 堆快照的成本与替代手段
| 手段 | 停顿 | 产物大小 | 何时用 |
|---|---|---|---|
| 类直方图 | 一次 STW,通常秒级以内 | KB 级文本 | 只需知道"哪类在涨" |
| 两次直方图差分 | 同上 | KB 级 | 区分"总量大"与"在增长" |
| 堆快照 hprof | 与堆使用量正相关 | 接近堆占用,GB 级 | 需要引用链 |
| 堆快照 + 修剪 | 同上,外加一次 GC | 略小 | 只想看存活对象 |
导出前先确认磁盘余量:写满磁盘的处置难度高于一次 OOM。能用类直方图回答的问题不要导堆;只有"谁持有引用"这类问题才必须 hprof。
7. 火焰图能回答什么、不能回答什么
| 能回答 | 不能回答 |
|---|---|
| CPU 时间在哪些调用路径上 | 为什么这段路径被调用(业务语义) |
| 是否存在意外的序列化/正则/日志格式化热点 | 采样窗口外的偶发尖峰 |
| 锁竞争在栈上的形态(配合锁模式) | 内联后已被合并的短小方法归属 |
| 分配压力集中在哪个类型(分配模式) | 精确调用次数与耗时 |
采样偏差是结构性的:采样频率越低,越偏向长方法;JIT 内联会把多层调用压成一帧,图上看到的是"内联后的边界",不是源码结构。用火焰图定位方向,用 JFR 事件或计数器确认量级。
8. GC 日志的最小字段集
# JDK 9+ 统一日志;上线前就开,事故时才有历史
-Xlog:gc*,gc+heap=info,gc+age=trace:file=/data/logs/gc-%p.log:time,uptime,level,tags:filecount=10,filesize=50M
最小可归因字段:本次停顿时长、停顿前后的堆占用、各代回收前后容量、晋升量、并发周期各阶段耗时。缺了停顿时长只能看频率,缺了回收前后占用就无法判断"是真占还是没到时机"。日志必须在事故发生之前就在写——这是唯一无法事后补采的证据。
9. 容器环境的三个陷阱
| 陷阱 | 表现 | 正确做法 |
|---|---|---|
-Xmx 写死且大于容器限额 |
被 OOMKilled 而非 OutOfMemoryError |
用 MaxRAMPercentage,并按容器限额核对 |
| 容器内看到的 CPU 数 | 线程池/并行度按宿主机核数放大 | ActiveProcessorCount 或按 cgroup 配额设定 |
| 采集工具不在镜像里 | jcmd/jstack 不存在 |
保留 JDK 而非 JRE,或用同版本工具 attach |
java -XX:MaxRAMPercentage=70 -XX:ActiveProcessorCount=4 -jar app.jar
# 核对实际生效值(含默认值来源)
jcmd $PID VM.flags | tr ' ' '\n' | grep -E "MaxHeapSize|ActiveProcessorCount|RAMPercentage"
MaxRAMPercentage 依赖容器内存限额被正确识别;识别不到时它会退回宿主机内存,结果比写死 -Xmx 更危险。上线前用 VM.flags 核对实际值,不要相信"应该生效"。
10. 基线,与从证据到假设的三步收敛
三步收敛:① 用分布排除症状类别(CPU 是集中在少数线程还是均摊?停顿是规律还是随机?)→ ② 用时间对齐确定因果候选(把栈、GC、耗时直方图放在同一时间轴上,看谁先动)→ ③ 用一次可逆实验证伪(降并发、关某条缓存、摘一个下游,看曲线是否跟着变)。
这三步都依赖基线:正常时段 CPU 分布长什么样、GC 周期多久一次、P99 的日常抖动幅度是多少。没有基线的"异常"只是感觉。基线不需要昂贵平台,一天一条汇总指标就能支撑 80% 的判断题。
11. 误判案例对照
| 现场现象 | 错误结论 | 反证手段 |
|---|---|---|
CPU 高、栈里大量 BLOCKED |
计算密集,扩容 | 看 top -H 是否集中在少数线程;扩的是锁不是 CPU |
| 老年代持续上涨后回落 | 内存泄漏 | 强制回收后是否回落、直方图差分是否只增不减 |
| 接口慢但应用自身耗时正常 | 应用代码问题 | 分段计时:下游 RTT、连接池等待、DNS、TLS 握手 |
| GC 频繁即调大堆 | 堆小 | 看晋升量与分配速率,大堆可能延长单次停顿 |
12. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
jcmd 报 AttachNotSupportedException |
用户/权限/JDK 版本不一致,或 attach 被禁用 | 以目标进程同一用户、同版本 JDK 执行 |
| 栈里全是 native 帧 | 采样只覆盖 Java 栈,或线程在 IO | 结合 perf/async-profiler 看内核与 native |
| 类直方图两次差异巨大但进程稳定 | 采样间隔跨过了一次 GC 周期 | 拉长间隔或改看 GC 日志的存活量 |
| hprof 导出失败 | 磁盘不足或路径不可写 | 先查余量,导出到独立数据盘 |
| 容器内工具看不到进程 | PID namespace 隔离 | 进容器执行,或用宿主侧同版本工具 attach |
13. 边界
| 场景 | 不必做的事 | 理由 |
|---|---|---|
| 一次性脚本、跑完即弃 | 搭诊断体系、开 JFR | 成本高于收益,日志足够 |
| 非生产环境复现不到 | 硬造线上条件 | 复现成本可能超过问题本身 |
| 缺基线 | 直接调参与扩容 | 先建基线,否则无法判断改动是否有效 |
| 已定位到单条 SQL/单次调用 | 继续全链路采样 | 证据已足够,继续采只增噪声 |
14. 自测
- CPU 高与内存涨同时出现,按取证成本排序,你先取哪一份证据?
- 为什么"改完参数症状消失"不能算问题解决?
- 线程栈里
RUNNABLE的线程一定在消耗 CPU 吗?如何快速排除? - 什么时候类直方图不足以替代堆快照?
- 火焰图上"看不到"某个短方法,可能是哪两个原因?
- 容器里
-Xmx与MaxRAMPercentage的选择取决于什么?