文档目录

4.4 JFR 与 jcmd:能常开的剖析与无依赖应急

上一节:4.3 async-profiler | 下一节:4.5 系统级观测 配套代码:04-toolchain/04-jfr-and-jcmd


一句话结论

JFR(JDK Flight Recorder)是唯一适合在生产环境长期开着的剖析工具(开销约 1%),它记录的是「系统发生了什么」的完整事件流。jcmd 则是零依赖的应急手段——什么都不用装,出问题时随手可用。


一、用「24 小时动态心电图」理解 JFR

普通心电图只在医院做几分钟。但有些心脏问题只在特定时刻发作——所以医生给病人戴一个24 小时动态心电图记录仪:

动态心电图 JFR
长期佩戴,开销极小 生产环境常开,约 1% 开销
持续记录,出事时回放 滚动录制,事后 dump
记录多种信号(心率、心律、ST 段) 记录几十类事件(GC、锁、IO、异常…)
不能当场看结果,要事后分析 用 JDK Mission Control 分析

关键价值:性能问题往往偶发且不可复现。JFR 让你能在问题发生后回到现场。


二、JFR 的三种用法

用法一:出问题时临时录一段

PID=$(jcmd | grep app.jar | awk '{print $1}')

# 录制 120 秒,输出到文件
jcmd "$PID" JFR.start name=perf settings=profile duration=120s filename=/tmp/perf.jfr

# 结束后自动停止;也可以手动
jcmd "$PID" JFR.stop name=perf

用法二:滚动录制(推荐用于生产)⭐

# 保持最多 256MB / 1 小时的事件,超出后自动丢弃最旧的
jcmd "$PID" JFR.start name=rolling settings=profile maxsize=256m maxage=1h

# 出问题时立刻 dump(不用等录制结束)
jcmd "$PID" JFR.dump name=rolling filename=/tmp/incident-$(date +%s).jfr

# 不再需要时停止
jcmd "$PID" JFR.stop name=rolling

这是最实用的模式:平时默默记录,出事时 dump 出「过去 1 小时」的事件。

用法三:启动时就开着

java -XX:StartFlightRecording=name=app,settings=profile,maxsize=256m,maxage=1h,dumponexit=true,filename=/var/log/app/recording.jfr \
     -jar app.jar

dumponexit=true 很重要:进程退出(包括崩溃)时会把录制内容落盘——这是排查 OOM/崩溃的关键证据。


三、JFR 最值得看的 8 类事件

事件 回答 关键字段
jdk.ExecutionSample CPU 热点 采样栈(可导出成火焰图)
jdk.ObjectAllocationSample 分配热点 对象类型、分配大小
jdk.JavaMonitorEnter 锁等待 锁对象、等待时长
jdk.ThreadPark 线程挂起 挂起时长、位置
jdk.SocketRead / jdk.SocketWrite 阻塞网络 IO 耗时、字节数、远端地址
jdk.FileRead / jdk.FileWrite 阻塞文件 IO 耗时、文件路径
jdk.GCPhasePause GC 停顿 各阶段耗时
jdk.ExceptionThrow 异常抛出 异常类型、位置
jdk.ClassLoad / jdk.ClassDefine 类加载 启动期开销
jdk.VirtualThreadPinned 虚拟线程被 pin 住 若用虚拟线程

⭐ 最容易被忽略、最有用的两类:

  • jdk.SocketRead / jdk.FileRead:它们直接告诉你「阻塞在哪个 IO 上、花了多久」。这是「CPU 不高但慢」的确定性证据。
  • jdk.JavaMonitorEnter:直接给出锁等待时长排行榜——比火焰图更精确(火焰图只能看采样比例)。

用 JDK Mission Control 分析

JMC 是官方分析工具,几个最有用的视图:

视图 用途
Automated Analysis 自动给出可疑结论(起点,但要自己验证)
Method Profiling CPU 热点(可以导出火焰图)
Lock Instances 锁争用排行
Socket I/O / File I/O 阻塞 IO 排行 ⭐
Memory / GC 分配速率、GC 停顿趋势
Event Browser 按时间轴看所有事件(找时间对齐)

最实用的一个动作:在 Event Browser 里,把 jdk.GCPhasePause 的时间点和你监控里的 P99 尖刺时间点对齐。对齐 → GC 是根因;不对齐 → 排除 GC(第 2 章 2.5 节)。


四、jcmd:零依赖的应急工具

jcmd 的价值:JDK 自带,什么都不用装,进程还活着就能用。

命令 用途 什么时候用
jcmd <pid> Thread.print 线程快照 怀疑卡住/锁竞争/池耗尽
jcmd <pid> GC.heap_info 堆与 GC 概况 内存问题
jcmd <pid> GC.class_histogram 对象数量排行 怀疑内存泄漏(谁最多)
jcmd <pid> GC.heap_dump <file> 堆快照 需要 MAT/VisualVM 深入分析
jcmd <pid> JFR.start/dump/stop JFR 控制 事件取证
jcmd <pid> VM.native_memory summary 堆外内存 堆稳定但 RSS 上涨
jcmd <pid> VM.flags 当前 JVM 参数 确认线上到底用了什么参数
jcmd <pid> VM.system_properties 系统属性 确认配置生效
jcmd (无参数) 列出所有 JVM 进程 找 PID

线程快照的正确用法

一次快照没有价值,连续多次对比才有。

PID=$(jcmd | grep app.jar | awk '{print $1}')

# 连续取 5 份,间隔 3 秒
for i in $(seq 1 5); do
  jcmd "$PID" Thread.print > "threads-$i.txt"
  sleep 3
done

# 观察:哪些栈在多次快照中【持续存在】
for i in $(seq 1 5); do
  echo "--- 第 $i 次 ---"
  grep -A2 "java.lang.Thread.State" "threads-$i.txt" | grep -E "^\s+at " | sort | uniq -c | sort -rn | head -5
done

判读规则:

现象 含义
大量线程停在同一处 RUNNABLE + CPU 满 真的在算(或在自旋)
大量 BLOCKED (on object monitor) 锁竞争——栈里能看到持锁者
大量 WAITING (parking) 且都在池的取任务处 池/队列供给不足,或队列空
大量停在同一 socket read 下游慢,或连接被占满
大量停在 HikariPool.getConnection 连接池排队
线程数随时间持续增长 线程泄漏

一个实用技巧:搜 HikariPool、socketRead、BLOCKED 这三个关键词,能覆盖大部分常见问题。


五、JFR vs async-profiler:怎么选

场景 选择
生产环境长期开着,等出问题时取证 JFR(滚动录制)
已知问题、需要深挖热点 async-profiler(CPU/分配火焰图更精确)
需要看「阻塞在哪个 IO、多久」 JFR(SocketRead/FileRead 事件直接给耗时)
需要看锁等待排行 两者都行,JFR 的 JavaMonitorEnter 更精确
只看 CPU 火焰图 async-profiler(图更易读)
进程快挂了、只想快速看一眼 jcmd(零依赖)

推荐组合:

生产环境:JFR 滚动录制(常开)+ Prometheus 指标 + 结构化日志
出问题时:dump JFR → JMC 定位到大致范围 → async-profiler 深挖 → 结论
应急时刻:jcmd Thread.print(连续多份)

六、本节小结

  1. JFR 是唯一适合生产常开的剖析工具(约 1% 开销),用滚动录制模式最实用。
  2. dumponexit=true 能在进程崩溃时保留证据——排查 OOM 的关键。
  3. JFR 有几十类事件,最有价值的是 SocketRead/FileRead(阻塞 IO 耗时)和 JavaMonitorEnter(锁等待时长)——它们比火焰图更精确。
  4. 把 GC 停顿的时间戳与 P99 尖刺对齐,是排除/确认 GC 的标准动作。
  5. jcmd 是零依赖应急工具:Thread.print 必须连续取多份对比才有价值。
  6. 用 jcmd <pid> VM.flags 确认线上真正的 JVM 参数——不要相信文档。

七、自测

  1. 一个服务的性能问题「偶发、不可复现」。你会怎么配置 JFR 来保证下次发生时有证据?
  2. 你取了 5 份线程快照,发现每份里都有 40+ 个线程停在 HikariPool.getConnection。这说明什么?接下来该看什么指标?(这道题的答案会连接到第 7 节)
  3. JFR 的 jdk.SocketRead 事件显示某个下游调用的 P99 是 800 ms,而 CPU 火焰图里完全看不到这个调用。请解释为什么不矛盾,以及这个信息对定位的价值。
  1. 用 JFR 滚动录制并在启动参数里配置:-XX:StartFlightRecording=name=rolling,settings=profile,maxsize=256m,maxage=1h,dumponexit=true,filename=/var/log/app/recording.jfr。这样:① 平时持续记录最近 1 小时的事件(开销约 1%,可以常开);② 用户报障或告警触发时,立刻用 jcmd <pid> JFR.dump name=rolling filename=/tmp/incident.jfr 保存现场;③ 如果进程崩溃/重启,dumponexit=true 会自动落盘。关键点:不要等出问题才开始录——偶发问题的证据只存在于"过去"。
  2. 说明大量请求在等连接池分配连接——这是「连接池排队」的直接证据(不是"数据库慢",而是"连不上数据库"这个环节在排队)。接下来要看的指标:① db_pool_pending(是否 > 0,以及数值大小);② db_pool_active / db_pool_total(是不是已经打满);③ 数据库侧的 pg_stat_activity(连接数是否触顶、有没有长时间运行的查询);④ 应用侧依赖计时 dep.postgres 的 P99(它包含了等连接的时间);⑤ 如果有,看单条 SQL 的执行时间(用来区分「池太小」还是「查询太慢」)。注意:这两种情况的修法完全相反——池太小要按 Little’s Law 调整,查询太慢要优化 SQL,盲目加池会把压力转移到数据库。
  3. 不矛盾,因为 SocketRead 是「阻塞时间」而不是「CPU 时间」。CPU 火焰图只统计「CPU 正在执行什么」,而等待网络响应时线程是阻塞的、不消耗 CPU,所以在 CPU 火焰图里看不到(甚至看不到这个调用栈)。价值:jdk.SocketRead 事件直接给出「阻塞在哪个 socket、远端是谁、花了多久」——这就把「CPU 不高但慢」的原因确定性地指向了某个下游依赖,比火焰图的采样比例更精确。下一步:用 tracing(第 6 节)或依赖级指标确认是哪个下游,再去查那个服务或网络。