Java 进程的 OOM 跨两层:Linux 内核的 OOM Killer 在物理内存或 cgroup limit 触及时 kill 进程,事后只能从 dmesg 还原;JVM 的 OutOfMemoryError 在堆/元空间/直接内存耗尽时抛异常,常带堆栈和 dump。两层工具栈不同:Linux 侧看 dmesg、cgroup、内核参数,JVM 侧看 jstat、jmap、jstack、arthas、JFR、gperftools。

一、Linux 层:OOM Killer 机制

Linux 内核有个 OOM (Out of Memory) Killer 的机制,可以在系统内存不足的时候,通过主动杀死一些进程来释放更多的内存空间。

很多时候,可以 ping 通一台服务器,但无法 ssh 上去,因为 sshd 被 OOM Killer 杀掉了。ping 能 ping 通,是因为处在内核态协议栈还能工作,发出回送报文。sshd 则因为是用户态进程,直接被干掉了。

OOM Killer 工作流程

OOM Killer 的完整工作流程如下:

OOM Killer 工作流程

关键机制

oom_score

每个进程都有一个 oom_score 值,范围是 0 到 1000,分数越高越容易被 OOM Killer 选中。可以通过 /proc/PID/oom_score 查看。

oom_score 的计算主要考虑以下因素:

  1. 进程占用的内存大小:包括虚拟内存、物理内存、swap 空间等
  2. 进程的运行时间:运行时间长的进程分数较低
  3. 进程的优先级:nice 值会影响分数
  4. 进程的 root 权限:root 进程会有一定的保护
  5. 是否直接访问硬件:直接访问硬件的进程分数会降低

oom_score_adj

oom_score_adj 是调整进程 oom_score 的接口,范围是 -1000 到 1000。可以通过 /proc/PID/oom_score_adj 进行设置。

  • -1000:完全禁止 OOM Killer 杀死该进程(相当于旧的 oom_adj = -17)
  • 0:默认值,不调整
  • 1000:极大增加被杀死的概率

旧的 oom_adj 接口(范围 -17 到 15)已被废弃,新代码应使用 oom_score_adj。

vm.overcommit_memory

vm.overcommit_memory 参数控制内核的内存过度分配策略,位于 /proc/sys/vm/overcommit_memory:

  • 0:启发式过度分配(默认值),内核会根据实际情况决定是否允许过度分配
  • 1:总是允许过度分配,可能导致 OOM
  • 2:严格过度分配控制,不允许过度分配超过 swap + 可配置的 RAM 比例

控制 OOM Killer

关闭再打开 OOM Killer

1
2
echo "0" > /proc/sys/vm/oom-kill
echo "1" > /proc/sys/vm/oom-kill

保护特定进程

可以通过设置 oom_score_adj 为 -1000 来强制内核不得杀死某个进程:

1
pgrep -f "/usr/sbin/sshd" | while read PID;do echo -1000 > /proc/$PID/oom_score_adj;done

或者针对单个进程:

1
echo -1000 > /proc/PID/oom_score_adj

cgroup 与 OOM Killer

在容器化环境中,cgroup 提供了更精细的 OOM 控制机制。

cgroup v1

在 cgroup v1 中,memory subsystem 提供了以下控制:

  • memory.limit_in_bytes:设置内存限制
  • memory.oom_control:控制 OOM 行为
    • 设置为 0 时禁用该 cgroup 的 OOM Killer
    • 设置为 1 时启用 OOM Killer

cgroup v2

在 cgroup v2 中,OOM 控制更加统一:

  • memory.max:设置内存限制
  • memory.oom.group:控制是否杀死整个进程组
    • 设置为 0 时只杀死触发 OOM 的进程
    • 设置为 1 时杀死整个 cgroup 中的所有进程

排查 OOM 问题

查看 OOM 日志

1
2
3
4
5
6
7
8
# 使用 dmesg 查看
dmesg | grep -i "killed process"

# 查看系统日志
grep -i "out of memory" /var/log/messages

# 使用 journalctl(systemd 系统)
journalctl -k | grep -i "oom"

典型的 OOM 日志如下:

1
2
Out of memory: Kill process 1234 (java) score 900 or sacrifice child
Killed process 1234 (java) total-vm: 12345678kB, anon-rss: 9876543kB, file-rss: 123456kB

监控内存使用

1
2
3
4
5
6
7
8
# 查看系统内存使用情况
free -h

# 查看进程内存使用排序
ps aux --sort=-%mem | head -n 10

# 查看 cgroup 内存使用(容器环境)
cat /sys/fs/cgroup/memory/memory.usage_in_bytes

预防措施

  1. 合理设置 swap:适当的 swap 空间可以缓解内存压力
  2. 监控内存使用:使用监控工具及时发现内存异常
  3. 优化程序:减少内存泄漏和不必要的内存占用
  4. 设置合理的 limits:使用 ulimit 或 cgroup 限制进程内存
  5. 配置 OOM 保护:对关键进程设置 oom_score_adj = -1000

参考文档:

  1. 关于OOM-killer
  2. 这里有一篇《OOM Killer》讨论它的工作细节。
  3. Linux 下 OOM Killer 机制的详解
  4. Linux内核OOM机制的详细分析

二、JVM 层:OOM 调查工具完全指南

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

工具速查表

诊断场景 首选工具 备选工具
进程级 CPU/内存概览 top -Hp <pid> htop、atop
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

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

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

实战中可以观察到:当 Young/Old 区的实际占用率很低时,top 和 free 显示的内存利用率也会相应降低——非 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_method、submit_compile、Broker::compile_method 等 JIT 编译器内部函数——正常火焰图中完全没有这些帧,说明 JIT 编译器正在疯狂重新编译被去优化的方法

JIT 分层编译的退化路径:

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

去优化时: C2 ──made not entrant──→ Interpreted → C1 → (等待重新 C2)
↑
CPU 偏高时大量方法卡在这两个阶段

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

诊断要点总结:

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

热点分析树与调用堆栈树

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

调用堆栈树(自顶向下):从入口方法开始,展示各子调用的耗时占比。常见的底层堆栈包括 Thread.run、ThreadPoolExecutor$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),变量名会变成 var1、var2
❌ 语法糖会被还原 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 的 trace、watch、monitor 等命令覆盖,且 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 日志分析

三、问题分析方法论

知识体系图谱

JVM 问题知识图谱

紧急处理原则

出现问题应该第一时间恢复,恢复过程中如果有余地则要保留问题现场供后面排查定位问题,其基本步骤是:

  • 打印堆栈信息
  • 打印内存镜像
  • 生成core文件
  • 保留gc日志文件
  • 保留业务日志文件
  • 查看JAVA堆内存运行分配

如果有余地,可以做完以上的操作后再重启应用。

实例

实例一

1 查看 gc 日志,评估代际问题。
2 查看访问日志,看看应用吞吐量。
3 用 jstat 查看代际问题。
4 把内存 dump 出来,分析数量最多的对象。
5 分析代码。
6 改善代码。

实例二

1 统计线程总数:ps -eLf | grep java -c
2 看 ulimit -u
3 jstack、gcore
4 改善 jvm 参数