服务变慢,第一反应是重启;重启把现场清掉了,也把证据清掉了。但更常见的浪费是反向的:把不属于 JVM 的问题拖进 JVM 排查,采了两天栈和堆,结论是下游一个接口超时。
定位的第一步不是取证,而是归属判定:这个现象归 JVM 管吗?判定错,后面所有证据都在解释一个不存在的原因。
1. 归属四问,任一为"否"就先改道
| 问 | 判据 | 否 → 去哪 |
|---|---|---|
| 是否所有实例同时变慢 | 单实例异常→本机资源或该实例代码 | 单实例:查本机 CPU/磁盘/邻居 |
| 资源指标是否真的异常 | CPU、内存、IO、网络是否有一项偏离基线 | 都正常:查等锁、等外部、池耗尽(§2) |
| 现象是否可复现 | 固定条件能再次出现 | 不可复现:先补观测,别硬猜 |
| 时间是否与某次变更对齐 | 发布、配置、流量、依赖版本 | 对齐:先回滚验证,再谈深挖 |
四问全过,才值得动线程栈与堆。第 4 问最省钱:多数"JVM 疑难"其实是同一时间窗的一次发布或一次依赖升级。
2. 响应慢而 CPU 不高:只有三类
| 类型 | 判别证据 | 定位入口 | 常见误判 |
|---|---|---|---|
| 锁等待 | 多数线程 BLOCKED 且栈顶同一把锁 | 连续栈 + 锁持有者 | 当成慢 SQL |
| 外部依赖慢 | 出站调用耗时、连接池排队 | 依赖侧指标 + 池指标 | 当成 GC |
| 线程池耗尽 | 活跃线程打满、队列堆积、拒绝计数 | 池的队列长度与拒绝数 | 当成 CPU 不足 |
三类的共同点:CPU 看起来闲着,请求却排着队。区别在于排队发生在自己进程内的锁、别人家的服务、还是自己的池。
PID=$(jps -l | awk '/app\.jar/{print $1}')
# 归属判定只要三样:线程状态分布、池指标、出站耗时
jcmd $PID Thread.print > t1.txt; sleep 5; jcmd $PID Thread.print > t2.txt
grep -c 'java.lang.Thread.State: BLOCKED' t1.txt t2.txt
jcmd $PID Thread.print -l | grep -A3 'pool-' | head -40 # 池线程的栈顶
3. 证据按"要不要停"分档,先取零成本的
| 档 | 证据 | 是否停顿 | 典型耗时 | 判别力 |
|---|---|---|---|---|
| 零 | 指标、访问日志、线程栈 | 毫秒级安全点 | 秒 | 分状态、分实例 |
| 低 | 类直方图、JFR 录制 | 一次短暂 STW / 个位数百分比开销 | 分钟 | 分对象、分事件 |
| 中 | 堆快照 | 全堆 STW,与堆规模相关 | 分钟到十几分钟 | 分引用持有者 |
| 高 | 加参数重启、造复现环境 | 停机 | 小时 | 换条件做对照 |
纪律只有一条:能在不停机、不改配置的前提下拿到的证据,先全部拿完。反过来做的代价是把可逆排查升级成不可逆变更。
4. 线程栈读的是分布,不是单点
| 状态占比 | 含义 | 下一步 |
|---|---|---|
| RUNNABLE 高且栈顶集中 | 真在算 | 火焰图看热点方法 |
| RUNNABLE 高但栈顶分散 | IO、系统调用、JNI | 看 strace/perf,别只看 Java |
| BLOCKED 集中在一把锁 | 锁竞争 | 找持有者,看它卡在哪 |
| WAITING/TIMED_WAITING 占多数 | 等队列、等超时、空转 | 查池配置与下游超时 |
RUNNABLE 不等于在工作:它只是"没在等 JVM 的锁"。线程在 socket 读上、在 native 方法里、在页错误里,都会显示 RUNNABLE。单次快照最容易骗人的地方就在这里——同一条栈可能只是恰巧路过。
# 连续采样看"是否停在同一行",单次快照没有这个信息
for i in 1 2 3 4 5; do jcmd $PID Thread.print > s$i.txt; sleep 2; done
for f in s*.txt; do grep -A6 'pool-1-thread' $f | grep 'at ' | head -1; done | sort | uniq -c
5. 火焰图与采样 profiler 的适用面
| 问题 | 采样 profiler 能答 | 不能答 |
|---|---|---|
| CPU 热点方法 | 是 | 精确调用次数 |
| 等锁时长 | 用 wall-clock 模式可近似 | 锁的排队顺序 |
| 分配热点 | 是(alloc 模式,开销较高) | 堆外分配细节 |
| 单次慢请求 | 样本太少,几乎测不到 | 该请求的完整路径 |
采样按周期抓栈,稀有事件抓不到。P99 毛刺类问题用它往往"看不到异常",这不是没有异常,是采样没命中。
6. 堆与堆外:dump 的时机与代价
| 动作 | 停顿 | 产物大小 | 什么时候值得做 |
|---|---|---|---|
GC.class_histogram |
一次短暂 STW | 文本,KB 级 | 想知道"哪类对象在涨" |
GC.heap_dump |
全堆 STW | 约等于堆使用量 | 需要引用链与持有者 |
NMT(-XX:NativeMemoryTracking) |
启动时开,几乎无额外停顿 | 文本 | 怀疑堆外/native 增长 |
| RSS 与堆使用量对比 | 无 | 两个数字 | 快速判断"是不是堆外" |
堆 dump 之前先回答两个问题:磁盘够不够(写满磁盘比 OOM 更麻烦),以及能不能接受这次停顿(大堆按秒到十几秒量级)。答案是否,就先用直方图与 RSS 对比。
7. 内存增长的三种形态
| 形态 | 特征 | 常见原因 | 判定动作 |
|---|---|---|---|
| 阶梯 | 某事件后抬升并保持 | 缓存预热、连接池扩容、类加载 | 对照事件时间窗 |
| 持续线性 | 与请求量同向、回收不回落 | 真泄漏:静态集合、监听器、ThreadLocal | 两次直方图差值 + 引用链 |
| 锯齿抬高 | 每次都回收,但谷底越来越高 | 会话/缓存无上限、对象晋升 | 看回收后水位而非峰值 |
# 形态判定:间隔取两次直方图,看"哪些类的实例在净增"
jcmd $PID GC.class_histogram > h1.txt; sleep 300
jcmd $PID GC.class_histogram > h2.txt
diff <(sort -k2 h1.txt) <(sort -k2 h2.txt) | grep '^>' | sort -k3 -nr | head
锯齿形态最容易被误判成泄漏:峰值一直很高,但每次 GC 都能落回去。判断依据是回收后的谷底,不是峰值,也不是 GC 次数。GC 次数多只说明回收频繁,说明不了内存是否泄漏。
8. 容器环境下的陷阱
| 陷阱 | 表现 | 处置 |
|---|---|---|
-Xmx 与容器 limit 不匹配 |
堆没满,进程被 OOMKilled | 堆 + 元空间 + 堆外 + 栈 < limit |
| 未感知 cgroup 限制 | JVM 按宿主机核数/内存算默认值 | 显式写 -Xmx、-XX:ActiveProcessorCount |
| 堆外增长无监控 | RSS 涨、堆平稳 | NMT + RSS 曲线 |
| 被内核杀掉 | 无 hs_err、无 JVM 日志、退出码 137 |
查 dmesg/kubectl describe 的 OOMKilled |
# 容器里先确认三件事:JVM 看到的核数、堆上限、容器 limit
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|ActiveProcessorCount'
cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes
dmesg -T | grep -i 'killed process' | tail -5 # 有记录 = 内核杀的,不是 OOM 异常
容器里"OOM"是两个完全不同的东西:JVM 抛 OutOfMemoryError(有堆栈、有 hs_err),和内核 OOM Killer 杀进程(什么都没有,只有一个退出码)。把后者当 JVM 问题查,永远查不到。
9. 复现优先于猜测
| 现象类型 | 能否造出来 | 手段 |
|---|---|---|
| 流量型(并发升高后变慢) | 多数能 | 压测复现目标并发与数据量 |
| 数据型(特定记录触发) | 能 | 从日志取真实样本回放 |
| 依赖型(下游抖动) | 部分能 | 注入延迟/错误,观察是否同形 |
| 状态型(跑几天才出现) | 难 | 只能靠观测先抓证据 |
能复现的问题,定位成本降一个数量级:可以随便 dump、随便改参数、随便加日志。不能复现时,唯一正确的动作是先把观测补上(慢日志、耗时直方图、池指标),等下一次出现时证据已在手上。
10. 定位记录模板
现象:订单查询 P99 从 200ms 升到 1.8s(11-09 14:20 起)
范围:3 个实例全部;同期 QPS 未变;CPU 35%(基线 40%)
时间窗:11-09 14:20 – 15:05,与 14:15 的配置发布对齐
证据:线程栈 5 次采样;BLOCKED 78% 集中在 InventoryLock;持有者停在 socketRead
连接池 active=200/200,队列 1.2k,拒绝计数 0→430
GC 停顿正常(P99 < 50ms),堆使用平稳
结论:线程池被下游库存服务拖满,非 GC、非内存
待验证:下游 14:10 上线后 RT 从 30ms 到 900ms(对方确认)
处置:池隔离 + 超时下调;观察 30 分钟
模板的价值在"证据"与"结论"两栏必须一一对应:结论里出现的每个词,都要能在证据里指到出处。
11. 排错
| 现象 | 原因 | 处置 |
|---|---|---|
jps 看不到进程 |
不同用户或容器 namespace | 进容器/换用户执行 |
| 栈里全是 RUNNABLE 但 CPU 低 | 卡在 IO 或 native | 用 perf/strace 补 |
| dump 文件比堆小很多 | 只 dump 了活对象,或 dump 失败 | 核对返回码与文件完整性 |
| 老年代不降 | 有强引用链 | 拿引用链回代码找静态字段 |
| 容器里堆没满却被杀 | 内核 OOM Killer | 调 limit 或降堆外/元空间 |
12. 边界
| 情形 | 需要 JVM 级排查吗 | 说明 |
|---|---|---|
| 一次性流量尖峰 | 不需要 | 容量问题,扩或限流 |
| 外部依赖故障 | 不需要 | 查依赖侧与超时配置 |
| 明确的代码逻辑错误 | 不需要 | 单测与日志就够 |
| 单请求偶发超时 | 通常不需要 | 采样抓不到,先加耗时打点 |
| 长期缓慢劣化 | 需要 | 这才轮到堆、GC、线程 |
13. 自测
- 归属四问里,哪一问能省掉最多无效取证?
- 响应慢而 CPU 不高,三类的判别证据分别是什么?
- RUNNABLE 为什么不能等同于"在工作"?
- 判断内存是泄漏还是缓存增长,看峰值还是看回收后水位?
- 容器里"退出码 137 且无
hs_err"指向什么? - 结论栏与证据栏必须一一对应,这条纪律防止的是什么?