1四症并发:一次典型的 JVM 疑难告警夜
凌晨 02:17,某订单中台 Pod 连续触发三条告警:CPU 使用率 96%、 Full GC 频率异常、接口 P99 从 45ms 飙到 8s。 值班工程师第一反应是扩容——加了两个实例后,新实例 CPU 也在 10 分钟内爬升到 85%, 而 QPS 仅比平日高出 20%。
进一步查看监控,发现老年代占用持续线性上升,Young GC 后回收效果越来越差;
同时 jstack 显示大量线程处于 BLOCKED 或 WAITING,
其中一组线程互相等待同一把 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 分钟内无法完成采集,应升级并申请「保留异常实例」专项审批,而非默认重启。
2.2 证据分级与因果链
排查过程中所有结论必须标注证据等级:fact(工具输出或官方文档可直接核验)、 prod(生产环境反复验证的经验模式)、 infer(基于机制推导、待压测或对照实验确认)、 trap(高频误判,应主动排除)。 当 CPU、内存、线程三类指标同时异常时,建议按时间先后而非告警优先级 排列因果链:通常内存压力先行(分配/GC),CPU 异常次之(GC 或自旋),线程阻塞最后放大(停顿窗口内锁等待)。
因果链的画法有一个实用技巧:在 Grafana 上把 jvm_memory_bytes_used、
process_cpu_seconds_total、http_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 同级优先级。
3CPU 飙高:从「谁在用 CPU」到根因收敛
CPU 飙高是线上最高频的 JVM 告警之一,但CPU 高本身不是根因,只是资源饱和的信号。 排查的第一步永远是区分:是业务线程在算(热点方法、正则、序列化), 还是GC 线程在回收(内存压力),还是自旋/空转(锁未优化、错误重试逻辑)。 三者的证据集和修复手段完全不同,混淆它们会导致「加了 CPU 还是高」的困境。
3.1 根因三分法
计算型热点:async-profiler 火焰图中业务方法占据 Top N,且与 QPS 正相关。
常见根因包括:热路径上的 JSON 序列化、复杂正则、Stream 链式操作在循环内、
未缓存的配置解析。修复方向是代码层优化,而非 JVM 参数。
一个典型误判是看到 Jackson writeValue 在 Top 就加对象池——
应先确认是「调用次数过多」还是「单次序列化过慢」,前者靠减少调用,后者靠换更快的序列化器或预生成。
GC 型 CPU:火焰图中 GC、G1、Parallel 相关帧占比超过 30%(合成阈值,视服务而定),
同时 GC 日志显示回收频率异常。修复方向是内存侧(泄漏、堆过小、分配速率过高)。
注意 GC 型 CPU 在监控上有一个迷惑特征:应用线程可能大量处于 SAFEPOINT 或 WAITING FOR GC,
QPS 下降但 CPU 仍高——因为 GC 线程在满负荷运转。此时 top 看线程名含 GC 的线程即可快速确认。
自旋/阻塞型:CPU 高但 profiler 显示大量 Unsafe.park、
Object.wait 或锁相关帧,jstack 中 BLOCKED 线程比例高。
修复方向是锁粒度、线程池配置、消除无效重试。
自旋型还有一个隐蔽变种:忙等重试——代码在循环中 Thread.sleep(0) 或
无退避的 CAS 重试,profiler 显示业务方法占比高但 jstack 中线程是 RUNNABLE,
需结合 WALL 模式 profiler 与 CPU 模式交叉确认。
3.3 与发布/变更的关联
CPU 飙高若发生在发布之后 30 分钟内,应优先查变更而非 JVM 本身: 新版本引入的正则灾难、循环内的远程调用、线程池参数被改小、或依赖库升级导致的序列化行为变化。 对比发布前后同一 QPS 下的 CPU 基线,若差值超过 20%(合成参考), 回滚验证往往比深入 profiler 更快。这不是偷懒,而是变更归因的优先级高于代码热点归因—— 除非回滚后 CPU 仍高,才进入完整 SOP。
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 60s | Top 3 方法已识别 | 火焰图平坦 → 考虑容器 throttling |
| 5 | 关联线程栈 | jcmd <pid> Thread.print | 热点线程栈与火焰图一致 | BLOCKED 多 → 转 Ch4 |
| 6 | 形成假设并验证 | 代码审查 / 灰度修复 | CPU 回落且 QPS 不变 | 无效 → 补充 dump 回到步骤 4 |
在容器 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 | 连续三次线程 dump | jcmd <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 采样告警。
ClassLoader 或 ThreadLocal,应优先怀疑对应模式。
5.3 内存泄漏 SOP
| 步骤 | 动作 | 命令 / 工具 | 通过标准 | 证伪信号 |
|---|---|---|---|---|
| 1 | 确认 GC 异常模式 | GC 日志 / Prometheus jvm_memory | 老年代单调升或 FGC 无效 | FGC 后回落 → 调堆非泄漏 |
| 2 | 触发 Full GC 对照 | jcmd <pid> GC.run | 回收率 <10% 为泄漏嫌疑 | 回收 >50% → 峰值非泄漏 |
| 3 | 采集 heap dump | jcmd <pid> GC.heap_dump /tmp/h1.hprof | dump 文件可打开 | dump 本身 OOM → 增大堆或 live dump |
| 4 | MAT Dominator 分析 | Leak Suspects + Dominator Tree | Top retained 对象已定位 | 分散小对象 → 可能是 churn |
| 5 | 沿 GC Root 链追溯 | MAT Path to GC Roots | 持有方代码路径已确认 | Root 是 JNI → 查 native |
| 6 | 修复并长期验证 | 发布后观察 72h GC 曲线 | 老年代稳定锯齿形 | 仍上升 → 二次 dump 对比 |
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 |
| 2 | Java heap OOM | 自动 dump + MAT | 转 Ch5 泄漏 SOP | 单次大对象 → 调 -Xmx |
| 3 | Metaspace OOM | jcmd VM.metaspace + 类加载日志 | 类加载数异常 | 类数稳定 → 查 MaxMetaspaceSize |
| 4 | Direct OOM | -XX:NativeMemoryTracking=summary | Internal/Other 段异常 | 堆内正常 → 必查 NIO/Netty |
| 5 | Container Kill | RSS 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 头部出现大量 ClassLoader 或
Class 实例,应沿 ClassLoader 引用链查谁持有了旧 Loader。
修复:限制动态代理范围、关闭不必要的 AOP、确保自定义 ClassLoader 在不用时显式 close。
6.3 Unable to create new native thread
第四种 OOM 变体是无法创建新线程——进程 hits 操作系统线程数上限或内存不足以分配线程栈。
在容器中表现为 Pod 无法响应新连接,jstack 可能无法 Attach。
判据:日志含 Unable to create new native thread;ulimit -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_object、
G1CollectedHeap::evacuate_collection_set 等 GC 内部方法,
且合计占比超过 30% 时,判读为GC 型 CPU,应转入内存排查而非优化业务代码。
若 Top 帧为 java.util.regex.Pattern$Branch.match 或
com.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 即可。
Found Java-level deadlock