JVM 性能诊断和 OOM 排查是 Java 工程师的核心技能之一。工具繁多,但核心思路只有一条:从宏观到微观,从现象到根因。操作系统工具看全局资源,JDK 命令行工具看 JVM 内部状态,Profiler 找热点方法,arthas 做精确定位——层层递进,最终锁定根因。

工具速查表

诊断场景 首选工具 备选工具
进程级 CPU/内存概览 top -Hp <pid> htopatop
JVM 内存分区使用率 jstat -gcutil <pid> jcmd <pid> GC.heap_info
GC 行为分析 jstat -gc <pid> 1000 GC 日志 + GCViewer
线程堆栈快照 jstack <pid> jcmd <pid> Thread.print
堆转储 jmap -dump:live,format=b,file=dump.hprof <pid> jcmd <pid> GC.heap_dump
Native Memory 追踪 jcmd <pid> VM.native_memory gperftools
启动参数确认 jps -lvm / jinfo <pid> jcmd <pid> VM.flags
CPU 热点方法 Async-Profiler 火焰图 arthas profiler
线上方法耗时定位 arthas trace arthas monitor
线上入参/返回值观察 arthas watch arthas tt
线上代码版本确认 arthas jad arthas sc -d
全维度 JVM 录制 JFR + JMC JConsole / JVisualVM

操作系统级工具

操作系统工具提供的是 JVM 进程的"外部视角"——进程占了多少 CPU、多少内存、有多少线程。这是排查的第一步:先确认问题是否出在 JVM 进程上。

top / htop / atop

top 是最基础的进程监控工具,htop 提供更友好的交互界面,字节跳动开源的 atop 则能细致地监控线程信息并快速采集系统信息。

实战:用 top 按内存排序找到最耗内存的进程

1
2
3
4
5
# 启动 top 后按 Shift+M 按内存排序
top

# 或者交互式选择排序列
# Shift+F → 方向键选 %MEM → S 确认 → Q 退出菜单

实战:用 top 查看 JVM 进程内的线程级 CPU 占用

1
2
# -H 显示线程,-p 指定进程
top -Hp <pid>

这一步的输出将在后续 jstack 分析中用到——找到 CPU 最高的线程 PID,转换为十六进制后在 jstack 输出中定位对应线程。

pmap

pmap 用于查看进程的内存映射。在非 root 权限下看到的是 JVM 启动参数,在 root/sudo 权限下看到的是完整的内存轮廓(各段内存的起止地址)。输出需要配合 HotSpot 的 Serviceability Agent API 才能解读各子线程的栈分布。

1
sudo pmap -x <pid>

ps_mem.py

ps_mem.py 是一个对 private RAM usage 分析不错的 Python 脚本,但需要 root 权限。

smem 与 /proc/pid/smaps

smem 对内存的 RSS(Resident Set Size)/ PSS(Proportional Set Size)/ USS(Unique Set Size)分析较好,但并不能帮助我们直接获知栈内存轮廓(比如当前 JVM 的 stack 到底是怎么分布的、占了多少内存),而且线上机器通常未安装。直接读取 /proc/pid/smaps 能获得同等信息,但需要 root 权限且输出较为冗长:

1
sudo cat /proc/<pid>/smaps

关键认知:JVM 内存的 committed 与 reserved

通过 topfree 观察到的内存占用,与 JVM 实际使用的内存之间存在差异:

  • reserved:JVM 通过 mmap(PROT_NONE) 向操作系统预留的地址空间(初始值等于 -Xmx),尚未实际分配物理内存
  • committed:JVM 通过 mmap(PROT_READ | PROT_WRITE) 实际申请的内存(初始值等于 -Xms),已映射到物理页

实战中可以观察到:当 Young/Old 区的实际占用率很低时,topfree 显示的内存利用率也会相应降低——非 committed 的内存不算真正占用。因此,关注 utilization(利用率)比关注 used / capacity 的绝对值更有意义

JDK 内置命令行工具

JDK 自带的命令行工具是 JVM 诊断的核心武器库。相比 GUI 工具,它们能在任何 Linux 服务器上直接使用,是线上排查的第一选择。

jps:查找 Java 进程

1
2
3
4
5
# 列出所有 Java 进程及其主类名
jps -l

# 列出所有 Java 进程及其 JVM 启动参数
jps -lvm

jinfo:查看和修改 JVM 参数

1
2
3
4
5
6
7
8
# 查看指定进程的所有 JVM 参数
jinfo <pid>

# 查看特定 flag 的值
jinfo -flag MaxHeapSize <pid>

# 动态修改可写的 flag(仅限 manageable 标记的参数)
jinfo -flag +PrintGCDetails <pid>

jstat:GC 和内存统计

jstat 直接读取 JVM 内部的性能计数器(PerfData),比直接读 GC 日志更直观。即使没有开启 -XX:+PrintGCDetails,JVM 也会在内部写一个类似日志的东西,把所有的 GC 记下来——jstat 读的就是这个内部数据。同理,JMX 客户端也可以看到这些值。

实战:监控 GC 行为

1
2
3
4
5
6
# 查看 GC 统计(绝对值),每秒刷新,共 100 次
jstat -gc <pid> 1000 100

# 输出示例(单位 KB):
# S0C S1C S0U S1U EC EU OC OU MC MU
# 2048.0 2048.0 0.0 1024.0 16384.0 8192.0 40960.0 20480.0 34816.0 33152.0

实战:监控各区利用率

1
2
3
4
5
6
# 查看各区利用率(百分比)
jstat -gcutil <pid> 1000

# 输出示例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 50.00 50.00 50.00 95.22 92.14 120 1.234 3 0.567 1.801

各列含义:S0/S1 = Survivor 区利用率,E = Eden 区,O = Old 区,M = Metaspace,YGC/FGC = Young/Full GC 次数,YGCT/FGCT = 累计耗时。

关键认知:DirectByteBuffer 和 Metaspace 的回收严重依赖 Full GC,Young GC 远远不够。如果观察到 Metaspace 持续增长但 FGC 次数为 0,需要警惕 Metaspace 泄漏。

jstack:线程堆栈快照

jstack 是线程级问题排查的核心工具,能输出所有线程的完整堆栈,并自动检测死锁。jstack 还可以用来分析 core dump(jmap 也可以)。注意线上有时候 jstack 会用不了(权限或 attach 机制问题)。

实战:定位 CPU 最高的线程

这是一个经典的三步排查法:

1
2
3
4
5
6
7
8
9
10
# 第一步:找到 CPU 最高的线程 PID
top -Hp <java_pid>
# 假设找到线程 PID 为 18969

# 第二步:将线程 PID 转换为十六进制
printf "%x\n" 18969
# 输出:4a19

# 第三步:在 jstack 输出中搜索该线程
jstack <java_pid> | grep -A 30 "nid=0x4a19"

jstack 输出格式解读

1
2
"JFR request timer" #6 daemon prio=5 os_prio=0 tid=0x00007fc2f6b1f800 nid=0x18070 in Object.wait() [0x00007fb9aa96b000]
|-----线程名------| |创建次序| |守护线程| |--优先级--| |------线程 id------| |--OS 轻量级进程 id--| |------线程动作------|
  • tid:JVM 内部的线程内存地址
  • nid:操作系统的线程 PID 的十六进制形式(与 top -Hp 输出对应)

死锁检测:如果存在死锁,jstack 输出的开头和末尾都会打印死锁信息,无需额外分析工具。

jmap:堆内存分析

1
2
3
4
5
6
7
8
9
10
11
# 查看堆内存概况(包括 GC 收集器类型)
jmap -heap <pid>

# 确认当前使用的收集器
jmap -heap <pid> | grep GC

# 查看堆中对象统计(会触发 Full GC)
jmap -histo:live <pid> | head -30

# 导出堆转储文件(会导致 STW,大堆慎用)
jmap -dump:live,format=b,file=/tmp/dump.hprof <pid>

注意:Heap Dump 的耗时包括 Full GC 时间和写磁盘时间。对于大堆(>8GB),STW 可能持续数十秒甚至分钟级,hang/panic 几乎不可避免。建议在摘流后的机器上执行。

jcmd:统一诊断命令

jcmd 是 JDK 7+ 引入的统一诊断工具,能替代 jps、jstack、jmap 的大部分功能,且支持更多高级特性(如 NMT、JFR)。Oracle 官方推荐使用 jcmd 替代其他独立工具。

常用命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 等同于 jps -l
jcmd -l

# 等同于 jstack
jcmd <pid> Thread.print

# 等同于 jmap -dump
jcmd <pid> GC.heap_dump /tmp/dump.hprof

# 查看类直方图(等同于 jmap -histo)
jcmd <pid> GC.class_histogram

# 查看启动参数
jcmd <pid> VM.flags

# 查看性能计数器
jcmd <pid> PerfCounter.print

# 触发 GC
jcmd <pid> GC.run

# 触发 Finalizer
jcmd <pid> GC.run_finalization

实战:使用 NMT 追踪 Native Memory

Native Memory Tracking(NMT)是 Java 8 自带的功能,能追踪 JVM 内部各子系统的内存使用。需要在启动时添加参数 -XX:NativeMemoryTracking=detail

1
2
3
4
5
6
7
8
# 查看 Native Memory 汇总
jcmd <pid> VM.native_memory summary scale=MB

# 设置基线(用于后续 diff 对比)
jcmd <pid> VM.native_memory baseline

# 查看与基线的差异(定位内存增长点)
jcmd <pid> VM.native_memory detail.diff scale=MB

NMT 能看到线程消耗的内存,但看不到 DirectByteBuffer。JMC 正好反过来——能直接看到 DirectByteBuffer,但看不到线程内存。两者结合使用效果最佳。

查看启动参数的多种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 方式一:jps 查看实际传入的 JVM 参数
jps -lvm

# 方式二:ps 查看进程完整命令行
ps -aux | grep java
ps -elf | grep java

# 方式三:查看 JVM 默认参数
java -XX:+PrintFlagsFinal -version

# 方式四:查看 Metaspace 相关默认值
java -XX:+PrintFlagsFinal -version | grep Metaspace

# 方式五:jinfo 查看运行时参数
jinfo <pid>

诊断思维:因果关系分析

面对 GC 耗时增大、线程 Block 增多、慢查询增多、CPU 负载高这四个常见表象时,关键在于理清因果链条和时间线顺序——到底哪个是诱因?

评价系统性能的核心挑战在于建立正确的 Metric 体系:选择什么指标来衡量当前系统的健康状况,比具体的调优手段更重要。

GUI 客户端与 JFR

GUI 工具提供了比命令行更直观的可视化界面,适合本地开发环境或通过 JMX 远程连接进行分析。但在生产环境中,由于网络隔离和安全限制,JConsole/JVisualVM/JMC 通常全部连不上线上——这是实际使用中最大的限制。

JConsole

JDK 自带的 JMX 客户端,MBean 功能比 JVisualVM 更强大。适合查看 MBean 属性、调用 MBean 操作(需要在界面上对"函数名"浮层按钮进行点击调用)。也可以在 JMC 上打开 MBeanServer 的控制台,在诊断命令面板里点击 GC.class_stats 等高危命令。

JVisualVM

从 Java 9 开始不再随 JDK 分发,需要到官方主页单独下载。命令行中 jvisualvm 等同于 visualvm。提供线程、内存、CPU 的实时监控和采样分析。

JMC(Java Mission Control)

从 JDK 9 开始不再随 JDK 分发,需要到 GitHub 单独下载。版本兼容性:JMC 8.0 支持 Java 8,JMC 8.1+ 需要 Java 11。

安装方式

1
2
3
4
5
6
7
8
9
10
# 通过 SDKMAN 安装(更多参考敖小剑的博客)
sdk i jmc 8.0.1.33-zulu

# macOS 命令行启动
alias zmc="/Users/<username>/.sdkman/candidates/jmc/8.0.1.33-zulu/Zulu\ Mission\ Control.app/Contents/MacOS/zmc"
zmc

# 或者用 open 启动
tar zxf jmc-<version>_macos-x64.tar.gz
open ./jmc-<version>_macos-x64/JDK\ Mission\ Control.app

如果 JMC 启动失败,可能是包的问题,也可能需要命令行启动。如果要指定 JVM,可以参考 Azul Mission Control 安装文档JDK Mission Control 8 安装指南

JMC 能直接看到 DirectByteBuffer 的使用情况,这一点比 NMT 更优秀(NMT 看不到 DirectByteBuffer,但能看到线程内存)。

JDK Flight Recorder(JFR)

JFR 是 JVM 内置的低开销事件录制引擎,是目前最完备的 JVM 运行时状态分析工具。在 JDK 11 之前,JFR 是 Oracle JDK 的商业特性;从 JDK 11 开始,Oracle 将其开源并集成到 OpenJDK 中,所有发行版免费可用。

核心特性

  • 低开销:生产环境性能开销通常小于 1%
  • 事件驱动:基于事件的记录机制,可配置记录哪些事件
  • 持续录制:支持长时间持续录制,适合生产环境监控
  • 详细诊断:记录 JVM 内部事件(GC、类加载、编译、线程等)和自定义事件

实战:启动时启用 JFR

1
2
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr,dumponexit=true \
-jar your-application.jar

常用参数:duration(录制时长)、filename(输出路径)、dumponexit(JVM 退出时自动 dump)、maxsize(最大文件大小)、maxage(保留时间)。

实战:运行时通过 jcmd 控制 JFR

1
2
3
4
5
6
7
8
9
10
11
# 启动录制
jcmd <pid> JFR.start name=myrecording duration=60s filename=recording.jfr

# 查看所有录制
jcmd <pid> JFR.list

# 停止录制
jcmd <pid> JFR.stop name=myrecording

# 导出录制
jcmd <pid> JFR.dump name=myrecording filename=recording.jfr

JFR 配置模板

  • default.jfc:默认配置,平衡性能和详细信息
  • profile.jfc:性能分析配置,记录更多事件
1
2
java -XX:StartFlightRecording=settings=/path/to/custom.jfc \
-jar your-application.jar

JFR 事件类型

类别 事件
CPU CPU Load、Method Profiling、Native Method Sample
内存 Allocation in new TLAB、Allocation outside TLAB、Object Count
GC GC Phase、Heap Summary、GC Configuration
线程 Thread Start/End、Thread Park/Sleep
I/O File Read/Write、Socket Read/Write
JVM 内部 Class Loading、Compilation、Code Sweeping

在 JMC 中分析 JFR:打开 JMC → 连接目标 JVM 或打开 .jfr 文件 → 浏览事件类型 → 使用 Flame Graph 查看热点 → 分析内存分配和 GC 行为 → 查看线程状态和锁争用。

Profiler 原理与实战

本节内容主要参考美团技术团队的《JVM CPU Profiler 技术原理及源码深度解析》

常见 Profiler 工具

工具 类型 特点
JProfiler 商用 “The Award-Winning All-in-One Java Profiler”,功能最全面
Uber JVM-Profiler 开源 支持 CPU、Memory、I/O 多维度采集,支持 Kafka 上报
IntelliJ 内置 Profiler 免费 基于 Async-Profiler 的包装
Async-Profiler 开源 低开销、无 SafePoint Bias,社区首选
更多 Profiler - jvm-profiling-tools 组织维护的完整列表

Sampling vs Instrumentation

CPU Profiler 的两种实现手段,本质区别在于:基于定时采样 dump,还是基于 AOP 拦截?

Sampling(采样)

  1. 启动采样定时器,以固定频率(毫秒级)对所有线程的调用栈进行 Dump
  2. 汇总统计每个方法被采样到的次数及调用关系
  3. 导出统计结果

Instrumentation(插桩)

利用 Instrument API 对所有必要的 Class 进行字节码增强,在方法入口埋点、出口统计耗时,最终汇总。本质上是一种 AOP。

对比

维度 Sampling Instrumentation
侵入性 高(字节码增强)
性能开销 高(每个方法都有额外开销)
数据精度 近似(取决于采样率) 精确(但包含探针自身的时间加成)
适用场景 CPU 密集型、线上服务 I/O 密集型、对精度要求高的场景

社区的 Profiler 更多基于 Sampling 实现。Sampling 适合线上服务,Instrumentation 适合离线分析。有一篇著名的文章《Why (Most) Sampling Java Profilers Are Fucking Terrible》深入讨论了这个问题。

基于 Java Agent + JMX 的 Sampling 实现

一个最简单的 Sampling CPU Profiler 可以用 Java Agent + JMX 方式来实现:以 Java Agent 为入口,进入目标 JVM 进程后开启一个 ScheduledExecutorService,定时利用 JMX 的 threadMXBean.dumpAllThreads() 来导出所有线程的 StackTrace,最终汇总并导出。

Uber 的 JVM-Profiler 实现原理也是如此,关键部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// com/uber/profiling/profilers/StacktraceCollectorProfiler.java

// 实现了 Profiler 接口,外部由统一的 ScheduledExecutorService 对所有 Profiler 定时执行
@Override
public void profile() {
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// ...
for (ThreadInfo threadInfo : threadInfos) {
String threadName = threadInfo.getThreadName();
// ...
StackTraceElement[] stackTraceElements = threadInfo.getStackTrace();
// ...
for (int i = stackTraceElements.length - 1; i >= 0; i--) {
StackTraceElement stackTraceElement = stackTraceElements[i];
// ...
}
// ...
}
}

Uber 提供的定时器默认 Interval 是 100ms,对于 CPU Profiler 来说略显粗糙。但由于 dumpAllThreads() 的执行开销不容小觑,Interval 不宜设置得过小,所以该方法的 CPU Profiling 结果会存在不小的误差。JVM-Profiler 的优点在于支持多种指标的 Profiling(StackTrace、CPUBusy、Memory、I/O、Method),且支持将 Profiling 结果通过 Kafka 上报回中心 Server 进行分析,也即支持集群诊断。

对于 Monitoring 服务而言,大停顿的操作都要小心、小心再小心。

使用 Java 实现 Profiler 相对较简单,但也存在一些问题——Java Agent 代码与业务代码共享 AppClassLoader,被 JVM 直接加载的 agent.jar 如果引入了第三方依赖,可能会对业务 Class 造成污染。截止发稿时,JVM-Profiler 都存在这个问题,它引入了 Kafka-Client、http-Client、Jackson 等组件,如果与业务代码中的组件版本发生冲突,可能会引发未知错误。Greys/Arthas/JVM-Sandbox 的解决方式是分离入口与核心代码,使用定制的 ClassLoader 加载核心代码,避免影响业务代码。

SafePoint Bias 问题

基于 Sampling 的 CPU Profiler 必须遵循两个原则:样本足够多,且所有代码点被采样的概率相同

传统的 JMX threadMXBean.dumpAllThreads() 和 JVMTI GetStackTrace() 都只能在安全点(SafePoint)采样,违背了第二条原则——某些代码可能永远没有机会被采样,即使它消耗了大量 CPU 时间。这种现象称为 SafePoint Bias

具体而言,GetStackTrace() 获取其他线程的调用栈时,必须等待目标线程进入安全点,且不能在 UNIX 信号处理器中被异步调用。更多细节参考《Safepoints: Meaning, Side Effects and Overheads》。

AsyncGetCallTrace:突破 SafePoint 限制

解决方案是使用 HotSpot 内部的 AsyncGetCallTrace 函数——它不受安全点干扰,且支持在 UNIX 信号处理器中被异步调用。

实现思路:注册一个 UNIX 信号处理器,在 Handler 中调用 AsyncGetCallTrace 获取当前线程的调用栈。由于 UNIX 信号会被随机分发给进程的某个线程,最终信号会均匀分布在所有线程上,从而均匀获取所有线程的调用栈样本。

在 Linux 环境下结合 perf_events 还能同时采样 Java 栈与 Native 栈。典型开源实现有 Async-Profiler 和 Honest-Profiler。IntelliJ IDEA 内置的 Java Profiler 就是 Async-Profiler 的包装。

火焰图

火焰图是 Profiler 采样结果的可视化形式:

  • X 轴:采样总量(越宽 = 占用 CPU 时间越多)
  • Y 轴:栈深度(底部是入口方法,顶部是叶子方法)
  • 每个框:一个栈帧,宽度代表该方法占用的 CPU 总时间

阅读要点找宽平顶——X 轴宽表示耗时多,顶部平表示该方法自身(而非子调用)消耗 CPU 多。宽平顶就是性能热点。

生成火焰图

FlameGraph 项目的核心是一个 Perl 脚本,输入格式为:

1
2
3
# 每行一类调用栈,分号分隔(左栈底右栈顶),空格后是出现次数
base_func;func1;func2;func3 10
base_func;funca;funcb 15
1
flamegraph.pl stacktraces.txt > stacktraces.svg

async-profiler-idea.png

火焰图帧颜色编码与 JIT 编译层级

async-profiler 生成的火焰图通过颜色区分不同类型的栈帧,这些颜色编码是诊断 JIT 编译问题的关键线索:

颜色 标签 含义
深绿 #50e150 Java compiled C2 编译的代码(最高优化级别,Server Compiler)
浅绿 #cce880 Java compiled by C1 C1 编译的代码(中间优化级别,Client Compiler)
淡绿 #b2e1b2 Interpreted 解释执行的代码(未编译)
青色 #50cccc Inlined 被 JIT 内联的方法
黄色 #c8c83c C++ (VM) HotSpot VM 内部 C++ 代码
红色 #e15a5a Native JNI / .so 库调用
橙色 #e17d00 Kernel 内核态代码

注意 Java compiled(深绿)并没有标注 “C2”——这是因为 C2 是 HotSpot 的默认最终编译器,async-profiler 将其视为"正常的已编译代码"。只有 C1 编译的代码才被特别标注。

实战:通过火焰图诊断 JIT Deoptimization Storm

在一次线上 SpEL 表达式求值的偶发性能退化排查中,我们通过对比正常时和 CPU 偏高时的两份 async-profiler 火焰图,发现了 JIT 去优化风暴(Deoptimization Storm)的直接证据。

正常火焰图的基线数据

指标
唯一函数名 462
总帧数 815
最大调用深度 220 层
Interpreted 帧 5
C1 帧 5

CPU 偏高火焰图的异常数据

指标 与正常对比
唯一函数名 1207 2.6 倍
总帧数 2533 3.1 倍
最大调用深度 297 层 +35%
Interpreted 帧 57 11.4 倍暴增
C1 帧 235 47 倍暴增

三重证据链

  1. Interpreted 帧 11.4 倍暴增:大量方法从 C2 编译态退化回解释执行,说明 JIT 编译器将这些方法标记为 not entrant(不可进入),触发了去优化
  2. C1 帧 47 倍暴增:方法被去优化后重新进入分层编译流程(Interpreted → C1 → C2),大量方法卡在 C1 这个"半优化"的中间状态
  3. JIT 编译器函数现身:CPU 偏高火焰图中出现了 invoke_compiler_on_methodsubmit_compileBroker::compile_method 等 JIT 编译器内部函数——正常火焰图中完全没有这些帧,说明 JIT 编译器正在疯狂重新编译被去优化的方法

JIT 分层编译的退化路径

1
2
3
4
5
正常稳态:  Interpreted → C1C2(性能最优)

去优化时: C2 ──made not entrant──→ Interpreted → C1 → (等待重新 C2)

CPU 偏高时大量方法卡在这两个阶段

这就是为什么 CPU 高但吞吐量低——代码在用"半优化"的 C1 版本运行,而不是"全优化"的 C2 版本。每个方法的执行效率可能只有 C2 优化后的 1/5 到 1/10,叠加上 JIT 编译器自身消耗的 CPU,整体表现为 CPU 飙高但业务处理能力骤降。

诊断要点总结

  • 对比两份火焰图时,优先看帧类型的比例变化,而不仅仅是看哪个方法宽。Interpreted 和 C1 帧的暴增是 JIT 去优化的直接信号
  • 关注 JIT 编译器自身的栈帧CompileBrokerC1_CompilerThreadC2_CompilerThread 相关帧在正常火焰图中几乎不可见,一旦大量出现说明编译器正在加班
  • 注意 RASP / APM Agent 的帧:安全 Agent(如 RASP)和 APM Agent(如 ATP)通过字节码增强可能打破 JIT 的类型假设(Class Hierarchy Analysis),间接触发去优化。在 CPU 偏高火焰图中如果出现了正常时没有的 Agent 帧,值得深入排查

热点分析树与调用堆栈树

热点分析树(自底向上):统计 CPU 上调用最频繁的方法,树形结构展示抵达热点的不同栈路径。常见热点方法包括日志调用、协议编解码、加解密、各种客户端 flushBuffer。需要注意的是,日志虽然调用频繁,但未必是真正的性能热点——就像字符串在 Heap Dump 中出现最多,但未必是内存问题的根因。

调用堆栈树(自顶向下):从入口方法开始,展示各子调用的耗时占比。常见的底层堆栈包括 Thread.runThreadPoolExecutor$Worker.run、各种 I/O 事件 Handler、Future.get 等。

Java Agent 与 Dynamic Attach

Java Agent 基于 JVMTI 机制与 JVM 通信,是 Debugger(JDWP)、Profiler、Monitor、Thread Analyser 等工具的统一基础。

Java Agent 规范:在 MANIFEST.MF 中指定 Premain-Class,实现 premain 方法:

1
2
3
4
5
public static void premain(String args, Instrumentation ins) {
// Instrumentation 接口提供 RetransformClasses 能力
// 可修改宿主进程的 Class,实现方法耗时统计、故障注入、Trace 等功能
// 同时可通过 JMX 获取内存、线程、锁等信息
}

依赖隔离问题:Java Agent 代码与业务代码共享 AppClassLoader,如果 Agent 引入了第三方依赖(如 Kafka-Client、Jackson),可能与业务代码的组件版本冲突。Arthas/JVM-Sandbox 的解决方式是分离入口与核心代码,使用定制的 ClassLoader 加载核心代码。

Dynamic Attach:JDK 1.6+ 提供 Attach API,允许向运行中的 JVM 进程添加 Agent。arthas 的核心实现:

1
2
3
4
5
// 通过 VirtualMachine.attach() 连接目标进程
VirtualMachine virtualMachine = VirtualMachine.attach(pid);
// 通过 loadAgent() 加载 Agent jar
virtualMachine.loadAgent(agentPath, args);
virtualMachine.detach();

Async-Profiler 的 jattach 方式:

1
jattach 1234 load /absolute/path/to/agent/libagent.so true

Profiler 核心结论

  1. Java Agent 的 Dynamic Attach 是否易用,决定了基于它构建的工具能否被广泛使用
  2. Instrumentation 不适合做定量分析,性能影响可能非常大
  3. 很多 JMX API 会 block until SafePoint(如 GetStackTrace),SafePoint Bias 是客观存在的问题
  4. Sampling 才是对业务友好的采集方式,精确度需要通过 AsyncGetCallTrace 等手段迂回解决

arthas 实战

arthas 是阿里开源的 Java 诊断利器,基于 Java Agent + Dynamic Attach 实现,能在不重启 JVM 的情况下完成方法级的精确诊断。

1
2
curl -o /tmp/arthas-boot.jar https://arthas.aliyun.com/arthas-boot.jar
java -jar /tmp/arthas-boot.jar

以下按诊断场景组织——每个案例从"遇到了什么问题"开始,一步步展示排查过程。

案例一:线上接口突然变慢,如何定位根因?

场景:某服务 RT 突然飙升,P99 从 200ms 涨到 5s+,监控告警但不知道慢在哪一层。

核心思路:漏斗式排查——先宏观找到最慢的入口,再逐层下钻到具体子调用,最后精确确认根因。三种方法互补。

方法一:trace 递归下钻法(最常用)

trace 只展开一层子调用的耗时。从最外层入口开始,每次找到耗时最大的子调用,再对它执行 trace,如此递归直到叶子方法。

第一步:从入口开始 trace

1
2
# 只抓耗时超过 2000ms 的请求,-n 5 最多抓 5 次自动退出
trace com.example.order.controller.OrderController createOrder '#cost > 2000' -n 5
1
2
3
4
5
`---ts=2024-11-15 14:23:01;thread_name=http-nio-8080-exec-3;
`---[4523.891ms] com.example.order.controller.OrderController:createOrder()
+---[0.12ms ] com.example.order.converter.OrderConverter:toDTO() #45
+---[4501.33ms ] com.example.order.service.OrderService:submitOrder() #46 <-- 瓶颈
+---[2.15ms ] com.example.order.mq.OrderMessageProducer:sendOrderCreated() #48

第二步:对瓶颈方法继续 trace

1
trace com.example.order.service.OrderService submitOrder '#cost > 2000' -n 5
1
2
3
4
5
`---[4498.72ms] com.example.order.service.OrderService:submitOrder()
+---[12.35ms ] com.example.order.service.OrderService:validateOrder() #78
+---[4.21ms ] com.example.order.service.OrderService:calculatePrice() #80
+---[4470.88ms] com.example.order.repository.OrderRepository:saveOrder() #82 <-- 继续下钻
+---[8.92ms ] com.example.order.service.InventoryService:deductStock() #84

第三步:递归到叶子

1
trace com.example.order.repository.OrderRepository saveOrder '#cost > 2000' -n 5
1
2
3
4
`---[4468.15ms] com.example.order.repository.OrderRepository:saveOrder()
+---[3.42ms ] com.example.order.repository.OrderRepository:buildInsertSQL() #112
+---[4462.07ms] org.apache.ibatis.session.SqlSession:insert() #114 <-- DB 慢查询
+---[1.88ms ] com.example.order.repository.OrderRepository:buildOrderDO() #116

trace 关键技巧

技巧 命令 说明
按耗时过滤 '#cost > 2000' 过滤正常流量噪音
限制抓取次数 -n 5 避免长时间挂载影响性能
包含 JDK 方法 --skipJDKMethod false 怀疑 JDK 层面问题时打开
正则多类匹配 trace -E com.example.service\|com.example.repository .* 一次 trace 多层
排除干扰类 --exclude-class-pattern com.example.log.* 排除日志等

trace 的原理是对匹配到的类做字节码增强,匹配范围越大性能开销越大。线上务必用 -n 限制次数,用完执行 reset 还原增强。

方法二:watch 精确确认法

trace 告诉"哪里慢",watch 告诉"为什么慢"——观察方法的入参、返回值、异常。

1
2
# -x 3 展开 3 层对象属性
watch com.example.order.repository.OrderRepository saveOrder '{params,returnObj,throwExp}' -x 3 -n 5 '#cost > 2000'
1
2
3
4
5
6
7
8
9
10
11
method=com.example.order.repository.OrderRepository.saveOrder
ts=2024-11-15 14:25:12; [cost=4501.23ms] result=@ArrayList[
@Object[][
@OrderDO[
orderId=@String[ORD202411150001],
items=@ArrayList[size=15000], <-- 15000 个商品项!
totalAmount=@BigDecimal[999999.99],
],
],
null, null,
]

watch 常用表达式速查

1
2
3
4
5
6
7
watch com.example.Foo bar '{params}' -x 2                              # 只看入参
watch com.example.Foo bar '{returnObj}' -x 2 # 只看返回值
watch com.example.Foo bar '{throwExp}' -e # 只看异常
watch com.example.Foo bar '{params, returnObj}' -x 3 '#cost > 1000' # 按耗时过滤
watch com.example.Foo bar '{params}' -x 3 -b # 方法入口观察
watch com.example.Foo bar '{params, returnObj}' -x 3 -b -s # 同时观察入口和出口
watch com.example.Foo bar '{params, returnObj}' 'params[0].equals("user123")' -x 3 # 按入参过滤

方法三:profiler 火焰图法(全局视角)

trace 和 watch 是"点"上的工具,profiler 是"面"上的工具——对整个 JVM 采样,不需要预先知道哪里慢。

1
2
3
4
5
6
7
8
9
10
11
profiler start                       # CPU 热点(默认)
profiler start --event alloc # 内存分配热点
profiler start --event lock # 锁竞争热点
profiler start --event wall # Wall Clock(含等待时间,适合 I/O 阻塞)

profiler getSamples # 查看已采集样本数
profiler start --duration 60 # 指定采集时长

profiler stop --format html --file /tmp/cpu_flamegraph.html # HTML 格式(推荐)
profiler stop --file /tmp/profile.jfr # JFR 格式
# 浏览器访问 http://localhost:3658/arthas-output/ 下载

三种方法对比

维度 trace 递归下钻 watch 精确确认 profiler 火焰图
适用场景 已知慢在某个入口,逐层定位 已知慢在某个方法,确认原因 完全不知道慢在哪里
信息类型 调用链 + 各层耗时 入参 / 返回值 / 异常 全局 CPU / 内存 / 锁热点
性能开销 中等(字节码增强) 低(单方法增强) 低(采样,约 1-5%)
使用顺序 第一步:定位哪个方法慢 第二步:确认为什么慢 第零步:不知道从哪开始

推荐排查流程:profiler 火焰图(全局扫描)→ trace 递归下钻(逐层定位)→ watch 精确确认(确认根因)→ jad 确认代码(确认线上版本)。

案例二:线程 CPU 飙高 / 死锁 / 线程池打满

场景:监控显示 CPU 使用率 100%,或请求全部超时,怀疑死循环、死锁或线程池打满。

CPU 飙高——找到最忙的线程

1
thread -n 3
1
2
3
4
5
6
"http-nio-8080-exec-5" Id=25 cpuUsage=98.12% deltaTime=982ms time=15234ms RUNNABLE
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4658)
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
...
at com.example.util.HtmlSanitizer.sanitize(HtmlSanitizer.java:42)
at com.example.service.ContentService.processContent(ContentService.java:88)

结论:正则表达式回溯导致 CPU 飙高(经典 ReDoS 问题),定位到 HtmlSanitizer.sanitize()

死锁——一键检测

1
thread -b
1
2
3
4
5
6
7
8
9
"http-nio-8080-exec-3" Id=22 BLOCKED on java.lang.Object@3d4eac69
owned by "http-nio-8080-exec-7" Id=26
at com.example.service.AccountService.transfer(AccountService.java:45)

"http-nio-8080-exec-7" Id=26 BLOCKED on java.lang.Object@5f2b3c1a
owned by "http-nio-8080-exec-3" Id=22
at com.example.service.AccountService.transfer(AccountService.java:52)

Found 1 deadlock.

其他线程诊断命令

1
2
3
4
thread                    # 所有线程概览(按 CPU 增量时间降序)
thread 25 # 查看指定线程 ID 的完整堆栈
thread --state BLOCKED # 所有 BLOCKED 状态的线程
thread --state WAITING # 所有 WAITING 状态的线程(排查线程池打满)

反向查调用栈:stack 命令

知道某个方法被调用了,但不知道从哪个入口调进来的:

1
stack com.example.MathGame primeFactors '#cost > 5'

stack vs trace:trace 是"从上往下看"——展开子调用;stack 是"从下往上看"——打印调用者链。

案例三:偶发问题难以复现,如何录制现场?

场景:某接口偶尔返回错误结果,用 watch 守了半天没抓到,因为出现频率太低。

tt(TimeTunnel,时空隧道)能录制方法调用的完整现场(入参、返回值、异常、耗时),支持事后回放。

开启录制

1
2
# -t 录制模式,-n 100 最多录制 100 次
tt -t com.example.order.service.OrderService createOrder -n 100
1
2
3
4
INDEX  TIMESTAMP            COST(ms)  IS-RET  IS-EXP  OBJECT        CLASS           METHOD
1000 2024-11-15 14:30:01 4523.12 true false 0x4a6b3c2d OrderService createOrder
1001 2024-11-15 14:30:03 45.67 true false 0x4a6b3c2d OrderService createOrder
1002 2024-11-15 14:30:05 5102.89 false true 0x4a6b3c2d OrderService createOrder <-- 异常!

事后查看异常调用

1
2
3
4
5
6
7
tt -i 1002                                    # 查看第 1002 次调用的完整信息
tt -l # 查看所有录制
tt -s '#cost > 2000' # 搜索耗时超过 2000ms 的调用
tt -s 'isThrow==true' # 搜索抛出异常的调用
tt -i 1002 -w 'params[0].getOrderId()' # 对入参做 OGNL 表达式求值
tt -i 1002 -w 'returnObj.getItems().size()' # 对返回值做表达式求值
tt -i 1002 -p # 重放历史调用(仅限只读方法!)

tt 能做什么与不能做什么

能力 说明
✅ 录制方法调用的完整现场 入参、返回值、异常、耗时、线程信息
✅ 事后反复查看任意一次调用 不需要像 watch 一样实时盯着
✅ 按条件搜索历史记录 按耗时、是否异常等条件过滤
✅ 对录制的对象做 OGNL 求值 事后分析入参/返回值的任意属性
✅ 重放历史调用 用原始入参重新触发一次方法调用
❌ 不能录制子调用 只录制你指定的那一个方法,不会展开子调用链(用 trace 看子调用)
❌ 不能修改入参后重放 重放使用的是录制时的原始入参(用 ognl 直接调用方法传自定义参数)
❌ 录制有内存开销 入参和返回值的引用会被持有,大对象会占用堆内存(用 -n 限制次数,及时 tt --delete-all 清理)
❌ 重放有副作用 重放会真实执行方法逻辑,写 DB、发 MQ 的方法重放会产生真实副作用(仅对只读方法使用 -p
❌ 不能跨 JVM 重放 录制现场只在当前 JVM 内存中,arthas 退出后丢失
❌ 不能录制构造方法 不支持 <init><clinit>(用 watch 观察构造方法)

tt vs watch 选择

维度 tt watch
核心差异 录制并存储,事后可反复查看和重放 实时输出,看完即丢
适合场景 偶发问题(录下来慢慢分析)、需要重放验证 高频问题(实时观察即可)、只需看一次
内存开销 较大(持有对象引用) 几乎无(输出后即释放)
使用建议 问题难以复现时优先用 tt 录制 问题容易复现时优先用 watch

案例四:线上代码不对 / 发布没生效 / 依赖版本错了

场景:确信代码已修复并发布,但线上行为还是老样子。或怀疑线上加载的二方包版本不对。

确认线上代码版本

1
2
3
4
5
# 反编译整个类
jad com.example.order.service.OrderService

# 只反编译某个方法
jad com.example.order.service.OrderService createOrder

对比 jad 输出和本地 IDE 中的源码,确认关键逻辑是否一致。反编译的代码在语法糖(lambda、try-with-resources 等)上会与原始源码有差异,只需关注业务逻辑。

确认二方包版本

1
2
3
# 输出中会显示 ClassLoader 和 jar 包路径
jad com.example.common.util.JsonUtil
# Location: /home/admin/app/lib/common-util-1.2.3.jar

多 ClassLoader 环境下的类冲突

在 OSGI、Pandora Boot 等多 ClassLoader 环境中,同一个类可能被不同的 ClassLoader 加载了不同版本:

1
2
3
4
5
# 先查看该类被哪些 ClassLoader 加载了
sc -d com.example.common.util.JsonUtil

# 输出中会显示 classLoaderHash,用 -c 指定具体的 ClassLoader
jad -c 3d4eac69 com.example.common.util.JsonUtil

导出到文件做 diff

1
2
jad --source-only com.example.order.service.OrderService > /tmp/OrderService.java
jad --source-only --lineNumber false com.example.order.service.OrderService > /tmp/OrderService_no_line.java

紧急热修复(jad + mc + redefine)

1
2
3
4
5
6
7
8
9
10
# 1. 反编译到临时目录
jad --source-only com.example.order.service.OrderService > /tmp/OrderService.java

# 2. 手动编辑 /tmp/OrderService.java 修复 bug

# 3. 编译(需指定 ClassLoader)
mc -c 3d4eac69 /tmp/OrderService.java -d /tmp/classes

# 4. 热加载
redefine /tmp/classes/com/example/order/service/OrderService.class

redefine 有诸多限制——不能增删方法/字段、不能修改方法签名、不能修改继承关系。且 redefine 后的类在下次 Full GC 或类重新加载时可能被还原。仅作为临时应急手段。

jad 能做什么与不能做什么

能力 说明
✅ 反编译任意已加载的类 包括你自己的代码、二方包、三方包、甚至 JDK 的类
✅ 显示 ClassLoader 和 jar 路径 直接确认类来自哪个 jar 包
✅ 指定 ClassLoader 反编译 解决多 ClassLoader 下同名类冲突
✅ 只反编译单个方法 大类只看关键方法,输出更精简
✅ 导出到文件做 diff 对比线上代码和本地代码的差异
❌ 不能看到注释 编译后注释已丢失,反编译无法恢复
❌ 不能看到原始变量名 如果编译时没有保留调试信息(-g),变量名会变成 var1var2
❌ 语法糖会被还原 lambda → 匿名内部类,for-each → iterator,switch-string → hashCode 判断
❌ 不能反编译 native 方法的实现 native 方法的实现在 C/C++ 层,jad 只能看到方法签名

案例五:JVM 整体状态一览

场景:刚登上一台线上机器,需要快速了解 JVM 的整体健康状况。

1
2
3
4
5
6
7
8
dashboard              # 实时仪表盘:线程、内存、GC 等信息,每 5 秒刷新
jvm # JVM 详细信息:运行时参数、ClassLoader、编译器等
memory # 内存分区:Eden、Survivor、Old、Metaspace、CodeCache 使用情况
heapdump /tmp/dump.hprof # 导出堆转储(会触发 Full GC,线上慎用)
heapdump --live /tmp/dump.hprof # 只 dump 存活对象(先 Full GC 再 dump)
monitor -c 5 com.example.MathGame primeFactors # 每 5 秒统计方法调用次数和成功率
sysprop # 查看所有系统属性
sysprop user.dir # 查看特定属性

heapdump 会导致 STW,大堆可能暂停数十秒甚至分钟级。优先在摘流后的机器上执行。

案例六:运行时动态操作

场景:不重启 JVM,动态调用方法、查看对象状态、搜索类信息。

ognl:动态执行表达式

1
2
ognl '@java.lang.System@out.println("hello")'                    # 调用静态方法
ognl '@com.example.AppConfig@MAX_RETRY_COUNT' # 获取静态字段值

vmtool:获取 JVM 中的活对象

1
2
3
4
5
6
7
8
9
# 获取 Spring ApplicationContext 实例
vmtool --action getInstances --className org.springframework.context.ApplicationContext

# 获取某个类的所有实例(限制数量避免 OOM)
vmtool --action getInstances --className com.example.cache.LocalCache --limit 5

# 获取 Spring Bean 并调用方法
vmtool --action getInstances --className org.springframework.context.ApplicationContext --limit 1 \
-x 1 --express 'instances[0].getBean("orderService").getOrderCount()'

sc / sm:搜索类和方法

1
2
3
4
5
sc com.example.order.*                                    # 搜索类(支持通配符)
sc -d com.example.order.service.OrderService # 类详细信息(ClassLoader、jar 路径等)
sc -f com.example.order.service.OrderService # 查看类的所有字段
sm com.example.order.service.OrderService # 搜索方法
sm -d com.example.order.service.OrderService createOrder # 方法详细信息

classloader:排查类加载问题

1
2
3
classloader            # 查看所有 ClassLoader
classloader -t # ClassLoader 继承树
classloader -c 3d4eac69 -r java/lang/String.class # 某个 ClassLoader 加载了哪些资源

dump:导出已加载类的字节码

1
dump -d /tmp/output com.example.order.service.OrderService

案例七:排查 multipart 请求导致的 OOM

场景:HTTP 请求中包含特殊构造的 multipart 文件名(如以 2\ 结尾),框架将文件的二进制内容误作为字符串参数写入 parameterMap,导致序列化时触发 java.lang.OutOfMemoryError: Java heap space

用 trace 定位耗时分布

1
trace com.magicliang.TraceIdFilter doFilter -n 5 --skipJDKMethod false
1
2
3
4
`---[3545.892486ms] com.magicliang.TraceIdFilter:doFilter() [throws Exception]
+---[29.02% 1029.140713ms ] javax.servlet.http.HttpServletRequest:getParameterMap() #63
+---[70.88% 2513.212256ms ] com.magicliang.common.util.JsonUtil:toJsonStr() #72 [throws Exception]
`---throw:java.lang.OutOfMemoryError #-1 [Java heap space]

getParameterMap() 耗时 29%,JsonUtil.toJsonStr() 耗时 70% 并最终抛出 OOM。

用 watch 确认超大参数

1
watch javax.servlet.ServletRequest getParameterMap '{params,returnObj,throwExp}' -n 3 -x 3
1
2
3
4
5
6
method=org.apache.catalina.connector.Request.getParameterMap location=AtExit
ts=2024-10-09 15:22:53.912; [cost=357.263226ms] result=@ArrayList[
@Object[][isEmpty=true;size=0],
@ParameterMap[
@String[files]:@String[][
@String[ Object size exceeds size limit: 10485760, ...

parameterMap 中存在超出大小限制的超大字符串,从而定位到根因。

arthas 安全退出

1
2
3
reset    # 重置所有被增强过的类(trace/watch/monitor 等命令的字节码增强)
quit # 退出 arthas 客户端(arthas 进程仍在运行,可重新 attach)
stop # 彻底停止 arthas 进程(会自动 reset 所有增强)

线上铁律:用完 arthas 一定要 stop,不要让 arthas 进程长期挂在线上 JVM 上。

BTrace:动态追踪工具

BTrace 是一个安全的、动态的 Java 追踪工具,基于 Java Agent 和 JVMTI 技术,通过字节码增强在运行时修改目标类的字节码,无需重启 JVM。

核心特点

  • 安全性:限制可用的 Java API,禁止创建对象、抛出异常、循环等可能导致 JVM 不稳定的操作
  • 低侵入性:追踪代码在目标 JVM 中执行,不影响业务逻辑
  • 动态性:运行时动态加载和卸载追踪脚本

安装与基本命令

1
2
3
4
5
6
7
wget https://github.com/btraceio/btrace/releases/download/v2.2.3/btrace-bin-2.2.3.tgz
tar xzf btrace-bin-2.2.3.tgz
export PATH=$PATH:/path/to/btrace/bin

btraceps # 列出所有 Java 进程
btrace <pid> TraceScript.java # 追踪指定进程
btracec TraceScript.java # 编译追踪脚本

示例:追踪方法执行时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import com.sun.btrace.annotations.*;
import static com.sun.btrace.BTraceUtils.*;

@BTrace
public class MethodDurationTracer {
@OnMethod(clazz = "com.example.Service", method = "processRequest")
public static void onMethodEntry(@ProbeClassName String className,
@ProbeMethodName String methodName) {
println(strcat("Entering: ", strcat(className, ".")));
println(strcat("Method: ", methodName));
}

@OnMethod(clazz = "com.example.Service", method = "processRequest",
location = @Location(Kind.RETURN))
public static void onMethodReturn(@Duration long duration) {
println(strcat("Duration (ms): ", str(duration / 1000000)));
}
}

示例:追踪方法参数和返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@BTrace
public class MethodArgReturnTracer {
@OnMethod(clazz = "com.example.UserService", method = "getUserById")
public static void onMethodEntry(@ProbeClassName String className,
@ProbeMethodName String methodName,
long userId) {
println(strcat("Args: userId=", str(userId)));
}

@OnMethod(clazz = "com.example.UserService", method = "getUserById",
location = @Location(Kind.RETURN))
public static void onMethodReturn(@Return Object result) {
println(strcat("Return: ", str(result)));
}
}

示例:定时统计方法调用次数

1
2
3
4
5
6
7
8
9
10
11
12
13
@BTrace
public class MethodCallCounter {
@TLS private static long count = 0;

@OnMethod(clazz = "com.example.Cache", method = "get")
public static void onMethodCall() { count++; }

@OnTimer(5000)
public static void printCount() {
println(strcat("Cache.get() calls in last 5s: ", str(count)));
count = 0;
}
}

示例:追踪异常

1
2
3
4
5
6
7
8
9
10
11
12
@BTrace
public class ExceptionTracer {
@OnMethod(clazz = "java.lang.Throwable", method = "<init>",
location = @Location(Kind.RETURN))
public static void onThrowable(@Self Throwable exception,
@ProbeClassName String className) {
if (contains(className, "com.example")) {
println(str(exception));
printStacktrace(exception);
}
}
}

示例:追踪内存分配

1
2
3
4
5
6
7
8
9
@BTrace
public class MemoryAllocationTracer {
@OnMethod(clazz = "com.example.BigObject", method = "<init>",
location = @Location(Kind.RETURN))
public static void onBigObjectAllocation(@Self Object obj) {
println(strcat("BigObject allocated: ", str(obj)));
println(strcat("Size: ", str(sizeof(obj))));
}
}

注意事项:BTrace 脚本中只能使用 BTraceUtils 提供的静态方法,禁止创建新对象、修改字段值、抛出异常。避免在频繁调用的方法上使用,生产环境使用前务必在测试环境验证。BTrace 2.x 版本支持更多 Java 特性。

在 arthas 出现后,BTrace 的大部分使用场景已被 arthas 的 tracewatchmonitor 等命令覆盖,且 arthas 的使用门槛更低。BTrace 的优势在于脚本的灵活性——可以编写任意复杂的追踪逻辑。

gperftools:Native Memory 排查

gperftools 是 Google 开发的性能分析工具集,在 JVM native memory leak 排查场景中,主要使用其 tcmalloc(Thread-Caching Malloc)内存分配器的 Heap Profiler 功能。

安装

1
2
git clone https://github.com/gperftools/gperftools.git
cd gperftools && ./configure && make && make install

启用 Heap Profiler

1
2
3
LD_PRELOAD=/usr/local/lib/libtcmalloc_and_profiler.so \
HEAPPROFILE=/tmp/heap_profile \
java -jar your-application.jar

主要环境变量:

变量 说明
LD_PRELOAD 预加载 gperftools 库
HEAPPROFILE heap profile 文件输出路径前缀
HEAP_PROFILE_ALLOCATION_INTERVAL 每分配指定字节数输出一次 profile(默认 1GB)
HEAP_PROFILE_INUSE_INTERVAL 当 inuse 内存达到指定字节数时输出
HEAP_PROFILE_TIME_INTERVAL 每隔指定秒数输出一次

分析 Native Memory

1
2
3
4
5
6
7
8
# 查看内存分配分布
pprof --text /path/to/java /tmp/heap_profile.0001.heap

# 生成调用关系图
pprof --pdf /path/to/java /tmp/heap_profile.0001.heap > memory_graph.pdf

# 生成火焰图
pprof --collapsed /path/to/java /tmp/heap_profile.0001.heap | flamegraph.pl > native_flamegraph.svg

适用场景:DirectByteBuffer 导致的 native memory 泄漏、JNI 调用中 native code 的内存泄漏、第三方 native 库(如 Netty native transport)的内存问题、NIO 相关的堆外内存泄漏。

注意事项:gperftools 只能追踪通过 malloc/free 分配的内存,无法追踪 JVM 内部直接通过 mmap 分配的内存。在高并发场景下有约 5%-10% 的性能开销。与 JVM 的 NMT 结合使用效果更佳。

Remote Debugger

Remote Debugger 基于 JDWP(Java Debug Wire Protocol)协议,允许 IDE 连接到远程 JVM 进程进行断点调试。

常见问题:为什么有时候 Remote Debugger 连接被拒绝(Connection Refused)?因为上一个 debugging session 还在继续,JDWP 端口已被占用,再 debug 上去会被拒绝。需要先断开上一个 session。

参考资料

JVM 内存与 GC

  1. 聊聊 JVM 的 -XX:MaxDirectMemorySize
  2. PermGen and Metaspace
  3. Native Memory Tracking in JVM
  4. JVM 源码分析之堆外内存完全解读
  5. JVM Anatomy Quark #12: Native Memory Tracking
  6. 聊聊 HotSpot VM 的 Native Memory Tracking
  7. NMT 工具输出表
  8. Java 程序在 Linux 上运行虚拟内存耗用很大
  9. native-mem-tracking.md
  10. 说说在 Java 启动参数上我犯的错
  11. 江南白衣 | 关键系统的 JVM 参数推荐(2018 仲夏版)
  12. Metaspace 整体介绍(永久代被替换原因、元空间特点、元空间内存查看分析方法)

线程与调试

  1. 线程栈的原理
  2. HotSpot thread.cpp 源码
  3. RocketMQ 的 JVM 配置
  4. gdb:注意 backtrace 的使用
  5. vmerr:注意看 JVM 退出时的线程栈状态,C++ 栈帧的调用来源和抛出位置

DirectByteBuffer 与 GC

  1. System.gc() 和 -XX:+DisableExplicitGC 启动参数,以及 DirectByteBuffer 的内存释放
  2. Impact of setting -XX:+DisableExplicitGC when NIO direct buffers are used

JVM Crash 分析

  1. JVM Crash 日志分析