2856 字
约 9 分钟
14
S05-A04 · JVM 线上诊断的决策路径:从症状到证据
2024-12-31
2026-11-09

告警同时亮三条: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. 自测

  1. CPU 高与内存涨同时出现,按取证成本排序,你先取哪一份证据?
  2. 为什么"改完参数症状消失"不能算问题解决?
  3. 线程栈里 RUNNABLE 的线程一定在消耗 CPU 吗?如何快速排除?
  4. 什么时候类直方图不足以替代堆快照?
  5. 火焰图上"看不到"某个短方法,可能是哪两个原因?
  6. 容器里 -Xmx 与 MaxRAMPercentage 的选择取决于什么?
S05-A04 · JVM 线上诊断的决策路径:从症状到证据
https://www.clxhxhhr.top/posts/6092/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。