面向 P6-P7+ 工程师 框架训练型 · 长文 约 8,500–10,000 字 信息截止 2026-08

线上 JVM 疑难问题根因排查方法论:
CPU 飙高 / 死锁 / 内存泄漏 / OOM

这篇文章不是 jcmd 命令速查表,而是给已经在凌晨被告警叫醒、面对「CPU 100% 但 QPS 没涨」「线程全 BLOCKED」「堆用了 90% 但 GC 后下不来」「频繁 OOM 重启」的工程师, 一套可复用的排查框架:如何把症状映射到证据、如何用不变量工具链快速缩小范围、以及四类高频故障各自的标准化 SOP。

主线风格:框架训练 60% + 问题现场 40% 版本假设:JDK 11+ LTS;jcmd / jstack / jmap / MAT / async-profiler 证据等级:官方文档优先;生产经验标注推断
问题现场 · 复合场景

1四症并发:一次典型的 JVM 疑难告警夜

复合场景 · 综合多个线上排查案例的典型对话,非指代单一具体事件 COMPOSITE SCENARIO

凌晨 02:17,某订单中台 Pod 连续触发三条告警:CPU 使用率 96%Full GC 频率异常接口 P99 从 45ms 飙到 8s。 值班工程师第一反应是扩容——加了两个实例后,新实例 CPU 也在 10 分钟内爬升到 85%, 而 QPS 仅比平日高出 20%。

进一步查看监控,发现老年代占用持续线性上升,Young GC 后回收效果越来越差; 同时 jstack 显示大量线程处于 BLOCKEDWAITING, 其中一组线程互相等待同一把 ReentrantLock。 十分钟后进程被 K8s OOMKilled——但容器内存 limit 是 8GB,而 JVM 堆只配了 4GB。

这个复合现场同时覆盖了本文的四条主线:CPU 飙高(但非流量驱动)、 疑似死锁/锁争用(线程阻塞放大延迟)、 内存泄漏(老年代只升不降)、 OOM(进程被容器杀掉)。 新手往往在这四个症状之间来回跳转、重复采集 dump,浪费黄金排查窗口; 老手则先问一个问题:这四个现象里,谁是因、谁是果?

上述场景揭示了一个普遍误区:把 JVM 疑难问题当作四个独立故障类型分别处理。 实际上它们经常形成因果链——内存泄漏导致频繁 GC 和安全点停顿, GC 线程与应用线程争用 CPU,锁争用又在 GC 停顿窗口内被放大,最终触发 OOM 或容器 Kill。 如果不先建立因果假设,排查就会退化为「看到什么查什么」的随机游走。 有经验的 SRE 会在告警面板前花三分钟画一张简易因果图:哪个指标先异常、 哪个指标滞后十分钟才跟上——这个时间差往往比绝对数值更能指向主因。

本文的目标不是教你记住二十条命令,而是建立一套症状 → 证据 → 根因 → 验证 的闭环:每个症状对应一组最小证据集,每条 SOP 都写清「什么信号可以排除什么假设」。 与 API 教程不同,排查方法论的核心是条件化结论:同一份 jstack 输出, 在 CPU 飙高场景下应关注 RUNNABLE 栈顶,在接口超时场景下应关注 BLOCKED 等待链—— 工具相同,判读逻辑不同。读者读完应能独立设计自己团队的 Runbook,而非背诵命令。

版本说明:工具命令基于 JDK 11 及以上jcmd 统一入口; 堆分析以 Eclipse MAT 为主;CPU 热点以 async-profiler 为首选(替代已废弃的 perf-map 方案)。 容器环境下部分命令需配合 -XX:+UseContainerSupport 理解 cgroup 限制。 JDK 17/21 的 JFR(Java Flight Recorder)可作为 async-profiler 的备选, 尤其在无法安装 native agent 的受限容器中;但 JFR 的 CPU 采样精度略低于 perf 系 profiler, 建议作为证据补充而非唯一来源。虚拟线程(JDK 21+)引入后,传统 jstack 的线程数解读需调整—— 百万级 virtual thread 存在时,BLOCKED 计数会失真,须改用 Thread.vthread 子命令。

已核验事实

JDK 官方 Troubleshooting Guide 明确区分三类资源争用:CPU 饱和(计算或自旋)、 内存压力(分配速率超过回收速率或泄漏)、 线程阻塞(锁、I/O、外部依赖)。 同一告警指标可能由多条链路触发,必须交叉验证而非单点定论。 来源:JDK 17 Troubleshooting Guide, Oracle

框架训练 · 排查不变量

2排查框架:三条不变量与证据分级

无论故障表象如何,线上 JVM 排查有三条不变量应当始终成立: 第一,先冻结现场再动刀——重启会销毁 80% 以上的根因证据; 第二,症状不可信、指标可交叉——CPU 高不等于算力不足,堆占用高不等于泄漏; 第三,一次只验证一个假设——并行改参数、扩实例、改代码会让因果无法归因。 框架训练型的价值在于:把这三条不变量固化成 SOP,让值班工程师在压力下仍能按步骤执行。 许多团队的 Runbook 写了十几页却没人用,根本原因是步骤太多、判据不清—— 本文每条 SOP 都限定六步以内,且每步有明确的「通过标准」和「证伪信号」, 证伪即转向另一条 SOP 或补充采集,避免在错误路径上消耗整晚。

2.1 四阶段闭环

每个排查周期严格按四个阶段推进,不可跳步: 定界(Triage)——确认影响范围、是否可止血; 采集(Capture)——在最小侵入前提下拿到线程、堆、GC、Profile 证据; 分析(Analyze)——用工具链解读证据,形成单一根因假设; 验证(Verify)——通过复现、对照实验或灰度修复确认假设。 定界阶段最常见的错误是「还没采集就重启」;验证阶段最常见的错误是「修复上线但不留对照组」。

四阶段之间有一个容易忽视的时间预算:P0 故障从告警到完成采集, 目标应控制在 20 分钟以内——超过这个时间窗口,线程栈可能已因超时自愈而改变, GC 曲线可能因自动重启而断裂。建议团队在 Runbook 中写明各阶段 SLA: 定界 5 分钟、采集 10 分钟、分析 2 小时(可并行)、验证按发布窗口。 若 20 分钟内无法完成采集,应升级并申请「保留异常实例」专项审批,而非默认重启。

图 1 · JVM 线上排查四阶段闭环
自制示意图
症状进入 → 证据产出 → 假设收敛 → 修复验证 1. 定界 Triage 影响面 / SLA / 能否止血 输出:故障分级 P0-P3 2. 采集 Capture jstack / jcmd / heap dump 输出:证据包 artifact bundle 3. 分析 Analyze MAT / profiler / 日志关联 输出:单一根因假设 4. 验证 Verify 复现 / 灰度 / 对照 输出:RCA 报告 采集阶段最小证据集(按症状选配) CPU 飙高 top + async-profiler + jstack 死锁 / 阻塞 jstack ×3 + 线程池指标 内存泄漏 GC 日志 + heap dump + MAT OOM hs_err + heap dump + 容器事件 假设被证伪 → 回到采集阶段补充证据(虚线回流) 不变量:P0 故障在采集完成前禁止重启;每次只改一个变量做验证
读图方式:从左到右阅读主流程四个色块——定界(红)、采集(青)、分析(紫)、验证(绿)。 下方四个小框是采集阶段按症状选配的最小证据集;虚线回流表示假设被证伪时需补充采集而非直接重启。 核心不变量写在底部:P0 禁止先重启,验证阶段一次只改一个变量。

2.2 证据分级与因果链

排查过程中所有结论必须标注证据等级:fact(工具输出或官方文档可直接核验)、 prod(生产环境反复验证的经验模式)、 infer(基于机制推导、待压测或对照实验确认)、 trap(高频误判,应主动排除)。 当 CPU、内存、线程三类指标同时异常时,建议按时间先后而非告警优先级 排列因果链:通常内存压力先行(分配/GC),CPU 异常次之(GC 或自旋),线程阻塞最后放大(停顿窗口内锁等待)。

因果链的画法有一个实用技巧:在 Grafana 上把 jvm_memory_bytes_usedprocess_cpu_seconds_totalhttp_server_requests_seconds P99 三条曲线叠在同一时间轴,观察谁先突破基线 2 倍。 若内存曲线领先 CPU 曲线 5–15 分钟,主因几乎一定在内存侧; 若 CPU 与 QPS 严格同步,则优先排除流量。这条时间差判据在复合告警中的价值 高于任何单一阈值——它把「四症并发」降维成「一个主因 + 三个次生症状」。

2.3 止血与定界的边界

定界阶段必须回答两个问题:能否通过摘流量止血,以及是否需要保留异常实例。 能摘流量则优先摘流量而非重启——K8s 的 readinessProbe 失败会自动摘流, 但需确认异常 Pod 未被立即删除(terminationGracePeriodSeconds 应 ≥120s 以便采集)。 不能摘流量(如单点或数据一致性要求)则先扩容 healthy 副本,再对异常实例做只读采集。 定界阶段的输出是一份故障分级:P0(核心链路不可用)、P1(SLA 跌破)、P2(单实例异常)、P3(指标预警)。 不同级别对应不同的采集深度——P3 可能只需 GC 日志,P0 必须全套 artifact。

症状组合优先假设首要证据可快速排除
CPU 高 + QPS 正常GC 开销 / 自旋 / 正则灾难async-profiler + GC 日志流量突增(QPS 未涨)
CPU 高 + 线程 BLOCKED 多锁争用引发上下文切换jstack 锁图谱 + 线程池队列纯计算热点(RUNNABLE 占主导)
堆升 + Full GC 无效内存泄漏 / 缓存无界两次 dump 对比 + MAT Dominator正常峰值(GC 后能回落)
OOM + 容器 Kill堆外内存 / 元空间 / 直接内存Native Memory Tracking + 容器事件纯堆 OOM(会有 Java heap dump)
推断性结论

从机制推导:复合场景中最常见的主因是内存侧问题(泄漏或无界缓存), CPU 和线程阻塞多为次生症状。因此当四症并发时,建议优先采集 GC 日志和 heap dump, 同时用轻量 profiler 确认 CPU 是否被 GC 线程占据——这一顺序可以比「先查 CPU 热点」更快收敛。 此结论来自多个生产案例的模式归纳,非对照实验,具体服务可能相反。 若你的服务有明确的锁热点历史(如大促库存扣减),则应把死锁 SOP 提升到与内存 SOP 同级优先级。

框架训练 · CPU 飙高

3CPU 飙高:从「谁在用 CPU」到根因收敛

CPU 飙高是线上最高频的 JVM 告警之一,但CPU 高本身不是根因,只是资源饱和的信号。 排查的第一步永远是区分:是业务线程在算(热点方法、正则、序列化), 还是GC 线程在回收(内存压力),还是自旋/空转(锁未优化、错误重试逻辑)。 三者的证据集和修复手段完全不同,混淆它们会导致「加了 CPU 还是高」的困境。

3.1 根因三分法

计算型热点:async-profiler 火焰图中业务方法占据 Top N,且与 QPS 正相关。 常见根因包括:热路径上的 JSON 序列化、复杂正则、Stream 链式操作在循环内、 未缓存的配置解析。修复方向是代码层优化,而非 JVM 参数。 一个典型误判是看到 Jackson writeValue 在 Top 就加对象池—— 应先确认是「调用次数过多」还是「单次序列化过慢」,前者靠减少调用,后者靠换更快的序列化器或预生成。

GC 型 CPU:火焰图中 GCG1Parallel 相关帧占比超过 30%(合成阈值,视服务而定), 同时 GC 日志显示回收频率异常。修复方向是内存侧(泄漏、堆过小、分配速率过高)。 注意 GC 型 CPU 在监控上有一个迷惑特征:应用线程可能大量处于 SAFEPOINT 或 WAITING FOR GC, QPS 下降但 CPU 仍高——因为 GC 线程在满负荷运转。此时 top 看线程名含 GC 的线程即可快速确认。

自旋/阻塞型:CPU 高但 profiler 显示大量 Unsafe.parkObject.wait 或锁相关帧,jstack 中 BLOCKED 线程比例高。 修复方向是锁粒度、线程池配置、消除无效重试。 自旋型还有一个隐蔽变种:忙等重试——代码在循环中 Thread.sleep(0) 或 无退避的 CAS 重试,profiler 显示业务方法占比高但 jstack 中线程是 RUNNABLE, 需结合 WALL 模式 profiler 与 CPU 模式交叉确认。

3.3 与发布/变更的关联

CPU 飙高若发生在发布之后 30 分钟内,应优先查变更而非 JVM 本身: 新版本引入的正则灾难、循环内的远程调用、线程池参数被改小、或依赖库升级导致的序列化行为变化。 对比发布前后同一 QPS 下的 CPU 基线,若差值超过 20%(合成参考), 回滚验证往往比深入 profiler 更快。这不是偷懒,而是变更归因的优先级高于代码热点归因—— 除非回滚后 CPU 仍高,才进入完整 SOP。

图 2 · CPU 飙高排查决策树
自制示意图 · 非基准数据
CPU > 80% 告警 先确认:单实例 or 全集群? Q1: QPS 同步上涨? 流量型 扩容 + 限流 Q2: GC 线程 CPU 高? GC 型 转内存 SOP async-profiler 定位热点方法 Q3: 热点在业务 or 锁? 锁/自旋型 jstack + 转死锁 SOP 计算热点 代码优化 / 缓存 关键判据:QPS 未涨 → 排除流量;GC 线程占比 → 转内存;否则 profiler 定热点 采集命令:async-profiler -d 60 -e cpu -f /tmp/cpu.html <pid>
读图方式:从顶部告警出发,依次回答三个菱形问题(黄色边框)。 「QPS 同步上涨」为是则走流量型(扩容/限流);否则检查 GC 线程 CPU—— 若 GC 占比高则转入内存/OOM 排查链路;否则用 async-profiler 区分锁争用与计算热点。 底部给出关键判据和推荐采集命令。

3.2 CPU 飙高 SOP

步骤动作命令 / 工具通过标准证伪信号
1确认影响面监控:单 Pod / 单 AZ / 全集群范围已界定全集群 → 先查上游发布
2排除流量因素QPS、线程池队列、连接数QPS 与 CPU 不同步QPS 同比涨 >50% → 流量型
3区分 GC vs 业务top -H -p <pid> 看 GC 线程GC 线程 CPU <20%GC 线程 Top → 转 Ch5/6
4采集 CPU 火焰图async-profiler 60sTop 3 方法已识别火焰图平坦 → 考虑容器 throttling
5关联线程栈jcmd <pid> Thread.print热点线程栈与火焰图一致BLOCKED 多 → 转 Ch4
6形成假设并验证代码审查 / 灰度修复CPU 回落且 QPS 不变无效 → 补充 dump 回到步骤 4
常见陷阱 · CPU

在容器 CPU Throttling 场景下误判为 Java 热点。 当 Pod 的 CPU limit 过低时,top 显示 CPU 100% 可能是 cgroup 限流而非 JVM 算力饱和, 此时 async-profiler 采样会失真。应先检查 container_cpu_cfs_throttled_seconds_total 指标:若 throttled 时间持续增加,优先调整 CPU limit/request 配比,而非优化 Java 代码。

框架训练 · 死锁与阻塞

4死锁与线程阻塞:锁图谱与等待链分析

「死锁」在线上往往被过度使用——真正满足四个必要条件(互斥、占有且等待、不可抢占、循环等待) 的 classic deadlock 并不多见;更常见的是锁争用(Lock Contention)线程池耗尽外部 I/O 阻塞导致的线程饥饿。 jstack 中的 Found one Java-level deadlock 是充分条件,但没有这行不代表没有阻塞问题

4.1 三类阻塞模式

Classic Deadlock:jstack 自动检测并输出循环等待链,修复方式是统一加锁顺序或缩小锁粒度。 Lock Contention:无循环等待,但大量线程 BLOCKED 在同一把锁上, 典型于 synchronized 方法包裹过重逻辑、或分布式锁未设超时。 Pool Exhaustion:线程池队列满、RejectedExecutionHandler 配置不当, 或下游慢调用占满 worker 线程——jstack 显示大量 WAITING 在 LinkedBlockingQueue.take

4.2 死锁 / 阻塞 SOP

步骤动作命令 / 工具通过标准证伪信号
1连续三次线程 dumpjcmd <pid> Thread.print 间隔 30s三次 dump 已保存线程状态每次完全不同 → 非稳定阻塞
2检查死锁检测输出搜索 deadlock 关键字有/无明确结论有 → 直接定位循环链
3统计线程状态分布RUNNABLE / BLOCKED / WAITING 计数BLOCKED >30% 为异常RUNNABLE 为主 → 转 CPU SOP
4绘制锁等待链提取 waiting to lock热点锁对象已识别无锁等待 → 查 I/O / DB
5关联线程池指标activeCount / queueSize / rejected池耗尽已确认或排除rejected >0 → 扩容或降级
6修复并验证锁优化 / 超时 / 池参数调整BLOCKED 比例恢复正常无效 → 查下游依赖 RT

连续三次 dump 的设计意图是区分瞬时阻塞稳定阻塞: 若同一线程在三份 dump 中始终 BLOCKED 在同一锁上,根因高度可信; 若状态剧烈变化,更可能是流量波动或 GC 停顿引起的短暂等待。 对于虚拟线程(JDK 21+ Project Loom),jstack 输出格式变化—— carrier 线程与 virtual thread 的映射需使用 jcmd <pid> Thread.vthread 辅助分析。

4.3 分布式环境下的「伪死锁」

微服务架构中,真正的 in-process deadlock 占比很低,更多见的是分布式死锁: 服务 A 持有锁 L1 等待 B 的响应,服务 B 持有 L2 等待 A 的响应,形成跨进程循环等待。 这类问题 jstack 无法检测——每个 JVM 内部的栈都是合法的 BLOCKED/WAITING。 排查需结合分布式链路追踪:在 Zipkin/Jaeger 中找到超时请求的完整调用链, 看是否存在环状依赖。修复方向是:统一锁顺序、设置锁超时(tryLock(timeout))、 或将串行锁改为粗粒度业务幂等,而非单纯调整 JVM 线程池。

数据库连接池耗尽是与死锁告警高度混淆的另一场景。HikariCP 的 connectionTimeout 默认 30 秒,当连接泄漏(未 close)或慢 SQL 占满池时,业务线程会在 HikariDataSource.getConnection 上 BLOCKED——jstack 看起来像锁问题, 实际是资源池问题。判据:BLOCKED 线程栈顶在 getConnection 而非业务锁; 连接池 JMX 指标 ActiveConnections 等于 MaximumPoolSize。 此类问题应查 SQL 慢查询和连接泄漏,而非 jstack 锁链。

生产经验 · 阻塞

线程池耗尽导致的「伪死锁」在微服务中占比高于 true deadlock。 典型模式:HTTP 客户端未设 connect/read timeout,下游慢 SQL 占满 Tomcat worker, 新请求在 accept 队列堆积,监控上表现为「线程数满 + 响应超时 + CPU 不高」。 修复优先级:先设超时和熔断,再调线程池大小——单纯增大 maxThreads 只会延迟崩溃。

框架训练 · 内存泄漏

5内存泄漏:从 GC 曲线到 Dominator Tree

「内存泄漏」在 JVM 语境中应精确定义为:对象已无业务用途,但仍被 GC Root 可达,导致无法回收。 与之易混淆的是内存溢出(Overflow)——对象确实还在用,只是量超出了堆/design容量; 以及内存抖动(Churn)——短命对象分配速率过高,GC 频繁但非泄漏。 三者诊断路径不同:泄漏看 Dominator Tree;溢出看峰值流量与缓存上限;抖动看分配 profiler。

5.1 泄漏判定信号

可靠的泄漏信号组合是:老年代占用单调上升 + Full GC 后回收率低于 10% + 多次 heap dump 中 Dominator 头部对象类不变。 仅「堆用了 90%」不构成泄漏证据——可能是业务高峰的正常水位。 必须在 Full GC 后观察是否回落;若回落,优先调堆大小而非查泄漏。

内存抖动(Churn)与泄漏的区分是排查中第二常见的误判。Churn 的特征是: Young GC 频率极高(如每秒多次),但每次 Young GC 回收率正常(>80%), 老年代增长缓慢;profiler alloc 模式显示大量短命对象在热路径分配。 修复方向是减少分配(对象复用、避免装箱、StringBuilder 替代字符串拼接), 而非 heap dump——因为 dump 时短命对象可能已被回收,MAT 看不到明显 Dominator。 若 async-profiler 的 alloc 火焰图 Top 是 byte[]char[], 且与 JSON/日志相关,优先优化序列化和日志级别,而非怀疑泄漏。

5.2 堆外泄漏的特殊性

并非所有「内存只升不降」都是 Java heap 泄漏。Netty 的 Direct ByteBuf 若未调用 release(),堆内监控完全正常,但进程 RSS 持续上升直至 Container Kill。 判据:heap 使用率健康但 RSS/heap 比值持续增大(如 >1.5);NMT 中 Internal 或 Other 段膨胀。 此类问题 heap dump 无效,必须用 -XX:NativeMemoryTracking=summary 并在可疑时段执行 jcmd <pid> VM.native_memory summary.diff 对比。 Netty 生产环境应开启 -Dio.netty.leakDetection.level=paranoid 仅在测试环境—— 生产用 simple 或依赖 ResourceLeakDetector 采样告警。

图 3 · 内存泄漏排查路径
自制示意图
GC 曲线异常 → dump 采集 → MAT 分析 → 代码定位 1. GC 日志分析 老年代单调上升? Full GC 回收 <10%? 2. 采集 Heap Dump jcmd GC.heap_dump 间隔 1h 采两次对比 3. MAT 分析 Dominator Tree Leak Suspects Report 4. 代码定位 GC Root 链 → 修复验证 MAT 分析要点 Histogram 按类统计实例数/占用 发现异常膨胀的 Collection 如 HashMap / byte[] / char[] Dominator Tree 找最大 retained heap 对象 展开 GC Root 引用链 定位谁持有了泄漏对象 Compare Duplicates 两次 dump 对比 确认对象持续增长 排除一次性峰值 高频泄漏模式(生产归纳) ThreadLocal 未 remove(线程池复用场景) 静态 Map 缓存无 TTL / 无 LRU 淘汰 ClassLoader 泄漏(热部署 / 动态代理 / OSGi) 监听器注册未注销(Observer 模式) Netty ByteBuf 未 release(堆外泄漏) MyBatis 一级缓存跨 Session 误用
读图方式:主路径从左到右四步——GC 日志确认异常 → 采集两次 heap dump → MAT 三维分析(Histogram / Dominator / 对比)→ 沿 GC Root 链定位代码。 下方列出六种生产高频泄漏模式;若 Dominator 头部是 ClassLoaderThreadLocal,应优先怀疑对应模式。

5.3 内存泄漏 SOP

步骤动作命令 / 工具通过标准证伪信号
1确认 GC 异常模式GC 日志 / Prometheus jvm_memory老年代单调升或 FGC 无效FGC 后回落 → 调堆非泄漏
2触发 Full GC 对照jcmd <pid> GC.run回收率 <10% 为泄漏嫌疑回收 >50% → 峰值非泄漏
3采集 heap dumpjcmd <pid> GC.heap_dump /tmp/h1.hprofdump 文件可打开dump 本身 OOM → 增大堆或 live dump
4MAT Dominator 分析Leak Suspects + Dominator TreeTop retained 对象已定位分散小对象 → 可能是 churn
5沿 GC Root 链追溯MAT Path to GC Roots持有方代码路径已确认Root 是 JNI → 查 native
6修复并长期验证发布后观察 72h GC 曲线老年代稳定锯齿形仍上升 → 二次 dump 对比
框架训练 · OOM

6OOM 分型:Java heap / MetaSpace / Direct / Kill

OOM 不是单一故障,至少存在四种截然不同的分型,混淆它们会导致错误的修复方向。 Java heap space:堆内对象无法分配,通常有 heap dump,走 Ch5 泄漏 SOP。 Metaspace:类元数据溢出,常见于动态代理/generate 类过多或 ClassLoader 泄漏。 Direct buffer memory:堆外直接内存,Netty/NIO 未 release,jmap 堆 dump 看不到。 Container OOM Kill:进程总 RSS 超限被 cgroup 杀掉,JVM 堆可能只用了 60%。 在 K8s 环境中,还应检查 hs_err_pid 文件是否生成:若进程被 SIGKILL, 往往来不及写 hs_err,此时应以 kubectl describe pod 的 Last State 为准。

6.1 OOM SOP

步骤动作命令 / 工具通过标准证伪信号
1确认 OOM 类型hs_err_pid*.log / K8s Events分型已确定Kill 137 → 容器 OOM 非 Java OOM
2Java heap OOM自动 dump + MAT转 Ch5 泄漏 SOP单次大对象 → 调 -Xmx
3Metaspace OOMjcmd VM.metaspace + 类加载日志类加载数异常类数稳定 → 查 MaxMetaspaceSize
4Direct OOM-XX:NativeMemoryTracking=summaryInternal/Other 段异常堆内正常 → 必查 NIO/Netty
5Container KillRSS vs limit;NMT total堆+堆外+线程栈总量已估算RSS ≈ 堆 → 单纯堆太小
6预防性配置-XX:+HeapDumpOnOutOfMemoryError下次 OOM 可自动留证

容器 OOM Kill(exit code 137)是云原生环境中最容易误判的分型。 JVM 进程 RSS = 堆内存 + 元空间 + 线程栈(每线程约 1MB)+ 直接内存 + Code Cache + GC 开销。 若容器 limit 8GB、-Xmx4g,剩余 4GB 并非全部可用—— 直接内存默认与 -Xmx 相同(-XX:MaxDirectMemorySize), 200 个线程就占 200MB 栈空间,再加上 Metaspace 和 Native 开销,很容易触碰 limit。 排查时应使用 NMT(Native Memory Tracking)而非仅看堆使用率。

6.2 Metaspace OOM 的特有模式

Metaspace OOM 常见于:Spring Boot 热部署、Groovy/JSP 动态脚本、CGLIB 代理类爆炸、 OSGi/插件化架构。每次动态生成类都会在 Metaspace 占一块,若 ClassLoader 未卸载, 类元数据永不释放。判据:jcmd <pid> VM.metaspace 显示 committed 接近 MaxMetaspaceSize;类加载数持续上升。 MAT 分析 heap dump 时若 Dominator 头部出现大量 ClassLoaderClass 实例,应沿 ClassLoader 引用链查谁持有了旧 Loader。 修复:限制动态代理范围、关闭不必要的 AOP、确保自定义 ClassLoader 在不用时显式 close。

6.3 Unable to create new native thread

第四种 OOM 变体是无法创建新线程——进程 hits 操作系统线程数上限或内存不足以分配线程栈。 在容器中表现为 Pod 无法响应新连接,jstack 可能无法 Attach。 判据:日志含 Unable to create new native threadulimit -u 或 cgroup pids.max 已达上限。修复方向:减少线程池 maxSize、 使用虚拟线程降低 OS 线程数、或调高容器 pid limit——而非增大 -Xmx

OOM 预防配置(推荐)

  • -XX:+HeapDumpOnOutOfMemoryError
  • -XX:HeapDumpPath=/data/dumps/
  • -XX:+ExitOnOutOfMemoryError(让 K8s 重启)
  • -Xlog:gc*:file=gc.log:time:filecount=5,filesize=50m
  • 容器 limit = 堆 × 1.5~1.8(预留堆外)

OOM 排查反模式

  • OOM 后立即重启不留 dump
  • 只调大 -Xmx 不查泄漏
  • 把 Container Kill 当 Java heap OOM
  • 生产环境开 NMT=detail 常驻(开销大)
  • 用 jmap -dump 在生产高峰期触发 STW
验证层 · 命令判读

7命令判读示例:从输出到假设

工具链不变量的价值在于:同一命令在不同故障中的判读逻辑不同。 本节给出三个典型输出片段的解读方式,训练「看到什么 → 推断什么 → 下一步做什么」的条件反射。 以下输出为合成示例,用于说明判读逻辑,非真实生产 dump。

7.1 jstack:BLOCKED 线程解读

当 jstack 中出现大量如下模式时:

"http-nio-8080-exec-12" #45 BLOCKED on java.util.concurrent.locks.ReentrantLock@0x4a3f2c1
  waiting to lock ... held by "http-nio-8080-exec-3"

判读逻辑:所有 BLOCKED 线程等待同一把 ReentrantLock, 且 holder 线程(exec-3)的栈顶如果在业务慢方法(如 DB 查询、RPC 调用), 则根因是锁粒度过大 + holder 执行慢,而非 true deadlock。 下一步:查看 exec-3 的完整栈——若在 SocketInputStream.read, 则应先解决下游超时,而非调整锁实现。

7.2 GC 日志:泄漏 vs 峰值的区分

合成 GC 日志片段:

[2026-08-08T02:20:01] GC(142) Pause Full (System.gc()) 4096M->3890M(4096M) 2842ms
[2026-08-08T02:35:01] GC(158) Pause Full (System.gc()) 4096M->3912M(4096M) 2901ms

判读:两次 Full GC 间隔 15 分钟,回收量仅约 200MB(回收率约 5%), 且回收后占用仍接近堆上限——这是泄漏的典型信号。 对比峰值模式:若 FGC 后从 4096M 降到 800M,则属于正常峰值,调 -Xmx 即可。 下一步:立即采集 heap dump,不要等待下一次 OOM。

7.3 async-profiler:GC 线程占 CPU 的识别

当火焰图 Top 帧为 collectedHeap::fill_with_objectG1CollectedHeap::evacuate_collection_set 等 GC 内部方法, 且合计占比超过 30% 时,判读为GC 型 CPU,应转入内存排查而非优化业务代码。 若 Top 帧为 java.util.regex.Pattern$Branch.matchcom.fasterxml.jackson.databind,则为计算热点, 应定位调用方是否在循环或热路径中重复调用。

7.6 复合场景下的命令执行顺序

当第一章的复合场景(CPU + 阻塞 + 泄漏 + Kill)同时出现时,推荐以下最小命令序列, 在 10 分钟内完成,避免遗漏关键证据: 第一步 jcmd <pid> VM.flags 确认 JVM 参数; 第二步 jcmd <pid> Thread.print > /tmp/t1.txt; 第三步 async-profiler 60s(后台运行); 第四步 jcmd <pid> GC.heap_dump /tmp/h.hprof(若堆占用 >85%); 第五步 30s 后第二次 Thread.print。 这五步完成后,无论根因在哪条 SOP,都有足够证据进入分析阶段。 切忌在复合场景下先花 40 分钟做完整 MAT 分析——那是分析阶段的事,采集阶段的目标是「广覆盖、轻侵入」。

7.4 jcmd GC.class_histogram 快速扫描

在尚未决定是否需要完整 heap dump 时,jcmd <pid> GC.class_histogram 可在数秒内给出按类统计的实例数和字节数。合成输出判读示例: 若 char[]String 占据 Top 2 且实例数达千万级, 同时伴随日志框架相关类(ch.qos.logback), 应优先怀疑日志风暴而非传统泄漏——降低日志级别往往比 MAT 分析更快止血。 若 Top 是 ConcurrentHashMap$Node 或业务 DTO 类且实例数持续线性增长, 则泄漏嫌疑上升,应进入完整 dump + MAT 流程。 注意:class_histogram 会触发 STW,超大堆上可能停顿数秒,P0 高峰期慎用。

7.5 NMT 输出与容器 Kill 的对应

开启 -XX:NativeMemoryTracking=summary 后, jcmd <pid> VM.native_memory summary 输出各内存段 committed 值。 合成判读:若 Java Heap committed 3.8G、Metaspace 256M、Thread 300M、Internal 1.2G, 合计约 5.5G,而容器 limit 6G——说明堆外 Internal 段是 Kill 主因, 应查 Direct Buffer 或 JNI 分配,而非继续增大 -Xmx。 NMT 的 detail 模式信息更全但开销显著,仅建议在复现环境短期开启, 生产常驻 summary 即可。

jstack 含 Found Java-level deadlockClassic deadlock按输出循环链改加锁顺序fact FGC 回收率 <10%,间隔缩短内存泄漏heap dump + MATfact exit 137,RSS ≈ container limitContainer OOM KillNMT + 调整 limit/堆配比fact GC 线程 CPU >30%,堆占用高内存压力引发 GC 型 CPUCh5/Ch6 内存 SOPinfer ThreadLocal 在 Dominator Tree 头部ThreadLocal 泄漏查线程池场景 remove 调用prod
体系架构 · 工具链

8工具链不变量与采集纪律

JDK 11 以后,jcmd 成为所有诊断命令的统一入口,替代了分散的 jstack、jmap、jinfo 调用。 工具链的不变量是:每个工具有明确的适用边界和侵入性, 选错工具要么拿不到证据(用 jstack 查泄漏),要么破坏现场(高峰期 jmap dump 触发 STW)。

8.1 核心工具矩阵

工具适用场景侵入性不适用
jcmd <pid> Thread.print线程状态、死锁检测低(SafePoint 暂停)高频连续采集(<10s 间隔)
jcmd <pid> GC.heap_dump堆快照中(可能 STW 数秒)超大堆(>16GB)高峰期
jcmd <pid> VM.native_memory堆外内存 / NMT未开启 NMT 时无输出
async-profilerCPU / alloc 热点容器未授权 perf_event
Eclipse MATheap dump 分析无(离线)无 dump 文件时
jcmd <pid> GC.class_histogram快速看重对象分布中(STW)替代完整 dump 做最终结论

8.2 采集纪律

生产环境采集必须遵守四条纪律: (1)P0 故障完成最小证据集采集前禁止重启——重启销毁线程栈和堆快照机会; (2)heap dump 优先用 jcmd 而非 jmap——jcmd 通过 Attach API,部分场景 STW 更短; (3)profiler 采样窗口不少于 60 秒——短窗口容易采到 GC 瞬态; (4)所有 artifact 带时间戳和 Pod 标识——便于与监控时间线关联。 建议在 K8s 中预置 preStop hook 或 sidecar 脚本,在 Pod 被驱逐前自动触发 dump。 第五条纪律同样重要:(5)采集命令本身不应成为故障原因—— 在堆已满时执行 live heap dump 可能直接触发 OOM;在 CPU 已饱和时长时间 profiler 可能加剧停顿。 超大堆(>16GB)dump 前先评估磁盘空间(dump 约为堆的 80–100%)和 STW 容忍度。

对于无法直接登录节点的容器环境,可通过 kubectl exec 进入容器执行 jcmd, 或使用 JDK 自带的 jcmd 远程 Attach(需开启 com.sun.management.jmxremote 并注意安全组)。 async-profiler 在容器中需要 kernel.perf_event_paranoid <= 2CAP_SYS_ADMIN 权限;若无权限,可退而使用 JFR(jcmd JFR.start)做次级采样。

8.3 证据包(Artifact Bundle)标准

成熟的团队会将一次排查的所有 artifact 打包为统一目录,命名规范如: {date}_{pod}_{symptom}/,内含 thread-1..3.txtgc.logheap.hprofcpu-flame.htmljcmd-vm-flags.txtmetrics-screenshot.png。 证据包的价值在于:事后 RCA 时可由未参与值班的人独立复盘; 也可作为混沌演练和培训的真实素材。建议平台侧提供一键采集 Sidecar: 收到 P0 告警后自动 exec 进容器执行预置脚本,减少值班同学的手动操作失误。

8.4 JFR 与 async-profiler 的选型

JDK 11+ 内置的 JFR 无需额外安装,适合安全策略禁止 perf_event 的环境。 开启方式:jcmd <pid> JFR.start duration=120s filename=/tmp/rec.jfr settings=profile, 用 JDK Mission Control 或 async-profiler 的 jfrconv 转火焰图。 JFR 的优势是开销极低(<1%),可长期开启;劣势是 CPU 采样精度不如 perf, 对短周期热点(<10ms 的尖刺)可能漏采。 推荐策略:日常监控用 JFR Continuous Recording(环形缓冲), P0 故障时用 async-profiler 做 60–120s 高精度采样。

生产经验 · 采集

建议在服务启动参数中始终开启-XX:+HeapDumpOnOutOfMemoryError、 GC 日志滚动输出、以及 JMX/prometheus exporter。 这三项的常驻开销极低,但在 OOM 时的证据价值不可替代。 对比:一次未留 dump 的 OOM 排查平均多耗费 4–8 小时(合成估算), 且结论Confidence 显著低于有 dump 的案例。

问题推导 · 反模式

9五个让 JVM 排查失败的认知陷阱

JVM 排查失败 rarely 是因为工具不会用,更多是因为在错误的时间做了正确的操作, 或在证据不足时过早下结论。以下五个陷阱在架构评审和故障复盘中反复出现。

陷阱一:重启治愈一切

重启确实能恢复服务,但同时销毁了线程栈、heap 状态和 GC 时间线。 如果团队 KPI 是 MTTR 而非 RCA 完成率,会形成「重启 → 忘记 → 下次再来」的循环。 正确做法:P0 先摘流量或扩容止血,保留一个异常实例不重启用于采集, 待证据包完整后再重启。许多公司把「15 分钟内恢复」和「7 天内提交 RCA」拆成两个独立 KPI, 前者激励止血,后者激励留证——只有两者并存,排查方法论才有落地动力。

陷阱二:单点证据定论

仅一份 jstack 不能证明死锁(可能瞬时),仅一次 heap dump 不能证明泄漏(可能峰值), 仅 10 秒 profiler 不能证明热点(可能采到 GC)。 每个假设至少需要两类独立证据交叉验证—— 例如 jstack + 线程池指标、GC 日志 + 两次 dump 对比。

陷阱三:工具万能论

MAT 只能分析 heap dump,看不到 Metaspace 和 Direct Memory; jstack 看不到 CPU 热点;profiler 看不到对象引用关系。 工具链必须组合使用,且每个工具的输出应导入统一时间线(如 Grafana + Loki 关联)。

陷阱四:忽略容器边界

裸机时代的经验(-Xmx 设为物理内存 70%)在容器中失效。 容器 limit 是硬上限,JVM 的 -XX:+UseContainerSupport 会自动感知 cgroup, 但堆外内存和线程栈不受 -Xmx 约束。 OOM Kill 137 与 Java OOM 的修复路径完全不同。

陷阱五:修复后不验证

调大堆、改线程池、加锁优化——任何修复都应在等量流量下观察 72 小时, 确认 GC 曲线、CPU 基线、线程 BLOCKED 比例均回归正常。 没有验证的修复只是「暂时没再告警」,不是根因已消除。

陷阱六:过度依赖 AI/脚本自动诊断

近年来出现一些「上传 jstack 自动分析」的工具,对 classic deadlock 和明显泄漏模式有一定帮助, 但对复合因果链容器边界类问题误判率很高。 自动工具的输出应作为 hypothesis 生成器,而非 RCA 终稿—— 值班工程师仍需按本文 SOP 完成证据交叉验证。尤其当自动报告给出「疑似泄漏」结论时, 必须人工确认 FGC 回收率和两次 dump 对比,否则容易把峰值误判为泄漏并做无效代码改动。

另一个相关陷阱是监控告警阈值过松导致排查窗口被压缩: 堆使用率告警设在 95%、CPU 告警设在 99%,留给值班工程师采集 dump 的时间往往不足 5 分钟。 建议:堆 80% 预警(留采集窗口)、Full GC 频率环比告警(捕获泄漏早期信号)、 线程 BLOCKED 比例 >20% 持续 3 分钟告警(捕获阻塞早期信号)。 排查方法论再完善,也需要足够的提前量才能执行。

常见陷阱 · 综合

把四类故障的 SOP 混为一条「万能排查清单」。 CPU SOP 的第一步是排除流量,泄漏 SOP 的第一步是确认 GC 模式—— 顺序和内容不可互换。建议团队将四条 SOP 打印为 Runbook 卡片, 告警路由时按主症状选择对应 SOP,而非从步骤 1 通查到步骤 6。

沉淀 · 检查表

10排查沉淀:Runbook 检查表与参考资料

10.1 故障排查 Runbook 检查表

检查项验收标准责任环节
P0 采集纪律重启前已完成最小证据集(jstack + GC 日志 + profiler/dump)值班/on-call
主症状 SOP 选择按 CPU/死锁/泄漏/OOM 主症状进入对应 SOP,未混用步骤值班/on-call
因果链假设复合症状已排列时间先后,明确主因/次因排查负责人
证据分级RCA 报告中每条结论标注 fact/prod/infer排查负责人
CPU:profiler 已采async-profiler ≥60s,Top 3 方法已记录排查执行
死锁:三次 dump间隔 30s 的三份 jstack 已存档排查执行
泄漏:FGC 对照Full GC 回收率已计算,两次 dump 已对比排查执行
OOM:分型确认heap / metaspace / direct / kill 四类之一已确定排查执行
修复验证修复后 72h 内 GC/CPU/线程指标回归基线发布/验证
预防配置HeapDumpOnOOM + GC 日志 + 监控告警已配置SRE/平台
Runbook 更新本次 RCA 已沉淀为新案例或补充现有 SOP排查负责人
Artifact 归档dump/profile/log 已上传对象存储,保留 ≥30 天平台

10.2 四类故障 SOP 速查

故障类型第一动作关键命令终止条件
CPU 飙高确认 QPS 是否同步上涨async-profiler, jstack热点方法或 GC 型已确认
死锁/阻塞连续三次 jstackjcmd Thread.print锁对象或池耗尽已定位
内存泄漏FGC 后看回收率jcmd GC.heap_dump + MATDominator 头部 + GC Root 链已确认
OOM确认分型(heap/meta/direct/kill)hs_err, NMT, K8s Events对应 SOP 已执行

10.4 团队能力建设建议

排查方法论要落地,离不开三项组织能力:定期演练(每季度做一次 heap dump 分析 Drill, 用历史 anonymized dump 练手)、Artifact 归档(每次 P0 的 evidence bundle 入库)、 RCA 评审(修复后一周内做 blameless postmortem,更新 SOP)。 新入职工程师应在入职第二周完成一次模拟排查:给定一份合成 jstack + GC 日志, 要求在 30 分钟内写出根因假设和下一步动作——这比阅读十篇教程更能建立肌肉记忆。

最后强调:JVM 排查的终极目标不是「找到哪个命令」,而是建立可复现、可传承的决策路径。 当团队能在告警触发后 15 分钟内完成定界、30 分钟内完成采集、 2 小时内给出有证据支撑的根因假设,这套方法论才算真正沉淀。 四条 SOP 是骨架,证据分级是约束,复合场景的因果链思维是灵魂—— 三者结合,才能避免在下一个告警夜重复「重启碰运气」的循环。

10.5 延伸阅读与参考资料

以下资料按「机制理解 → 工具实操 → 生产案例」顺序排列。建议先读周志明第 4 章建立工具直觉, 再对照 Oracle Troubleshooting Guide 确认命令参数,最后用 MAT 与 async-profiler 官方文档补齐实操细节。 排查能力的提升是渐进过程,不必一次读完——遇到哪类故障就深读对应章节与官方文档即可,并在演练中反复巩固。

  1. BOOK 周志明,《深入理解 Java 虚拟机(第3版)》,机械工业出版社,2019 —— 第4章(虚拟机性能监控与故障处理)是 JVM 排查工具链的中文最佳参考
  2. DOC JDK 17 Troubleshooting Guide —— Oracle 官方,涵盖 jcmd、JFR、NMT 及各类 OOM 分型说明
  3. DOC JDK 17 jcmd Reference —— jcmd 子命令完整列表与参数说明
  4. TOOL async-profiler —— 生产级 CPU/alloc 采样 profiler,支持火焰图输出
  5. TOOL Eclipse Memory Analyzer (MAT) —— heap dump 分析标准工具,Leak Suspects 和 Dominator Tree
  6. DOC Native Memory Tracking, JDK 17 —— 堆外内存与容器 OOM Kill 排查必备