编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
        • 1. 案例引入
          • 1.1 一次诡异告警
          • 1.2 顺藤摸到工具
          • 1.3 我们要回答什么
        • 2. 工具链全景图
          • 2.1 五大诊断维度
          • 2.2 工具能力矩阵
          • 2.3 选型决策树
        • 3. 命令行四件套
          • 3.1 jps定位进程
          • 3.2 jstat看GC脉搏
          • 3.3 jstack抓线程栈
          • 3.4 jmap导堆快照
        • 4. jcmd瑞士军刀
          • 4.1 一统老命令
          • 4.2 常用子命令
          • 4.3 NMT原生跟踪
        • 5. JFR飞行记录
          • 5.1 黑匣子设计
          • 5.2 事件驱动架构
          • 5.3 启动与采集
          • 5.4 JMC可视化分析
        • 6. Arthas在线诊断
          • 6.1 Attach机制原理
          • 6.2 watch追入参出参
          • 6.3 trace定位耗时
          • 6.4 jad反编译实战
        • 7. 火焰图分析
          • 7.1 火焰图怎么看
          • 7.2 async-profiler
          • 7.3 CPU火焰图实战
          • 7.4 内存分配火焰图
        • 8. 堆内存深析
          • 8.1 MAT支配树
          • 8.2 OQL查询语法
          • 8.3 内存泄漏典型形态
        • 9. 容器与云原生
          • 9.1 容器内的陷阱
          • 9.2 K8s下的诊断
          • 9.3 远程调试通道
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次故障的一生
          • 10.3 设计哲学回扣
          • 10.4 工具链速查表
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

JVM性能诊断工具链

# 07.JVM性能诊断工具链

# 目录介绍

  • 1. 案例引入
    • 1.1 一次诡异告警
    • 1.2 顺藤摸到工具
    • 1.3 我们要回答什么
  • 2. 工具链全景图
    • 2.1 五大诊断维度
    • 2.2 工具能力矩阵
    • 2.3 选型决策树
  • 3. 命令行四件套
    • 3.1 jps定位进程
    • 3.2 jstat看GC脉搏
    • 3.3 jstack抓线程栈
    • 3.4 jmap导堆快照
  • 4. jcmd瑞士军刀
    • 4.1 一统老命令
    • 4.2 常用子命令
    • 4.3 NMT原生跟踪
  • 5. JFR飞行记录
    • 5.1 黑匣子设计
    • 5.2 事件驱动架构
    • 5.3 启动与采集
    • 5.4 JMC可视化分析
  • 6. Arthas在线诊断
    • 6.1 Attach机制原理
    • 6.2 watch追入参出参
    • 6.3 trace定位耗时
    • 6.4 jad反编译实战
  • 7. 火焰图分析
    • 7.1 火焰图怎么看
    • 7.2 async-profiler
    • 7.3 CPU火焰图实战
    • 7.4 内存分配火焰图
  • 8. 堆内存深析
    • 8.1 MAT支配树
    • 8.2 OQL查询语法
    • 8.3 内存泄漏典型形态
  • 9. 容器与云原生
    • 9.1 容器内的陷阱
    • 9.2 K8s下的诊断
    • 9.3 远程调试通道
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次故障的一生
    • 10.3 设计哲学回扣
    • 10.4 工具链速查表

# 1. 案例引入

# 1.1 一次诡异告警

凌晨 3 点,订单服务告警:P99 接口耗时从 80 ms 飙到 2.3 s,CPU 占用率 95%,但 QPS 不升反降。运维同事按惯例先重启——一小时后又来一次,而且每次发作前 GC 频率会先上升。

业务代码近一个月没动,唯一的变更是一周前升级了某个 RPC 客户端的小版本。研发排查到 Redis、DB 都没问题,业务日志看不出名堂。问题落到我们手里:

症状①:CPU 95%,但 top 看不出热点
症状②:P99 飙升 28 倍,P50 几乎没变
症状③:发作前 5 分钟 Young GC 频率从 2 次/min 涨到 30 次/min
症状④:堆内存使用曲线锯齿越来越密集
症状⑤:FullGC 一次都没有
症状⑥:重启 1 小时后必现
1
2
3
4
5
6

光看应用日志,毫无头绪——业务代码什么都没改,怎么会诡异成这样?

# 1.2 顺藤摸到工具

我们按"线上诊断五步法"展开:

第 1 步:先看进程是否还活着         → jps
第 2 步:GC 是不是在拖后腿?        → jstat -gcutil
第 3 步:哪些线程在抢 CPU?         → jstack + top -H
第 4 步:堆里到底囤了什么对象?      → jmap + MAT
第 5 步:具体哪个调用路径热?        → async-profiler 火焰图
1
2
3
4
5

实际现场跑下来:

  • jstat 显示 Eden 区每秒被打满一次,Survivor 频繁晋升老年代
  • jstack 抓到 80% 业务线程都卡在 RpcClient.send → Object.wait
  • jmap -histo 显示 RpcRequest 对象数量是预期的 1000 倍
  • 火焰图显示 RpcClient.serialize 一个方法占了 60% CPU 时间
  • 反编译新版本 RpcClient 一看:新版的连接池被改成"按请求 new"——每次都新建一份序列化器

光靠 IDE + 日志根本看不出这条线。整套工具链就是 Java 程序员的"X 光机 + B 超 + CT"——它们让你看见 JVM 内部正在发生什么。

带着这个故事,我们至少要回答 7 个问题:

① jps/jstat/jstack/jmap 各自看什么?分工边界在哪?        → 第3章
② jcmd 是什么?为什么号称"老命令的统一入口"?             → 第4章
③ JFR 黑匣子和传统 profiler 有什么本质差别?              → 第5章
④ Arthas 凭什么不重启就能改方法、看入参?                  → 第6章
⑤ 火焰图为什么是性能分析的"事实标准"?怎么读?             → 第7章
⑥ MAT 的支配树和直接引用链有什么区别?                    → 第8章
⑦ 容器/K8s 环境下,为什么经典工具会"失灵"?               → 第9章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

第 15 篇要把这 7 个问题全部讲透。读完之后再遇到类似线上事故,你能在 30 分钟内把诊断动作做完一遍并定位到根因,而不是干等着"重启再看"。

本篇路线:

工具链全景 (第2章)
    ↓
命令行四件套 (第3章) ─→ jps/jstat/jstack/jmap
    ↓
jcmd 瑞士军刀 (第4章)  ─→ 替代老命令 + NMT
    ↓
JFR 黑匣子 (第5章)    ─→ 永远在线的低开销采样
    ↓
Arthas (第6章)        ─→ 不重启的运行时神探
    ↓
火焰图 (第7章)        ─→ async-profiler 的双轴 (CPU + 内存)
    ↓
MAT 堆分析 (第8章)    ─→ 支配树 / OQL / 泄漏定位
    ↓
容器与云原生 (第9章)  ─→ 线上的特殊战场
    ↓
综合案例 (第10章)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 2. 工具链全景图

# 2.1 五大诊断维度

JVM 出问题,本质上落到 5 个维度:

┌──────────────────────────────────────────────────────┐
│                    JVM 进程                          │
│                                                      │
│   ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌────────┐ │
│   │  CPU    │  │  内存    │  │  线程    │  │  IO    │ │
│   │  热点    │  │  泄漏    │  │  阻塞    │  │  阻塞  │ │
│   └─────────┘  └─────────┘  └─────────┘  └────────┘ │
│                                                      │
│   ┌────────────────────────────────────────────────┐│
│   │           类加载 / JIT / GC                     ││
│   └────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

每个维度都有一组对应工具。先归类再查工具,比"从工具反推问题"高效得多。

# 2.2 工具能力矩阵

维度 命令行 jcmd JFR Arthas 火焰图 MAT
GC 行为 jstat GC.* ✅ dashboard ⚪ ⚪
CPU 热点 top -H ⚪ ✅ profiler ✅ ⚪
线程阻塞 jstack Thread.print ✅ thread ⚪ ⚪
堆内存 jmap GC.heap_dump ✅ heapdump ⚪ ✅
内存分配 ⚪ ⚪ ✅ ⚪ ✅ ⚪
类加载 jstat -class VM.classloader ✅ sc/sm ⚪ ⚪
JIT 行为 ⚪ Compiler.* ✅ ⚪ ⚪ ⚪
方法调用 ⚪ ⚪ 部分 watch/trace ✅ ⚪
本地内存 ⚪ NMT 部分 ⚪ ⚪ ⚪

✅=擅长 ⚪=不支持

# 2.3 选型决策树

flowchart TD
    A[现象出现] --> B{能登上机器吗?}
    B -->|否| C[依赖 JFR + 监控大盘<br/>事后分析]
    B -->|是| D{需要立即看?}
    D -->|是| E[Arthas 在线诊断]
    D -->|否-允许采样| F[async-profiler 火焰图]
    F --> G{堆问题?}
    G -->|是| H[jmap 导出 + MAT]
    G -->|否| I[JFR 长期录制]
    E --> J{方法级问题?}
    J -->|是| K[watch / trace / monitor]
    J -->|否-看大局| L[dashboard / thread]
1
2
3
4
5
6
7
8
9
10
11
12

实战建议:

  • 紧急排查 → Arthas(5 秒内开干)
  • 性能分析 → async-profiler(最低开销火焰图)
  • 长期观测 → JFR(生产可常开)
  • 内存泄漏 → jmap + MAT
  • GC 问题 → jstat / GC 日志 / JFR

后续章节按这个分类逐一展开。

# 3. 命令行四件套

JDK 自带的 jps/jstat/jstack/jmap 是最古老但永远管用的工具。它们的最大价值是:任何 JDK 安装包里都有,登上机器立即能用,不依赖任何外部组件。

# 3.1 jps定位进程

jps 是 Java 版的 ps:

$ jps -lvm
12345 com.foo.OrderService -Xms4g -Xmx4g -XX:+UseG1GC
12678 sun.tools.jps.Jps -lvm -Dapplication.home=/opt/jdk
13012 com.foo.PaymentService -Xms2g -Xmx2g

# 参数
# -l : 显示主类全限定名
# -v : 显示 JVM 启动参数
# -m : 显示传给 main 的参数
1
2
3
4
5
6
7
8
9

比 ps 强在哪:

  1. 直接显示主类(不是一堆 java -cp ... 看不懂)
  2. 显示 JVM 参数(堆大小、GC 类型一目了然)
  3. 跨用户友好(容器里 PID namespace 隔离时仍可用)

容器里的注意点:jps 依赖 /tmp/hsperfdata_*,如果挂载了 tmpfs 或者 -XX:-UsePerfData 关掉了,jps 就失灵——这时退回 ps -ef | grep java。

# 3.2 jstat看GC脉搏

最高频使用的子命令是 jstat -gcutil:

$ jstat -gcutil 12345 1000 10
# 含义:每 1000 ms 采样一次,连续 10 次

  S0      S1      E       O       M      CCS     YGC    YGCT    FGC    FGCT     GCT
  0.00   89.72   23.45   45.12   97.42   95.31    342    8.521     2    0.412    8.933
  0.00   89.72   45.67   45.12   97.42   95.31    342    8.521     2    0.412    8.933
 89.72    0.00    0.00   45.34   97.42   95.31    343    8.553     2    0.412    8.965
1
2
3
4
5
6
7
列 含义
S0/S1 Survivor 0/1 区使用率 %
E Eden 区使用率 %
O Old 区使用率 %
M Metaspace 使用率 %
CCS 压缩类空间使用率 %
YGC Young GC 次数
YGCT Young GC 累计耗时(秒)
FGC Full GC 次数
FGCT Full GC 累计耗时
GCT 总 GC 时间

怎么读:

  • E 在 0~100% 之间快速循环 → Young GC 正常
  • O 持续上涨且 FGC 后下不去 → 内存泄漏
  • YGC 次数飞涨但 YGCT 增量小 → Eden 设置过小
  • M 持续上涨 → 类加载泄漏(CGLIB / 热部署常见)

回扣案例:第 1 章那个"YGC 频率从 2 次/min 涨到 30 次/min"就是用这条命令看出来的。

# 3.3 jstack抓线程栈

jstack 的真正用法是抓多份做时序对比:

# 5 秒内连续抓 3 份
for i in 1 2 3; do
    jstack 12345 > stack-$i.txt
    sleep 2
done

# 然后对比哪些线程一直卡在同一行——那就是问题点
1
2
3
4
5
6
7

线程状态速记:

状态 代码层语义 常见栈顶
RUNNABLE 真在跑 OR 阻塞在 IO socketRead0 / 业务方法
BLOCKED synchronized 等锁 monitorenter
WAITING wait/park Object.wait / LockSupport.park
TIMED_WAITING sleep / 限时 wait Thread.sleep / parkNanos

关键技巧:

# 找出 CPU 最高的 Java 线程
top -H -p 12345               # 拿到 nid(十进制)
printf "%x\n" 12567           # 转 16 进制

# 在 jstack 输出里搜 nid=0x3117 → 直接定位到罪犯
1
2
3
4
5

死锁检测:jstack 末尾会自动打印 Found one Java-level deadlock:,直接给出涉及锁和线程,省得自己看。

# 3.4 jmap导堆快照

jmap 现在主要剩两个用法:

# 直方图(轻量,无需 dump 整个堆)
$ jmap -histo:live 12345 | head -20

 num     #instances         #bytes  class name
   1:       1234567      123456789  [B
   2:        345678       45678901  java.lang.String
   3:         12345        1234567  com.foo.RpcRequest    ← 案例里的罪犯
   ...

# 完整 dump(重量,会触发一次 Full GC,生产慎用)
$ jmap -dump:format=b,file=heap.hprof,live 12345
1
2
3
4
5
6
7
8
9
10
11

生产环境的告警:jmap -dump 在 8GB 堆上能 STW 数十秒。生产环境应该用 -XX:+HeapDumpOnOutOfMemoryError 让 JVM 自己在 OOM 时写出,而不是手动触发。

对比 jcmd 的 GC.heap_dump:jcmd 的 dump 走相同底层 API,但不会让 jmap 进程的 PID 出现在系统日志——更适合自动化脚本。

# 4. jcmd瑞士军刀

# 4.1 一统老命令

JDK 8 之后官方推荐用 jcmd 替代 jstack/jmap/jinfo 等老工具。为什么:

疑惑:老工具用得好好的,为什么还要再造一个 jcmd?

论证:

  1. 协议统一:老工具各自走 attach API、PerfData、SA-Agent 三种通道,互相不一致;jcmd 全部走 attach API
  2. 远程支持:jcmd 子命令可以通过 JMX 远程触发,老工具只能本地
  3. 更安全:老命令 jmap -F(强制模式)会暂停 JVM 数十秒,jcmd 没有 -F
  4. 能力可扩展:新功能(NMT、JFR、Compiler.*)只在 jcmd 提供
  5. 官方明示:JDK 21 已把 jstack 标记为 deprecated,给出的迁移路径就是 jcmd <pid> Thread.print

结论:jcmd 不是简单的"老命令套壳",而是JVM 诊断协议的统一收口。今天写新脚本,应该首选 jcmd。

# 4.2 常用子命令

# 列出所有可用子命令
$ jcmd 12345 help

# 线程栈(替代 jstack)
$ jcmd 12345 Thread.print

# 堆 dump(替代 jmap)
$ jcmd 12345 GC.heap_dump /tmp/heap.hprof

# 类直方图(替代 jmap -histo)
$ jcmd 12345 GC.class_histogram

# 主动触发 GC(替代 jmap -histo:live)
$ jcmd 12345 GC.run

# JVM 参数信息(替代 jinfo)
$ jcmd 12345 VM.flags
$ jcmd 12345 VM.system_properties

# JIT 编译统计
$ jcmd 12345 Compiler.codecache
$ jcmd 12345 Compiler.queue

# 类加载器树
$ jcmd 12345 VM.classloaders
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 4.3 NMT原生跟踪

Native Memory Tracking 是 jcmd 独有的杀器,用来排查"堆没满,但进程内存爆炸"问题。

# 启动时开启
java -XX:NativeMemoryTracking=summary -jar app.jar

# 查询
$ jcmd 12345 VM.native_memory summary

Total: reserved=4892428KB, committed=4892428KB
   - Java Heap (reserved=4194304KB, committed=4194304KB)
   - Class (reserved=1099912KB, committed=78912KB)
   - Thread (reserved=132588KB, committed=132588KB)        ← 线程栈空间
   - Code (reserved=255168KB, committed=42368KB)           ← CodeCache
   - GC (reserved=287736KB, committed=287736KB)
   - Compiler (reserved=185KB, committed=185KB)
   - Internal (reserved=2456KB, committed=2456KB)
   - Symbol (reserved=21672KB, committed=21672KB)
   - Native Memory Tracking (reserved=2789KB, committed=2789KB)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

为什么重要:

Linux 看到的进程 RSS = Java Heap + Metaspace + Thread Stacks 
                    + CodeCache + GC 元数据 + DirectByteBuffer
                    + JNI 分配 + glibc 内存碎片
1
2
3

只有 NMT 能拆解前 6 项;后两项要靠 pmap + jemalloc profile。

经典场景:堆 4 GB,物理内存 12 GB,多出来的 8 GB 哪儿去的?NMT 一查,发现 Thread 占了 6 GB——某处线程池忘了限制大小,开了 6000 个线程,每个 1 MB 栈。

# 5. JFR飞行记录

# 5.1 黑匣子设计

JFR (Java Flight Recorder) 借鉴飞机黑匣子设计:永远在录、覆盖式存储、关键时刻回看。

疑惑:传统 profiler 也能采样,为什么需要 JFR?

论证:

  1. 开销极低:JFR 默认配置开销 < 1%,传统 sampling profiler 起步 5%
  2. 生产可常开:阿里、亚马逊都把 JFR 默认打开,事故时直接拉文件
  3. JVM 内核级集成:可以采到 GC 内部事件、类加载、JIT 编译动作——这些都是用户态 profiler 看不到的
  4. 事件粒度可调:从 100 ns(细) 到 1 s(粗),按需折中
  5. OpenJDK 11 起免费:之前是 Oracle 商业特性,现在所有 JDK 11+ 都自带

结论:JFR 是生产环境监控的事实标准——它解决了"问题发生那一刻没人在采样"的根本痛点。

# 5.2 事件驱动架构

JFR 的核心抽象是"事件":JVM 内部预定义了 ~150 种事件,每种事件有自己的字段和触发频率:

事件类型分类:
├── 持续型(Duration)   :方法执行、GC 暂停、锁等待
├── 即时型(Instant)    :类加载、异常抛出、线程启动
├── 采样型(Sample)     :Method Profiling 每 10 ms 采样一次
└── 周期型(Periodic)   :CPU 使用率、堆使用率每秒采样
1
2
3
4
5

自定义事件:

import jdk.jfr.*;

@Name("com.foo.OrderProcessed")
@Label("Order Processed")
@Category("Business")
public class OrderEvent extends Event {
    @Label("Order ID")
    long orderId;
    
    @Label("Amount") @DataAmount(DataAmount.BYTES)
    long amount;
}

// 业务代码
OrderEvent e = new OrderEvent();
e.begin();
processOrder(...);
e.orderId = orderId;
e.amount = amount;
e.commit();             // 写入 JFR 缓冲区
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

业务事件和 JVM 事件走同一套录制管道——这是 JFR 区别于其他 APM 的关键。

# 5.3 启动与采集

两种启动方式:

# 方式一:进程启动时开启(默认配置 default.jfc)
java -XX:+FlightRecorder \
     -XX:StartFlightRecording=duration=60s,filename=app.jfr \
     -jar app.jar

# 方式二:jcmd 运行中触发
jcmd 12345 JFR.start name=urgent duration=2m filename=urgent.jfr
jcmd 12345 JFR.dump name=urgent filename=now.jfr
jcmd 12345 JFR.stop name=urgent
1
2
3
4
5
6
7
8
9

两套预设配置:

配置 开销 用途
default.jfc < 1% 生产长期开启
profile.jfc 2~3% 故障重现期短期开

# 5.4 JMC可视化分析

JFR 文件用 JDK Mission Control (JMC) 打开:

JMC 主要面板:
├── 概览:CPU/堆/GC 时间轴
├── Java Application:方法热点、内存分配热点
├── JVM 内部:JIT/类加载/CodeCache
├── 环境:系统 CPU、内存、IO
└── 事件浏览器:自定义事件 + 全部原始事件
1
2
3
4
5
6

杀手级特性:Automated Analysis Results——JMC 内置规则引擎,自动检测出"过度同步"、"低效 GC 配置"、"潜在内存泄漏"等问题,直接给修复建议。这是 Oracle 把 20 年的运维经验固化在工具里。

# 6. Arthas在线诊断

# 6.1 Attach机制原理

Arthas 是阿里开源的运行时诊断利器。它的神奇之处:不重启进程,就能改方法、查入参、反编译类。

疑惑:Arthas 凭什么能做到?

论证:

Arthas 客户端                    目标 JVM 进程
    │                                │
    │  attach 命令                   │
    ├─────────────────────────►     │
    │   (通过 Attach API)            │
    │                                │
    │                       ┌────────▼────────┐
    │                       │ Instrumentation │
    │                       │     Agent       │
    │                       └─────────────────┘
    │                                │
    │                       通过字节码增强
    │                       动态修改方法
    │                                │
    └────── Telnet/HTTP ◄────────────┘
              交互
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

底层依赖三件套:

  1. Attach API:JDK 5 起的 com.sun.tools.attach.VirtualMachine,可让外部进程 attach 到 JVM
  2. Instrumentation API:JDK 5 起的 java.lang.instrument,允许 Agent 修改已加载类的字节码
  3. ASM 字节码框架:Arthas 内部用 ASM 织入"前置/后置/异常"切面

结论:Arthas 不是黑魔法,它走的是 JVM 官方支持的 Agent 机制(详见第 33 篇)。它的功能上限受 Instrumentation 约束——比如不能修改方法签名,只能改方法体。

# 6.2 watch追入参出参

$ arthas-boot.jar
[INFO] Arthas remote attached, success!
$ watch com.foo.OrderService createOrder '{params, returnObj}' -x 3

method=com.foo.OrderService.createOrder location=AtExit
ts=2026-05-29 14:32:11; [cost=12.337ms] result=@ArrayList[
    @Object[][
        @Long[123456],
        @String[张三],
        @BigDecimal[99.5],
    ],
    @Order[id=123456, status=PAID, amount=99.5],
]
1
2
3
4
5
6
7
8
9
10
11
12
13

参数说明:

  • '{params, returnObj}' 是 OGNL 表达式,决定打印什么
  • -x 3 控制对象展开深度
  • -e 只在抛异常时打印
  • -c 30 30 秒后自动停止

实战用法:

# 只看耗时 > 100ms 的调用
watch com.foo.OrderService createOrder '{params, returnObj}' '#cost > 100'

# 只看抛了 RpcException 的调用
watch com.foo.RpcClient send '{params, throwExp}' -e
1
2
3
4
5

# 6.3 trace定位耗时

$ trace com.foo.OrderService createOrder

`---ts=2026-05-29 14:35:23;thread_name=http-nio-8080-exec-1;id=42;is_daemon=true;priority=5;
    `---[120.345ms] com.foo.OrderService:createOrder()
        +---[2.123ms] com.foo.OrderValidator:validate() #45
        +---[18.567ms] com.foo.UserDao:findById() #46
        +---[95.123ms] com.foo.RpcClient:send() #47        ← 罪魁祸首
        `---[4.532ms] com.foo.OrderDao:save() #48
1
2
3
4
5
6
7
8

在 1 秒内你就知道:120 ms 的总耗时里,95 ms 花在 RpcClient.send 上。继续往里钻:

$ trace com.foo.RpcClient send -n 5
# trace 5 次后自动停止,避免影响生产
1
2

# 6.4 jad反编译实战

回到第 1 章案例——升级了 RPC 客户端版本,但本地 jar 已被打包到镜像,没法用 IDE 反编译。Arthas 直接现场反编译运行中的类:

$ jad com.foo.RpcClient send

/*
 * Decompiled with CFR.
 */
public Object send(RpcRequest req) {
    Serializer serializer = new Kryo();        // ← 看到了!每次 new
    byte[] data = serializer.serialize(req);
    ...
}

# 与 git 上的旧版对照:
public Object send(RpcRequest req) {
    Serializer serializer = SERIALIZER_POOL.borrow();   // 旧版有池
    try { ... } finally { SERIALIZER_POOL.release(); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

问题彻底定位:新版去掉了序列化器池,每次请求都创建一个 Kryo(Kryo 初始化成本极高,且大量临时对象进 Eden)。后续解决也很简单——降级或者打补丁。

# 7. 火焰图分析

# 7.1 火焰图怎么看

火焰图是 Brendan Gregg 发明的可视化形式,已成事实标准:

                                                  
   ┌───────────────────────────────────┐           
   │           main()                  │   ← 顶层 60% 宽
   ├──────────┬─────────────┬──────────┤           
   │ doA()    │   doB()     │  doC()   │           
   ├──────────┼──────┬──────┼──────────┤           
   │ ...      │ a()  │ b()  │   ...    │           
   └──────────┴──────┴──────┴──────────┘           
                  ↑       ↑                        
                  │       │                        
               热点候选  另一热点                    
1
2
3
4
5
6
7
8
9
10
11

两条核心规则:

  1. 宽度 = CPU 时间占比(或样本数占比),越宽越热
  2. 纵向 = 调用栈,自下而上

怎么找热点:盯着每个水平方向最宽的"高原",那就是 CPU 在那里逗留最久的路径。

# 7.2 async-profiler

async-profiler 是当前最好的 Java profiler:

维度 async-profiler jstack 抓栈 JFR Method Profiling
采样原理 perf_events + AsyncGetCallTrace 全量 dump 栈 安全点采样
开销 < 1% 5%~10% 1%~3%
是否有"安全点偏差" 否 否 是
内核栈 支持 不支持 不支持
内存分配 profile 支持 不支持 支持

关键优势:用 AsyncGetCallTrace(HotSpot 内部 API)绕开了"安全点偏差"——传统 profiler 只能在安全点采样,导致在安全点之间运行的代码(如紧凑循环、大数组拷贝)永远抓不到,profile 失真。

# 7.3 CPU火焰图实战

# 下载并启动采样 30 秒
$ ./profiler.sh -d 30 -f cpu.html 12345

# 浏览器打开 cpu.html
1
2
3
4

回到案例:cpu.html 里我们看到一个 60% 宽的高原:

java/lang/Thread.run
└── http-nio request handler
    └── OrderController.create
        └── OrderService.createOrder
            └── RpcClient.send                           ← 占 60%
                └── RpcClient.serialize          ── 55%
                    └── com.esotericsoftware.kryo.Kryo.<init>  ── 50% ← 罪魁
1
2
3
4
5
6
7

结论一目了然:每次 send 都构造一个新的 Kryo,Kryo 构造里要扫描类、注册序列化器,单次 ms 级别——和 jad 反编译看到的现象完全对应。

# 7.4 内存分配火焰图

async-profiler 还能采"分配热点":

$ ./profiler.sh -d 30 -e alloc -f alloc.html 12345
1

输出告诉你每行 Java 代码累计分配了多少字节:

                                                  
   ┌─────────────────────────────────┐             
   │         GC eden 占用               │             
   ├──────────┬──────────────────────┤             
   │ Kryo.new │ RpcRequest.new       │             
   │ (90%)    │ (10%)                │             
   └──────────┴──────────────────────┘             
1
2
3
4
5
6
7

Kryo.new 占了 90% 的分配字节——这就是为什么 Eden 一秒一爆、YGC 飙到 30 次/min 的根因。CPU 火焰图 + 分配火焰图叠加,问题瞬间穿透。

# 8. 堆内存深析

当 jmap 导出的 heap dump 摆在面前时,工具就是 MAT (Memory Analyzer Tool)。

# 8.1 MAT支配树

支配树(Dominator Tree)是 MAT 最核心的概念。

疑惑:直接看引用链不够吗?为什么要发明"支配树"?

论证:

考虑两个对象 A、B 都引用大数组 X:

   A ──┐
       ├──► X (100MB)
   B ──┘
1
2
3

如果只看 A 的"保留集"——就是"删除 A 后能被 GC 回收的所有对象"——发现X 不算,因为 B 还引用 X。同理 B 的保留集也不含 X。两个对象单看都"不大",但 X 实际占 100MB 没人能放手。

支配树解决了这个问题。一个对象 A 支配 对象 X,当且仅当所有从 GC Root 到 X 的路径都必经过 A。在支配树视图下:

     GC Root
        │
        ▼
  Common Ancestor (双方都依赖的上游)
        │
        ▼
        X (100MB)  ← 真正应该被关注的"重头"
1
2
3
4
5
6
7

结论:支配树告诉你"如果某个对象消失,能省多少内存"——这才是真正可行动的指标。MAT 默认按 Retained Heap 降序——直接看头部即可定位"内存大户"。

# 8.2 OQL查询语法

MAT 内置类 SQL 的对象查询语言:

-- 找所有 String 内容长度超过 1000 的
SELECT s, s.toString() FROM java.lang.String s 
WHERE s.value.@length > 1000

-- 找所有 ArrayList 容量超过 10000 的
SELECT a, a.size, a.elementData.@length 
FROM java.util.ArrayList a 
WHERE a.elementData.@length > 10000

-- 找特定类的所有实例数和总大小
SELECT classof(o).@name AS klass, COUNT(*) AS num
FROM com.foo.RpcRequest o
1
2
3
4
5
6
7
8
9
10
11
12

OQL 是定位具体业务对象异常的利器——堆里几百万 String,肉眼根本看不出哪条有问题,OQL 一查就定位到。

# 8.3 内存泄漏典型形态

形态 1:静态集合越长越大

public class Cache {
    static Map<String, byte[]> cache = new HashMap<>();    // 静态,永不释放
    public static void put(String k, byte[] v) {
        cache.put(k, v);                                   // 只 put 不 evict
    }
}
1
2
3
4
5
6

MAT 表现:HashMap → Node[] → 无数 Node 实例。Retained Heap 飙升。

形态 2:ThreadLocal 忘记 remove

private static ThreadLocal<BigContext> CTX = new ThreadLocal<>();

public void handle() {
    CTX.set(new BigContext());          // 进来设
    doBusiness();
    // 走的时候忘了 CTX.remove()
}
1
2
3
4
5
6
7

线程池场景下尤其致命——线程不死,ThreadLocalMap 不清,内存只增不减。MAT 表现:每个 Worker 线程的 ThreadLocalMap.Entry[] 越来越大。

形态 3:监听器/回调注册不解绑

eventBus.register(this);     // register 后引用进入 EventBus
// 业务结束没 unregister,this 永远活着
1
2

形态 4:内部类持有外部类引用

class Outer {
    void schedule() {
        executor.scheduleAtFixedRate(new Runnable() {     // 匿名内部类
            @Override public void run() { ... }            // 隐式持有 Outer.this
        }, 0, 1, MINUTES);
    }
}
// Outer 实例理论上能 GC,但 ScheduledFuture 持有 Runnable,Runnable 持有 Outer.this
1
2
3
4
5
6
7
8

四种形态加起来覆盖了 80% 的 Java 内存泄漏。MAT 的"Leak Suspects Report"会自动归类到这几种。

# 9. 容器与云原生

# 9.1 容器内的陷阱

疑惑:传统工具到容器里就出问题,为什么?

论证:

  1. CPU 数感知错误:JDK 8u131 之前,Runtime.availableProcessors() 看到的是宿主机核心数,不是容器 cgroup 限制。导致 ForkJoinPool、ParallelStream 开线程过多
  2. 内存感知错误:同样问题,旧 JDK 用宿主机内存算 MaxRAMFraction,分到容器里就 OOM
  3. PID namespace:容器内 PID 1 是应用,主机上 PID 不同——jps 给的是宿主机 PID 的话工具串不起来
  4. /tmp 隔离:jps 依赖 /tmp/hsperfdata_*,容器和宿主机 /tmp 不互通
  5. Attach API 失败:跨容器 attach 必须共享 PID namespace(Pod 内 sidecar 模式才行)

结论:JDK 10 之后引入 -XX:+UseContainerSupport(默认开启),上述前两个问题缓解;但 PID namespace、/tmp 隔离、attach 跨容器仍然要靠运维设计解决。

# 9.2 K8s下的诊断

推荐架构:

┌─────────────── Pod ───────────────┐
│  ┌────────────┐   ┌────────────┐ │
│  │ App        │   │ Diag       │ │
│  │ Container  │   │ Sidecar    │ │
│  │            │   │ (Arthas)   │ │
│  └────────────┘   └────────────┘ │
│       共享 PID namespace          │
│       共享 /tmp volume            │
└──────────────────────────────────┘
1
2
3
4
5
6
7
8
9

Sidecar 容器装好 Arthas/工具链,通过 shareProcessNamespace: true 共享 PID,应用容器保持精简。出问题时 kubectl exec 进 sidecar 即可。

或者更轻:使用 kubectl debug 临时注入诊断容器:

kubectl debug -it pod/order-service-abc123 \
    --image=alibabacloud/arthas:latest \
    --target=order-service \
    --share-processes
1
2
3
4

# 9.3 远程调试通道

# JVM 启动开 JMX
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9010
-Dcom.sun.management.jmxremote.authenticate=false   # 仅内网
-Dcom.sun.management.jmxremote.ssl=false

# 远程 jcmd / jconsole / VisualVM 连接 9010 即可
1
2
3
4
5
6
7

JFR 远程 dump:

# 让 K8s 里的 Pod 把 JFR 文件吐到 OSS
jcmd 1 JFR.dump filename=/tmp/dump.jfr
kubectl cp order-pod:/tmp/dump.jfr ./dump.jfr
1
2
3

JFR 文件离线分析,不占线上 CPU——这是 JFR 在云原生场景下的核心价值。

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章那次诡异告警,第 1 章列了 7 个疑问,逐条作答:

疑问 ①:jps/jstat/jstack/jmap 各自看什么? → 第 3 章。jps 找进程、jstat 看 GC 心电图、jstack 抓线程栈、jmap 看堆快照。四件套是登上机器后的肌肉记忆。回到案例:用 jstat 看到 YGC 异常飙升,用 jstack 看到大量线程在 RPC 调用栈上,用 jmap -histo 看到 RpcRequest 实例数 1000 倍——三招就锁定方向。

疑问 ②:jcmd 凭什么是统一入口? → 第 4 章。它把老命令统一到 attach API,加上独占的 NMT 和 JFR 控制能力,是新脚本的首选。老命令未来会被 deprecated。

疑问 ③:JFR 黑匣子和传统 profiler 本质差别? → 第 5 章。JFR 开销极低(< 1%)、JVM 内核级集成、生产可常开。它不是"问题发生才采",而是"永远在采"——这从根本上改变了诊断范式。

疑问 ④:Arthas 凭什么不重启就能改方法? → 第 6 章。Arthas = Attach API + Instrumentation + ASM。走的是 JVM 官方 Agent 通道,没有黑魔法。但受 Instrumentation 限制,不能改方法签名。回到案例:jad 命令现场反编译 RpcClient,找出"序列化器没池化"的真相。

疑问 ⑤:火焰图怎么看? → 第 7 章。横向宽度看 CPU 占比,纵向看调用栈。盯最宽的高原。async-profiler 解决了安全点偏差,是当前最准的工具。回到案例:火焰图 60% 宽的高原指向 Kryo.<init>,与 jad 反编译结果对照——闭环。

疑问 ⑥:MAT 支配树和直接引用链区别? → 第 8.1 节。直接引用链让你看见"谁引用我";支配树让你看见"删了我能省多少"。后者才是泄漏定位的真正抓手。

疑问 ⑦:容器/K8s 下经典工具为什么失灵? → 第 9 章。CPU/内存感知、PID namespace、/tmp 隔离、attach 跨容器是四大坑。解决方案:sidecar 共享 PID + 共享 /tmp,或者 kubectl debug 临时注入。

根本症结:那次故障的根因是新版 RpcClient 砍掉序列化器池,看似优雅的简化引爆了对象分配 → Eden 满 → 频繁 YGC → 应用线程被 STW 拖累 → P99 飙升这条因果链。单凭日志根本看不出来,必须用工具把 JVM 内部展开看。整个排查路径:

jstat → 发现 YGC 异常 (3.2)
   ↓
jstack → 发现线程都堵在 RpcClient.send (3.3)
   ↓
jmap -histo → 发现 RpcRequest 实例数 1000x (3.4)
   ↓
async-profiler → 火焰图指向 Kryo.<init> (7.3)
   ↓
Arthas jad → 反编译看到去掉了序列化器池 (6.4)
   ↓
async-profiler -e alloc → Kryo 占 90% 分配 (7.4)
   ↓
根因定位 + 降级回滚
1
2
3
4
5
6
7
8
9
10
11
12
13

整个过程 30 分钟内可以结束——前提是工具链熟练。

# 10.2 一次故障的一生

把这次故障从触发到回滚串成完整时间线,回扣 01~15 篇要素:

T-1 周  RpcClient 升级,序列化器池被去掉
        [33篇] Maven 依赖变更,class 重新进入 Metaspace
        [02篇] 类加载器加载新版 RpcClient.class

T 0   流量涌入触发热点路径
        [13篇] send 方法字节码进入解释器
        [14篇] 计数器到阈值,C1→C2 编译

T+5 min 业务积累,每次 send 都 new Kryo
        [01篇] 大量临时对象进入 Eden
        [03篇] Eden 满触发 Minor GC
        [03篇] Survivor 来不及容纳,Object 提前晋升老年代

T+30 min YGC 频率从 2 → 30 次/min
        [本篇 §3.2] jstat -gcutil 看到 E 列高频清零
        [本篇 §7.4] alloc 火焰图显示 Kryo 分配占 90%

T+45 min 应用线程因 STW 累积排队
        [本篇 §3.3] jstack 看到大量 BLOCKED 在 RPC 调用
        [08篇] 部分线程进入锁竞争,锁升级到重量级
        [09篇] CPU 缓存被频繁失效,性能进一步退化

T+50 min 告警触发,运维介入
        [本篇 §3.1] jps 拿到 PID
        [本篇 §6.x] Arthas attach 上去
        [本篇 §6.4] jad 反编译,定位代码差异

T+60 min 根因明确,回滚版本
        [02篇] 类加载器卸载旧 RpcClient(实际上要等元空间 GC)
        [本篇 §8] MAT 复盘 dump,确认无残留泄漏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

这条时间线串起本册前 14 篇 80% 的核心知识点——从类加载到 GC 到 JIT 到锁,最后由本篇的工具链把它们全部"看见"。

# 10.3 设计哲学回扣

跳出技术细节,提炼三条会贯穿全册的设计哲学:

  1. 可观测性先于优化:你看不见的东西没法优化。Java 用 jstat / JFR / NMT / Arthas 把虚拟机内部一层层"剖开"——这种"暴露内部状态"的设计哲学和卷一第 1 篇里堆/栈/方法区"井井有条"的内存模型是一脉相承的:先把状态明确建模,再让外部能查询。这同样体现在 ConcurrentHashMap 暴露 mappingCount、ThreadPoolExecutor 暴露 getActiveCount/getPoolSize 等设计上。

  2. 代价分层:不同工具有不同代价(jstat ~ 0%、JFR < 1%、async-profiler 1%、jstack 5%、jmap dump 数十秒 STW)。没有"最强"的工具,只有"最适合当前代价预算"的工具。这种"分层"思想和 14 篇的解释器/C1/C2、03 篇的 Young/Old 分代、08 篇的偏向锁/轻量级锁/重量级锁是同构的。

  3. 采样优于全量:JFR、async-profiler、jstat 都是采样型工具。采样在统计学意义上足够准、代价线性可控、永远在线。这种"以小博大"的思想后续在 38 篇 LongAdder 分段、05 篇 String.intern 概率优化、20 篇 ConcurrentHashMap.size 估算里会反复出现。

# 10.4 工具链速查表

最后一张表,建议截图保存:

场景 首选工具 命令骨架 章节
进程列表 jps jps -lvm §3.1
GC 心电图 jstat jstat -gcutil <pid> 1000 §3.2
线程快照 jstack / jcmd jcmd <pid> Thread.print §3.3 / §4.2
堆直方图 jmap jmap -histo:live <pid> §3.4
堆 dump jcmd jcmd <pid> GC.heap_dump f.hprof §4.2
JIT 编译统计 jcmd jcmd <pid> Compiler.codecache §4.2
原生内存 NMT jcmd <pid> VM.native_memory summary §4.3
长期录制 JFR JFR.start duration=2m filename=... §5.3
离线分析 JMC 打开 .jfr 文件 §5.4
在线诊断 Arthas watch / trace / jad §6
CPU 热点 async-profiler ./profiler.sh -d 30 -f cpu.html <pid> §7.3
分配热点 async-profiler ./profiler.sh -e alloc ... §7.4
内存泄漏 MAT 支配树 + Leak Suspects §8
容器诊断 kubectl debug sidecar 共享 PID + /tmp §9.2

掌握诊断工具链,是从"看代码就发慌"进阶到"线上从容应对"的分水岭。下一篇我们顺着"工具看到了 OOM,那 OOM 到底有几种?"这条线,进入 第 16 篇:OOM 八大现场全景剖析——把堆、元空间、直接内存、栈、native 等八种不同的 OutOfMemoryError 一次讲透。

上次更新: 2026/06/10, 11:13:41
JIT编译与去优化机制
OOM八大现场全景剖析

← JIT编译与去优化机制 OOM八大现场全景剖析→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式