文档目录

4.4 配套代码:JFR 三种用法与 jcmd 应急手册

对应小节:4.4 JFR 与 jcmd 这份文件是可以直接贴进生产配置的 JFR 参数,以及 jcmd 的实战用法。

一、JFR:生产推荐的启动配置

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

每个参数的理由:

参数 理由
name=rolling 给录制起名,便于后续 JFR.dump name=rolling
settings=profile 记录更多事件(约 1–2% 开销),适合排障;长期生产可用 default
maxsize=256m 滚动上限,超出丢弃最旧事件(防止磁盘写满)
maxage=1h 只保留最近 1 小时(按你的业务周期调整)
dumponexit=true ⭐ 进程退出(含崩溃)时自动落盘——排查 OOM 的关键
stackdepth=128 栈深度,太浅会丢上下文,太深占空间
filename=... 落盘位置;放到独立分区避免写满根分区

验证是否生效:

jcmd <pid> JFR.check
# 应该看到类似:Recording: name=rolling, ... running

二、运行中控制(不用重启)

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

# ── 出问题时:立刻保存过去 1 小时的事件 ──────────────────
jcmd "$PID" JFR.dump name=rolling filename=/tmp/incident-$(date +%Y%m%d-%H%M%S).jfr

# ── 临时录一段(不依赖启动参数)─────────────────────────
jcmd "$PID" JFR.start name=adhoc settings=profile duration=120s filename=/tmp/adhoc.jfr

# ── 查看正在运行的录制 ──────────────────────────────────
jcmd "$PID" JFR.check

# ── 停止某个录制 ────────────────────────────────────────
jcmd "$PID" JFR.stop name=adhoc

⭐ 最重要的一条:问题发生时立刻 dump。偶发问题的证据只存在于"过去",等重启后再查就没了。

三、用命令行分析 JFR(没有 JMC 也能用)

JDK 自带 jfr 命令,足以完成大部分分析:

# ① 概览:录了哪些事件、各多少条
jfr summary /tmp/incident.jfr

# ② 看最慢的网络 IO(找出"等哪个下游")
jfr print --events jdk.SocketRead /tmp/incident.jfr | head -60

# ③ 看最慢的文件 IO(找到日志/本地缓存的开销)
jfr print --events jdk.FileWrite /tmp/incident.jfr | head -40

# ④ 看锁等待(谁在抢锁、等了多久)
jfr print --events jdk.JavaMonitorEnter /tmp/incident.jfr | head -60

# ⑤ 看 GC 停顿
jfr print --events jdk.GCPhasePause /tmp/incident.jfr | head -40

# ⑥ 看异常(高频异常是隐藏的性能杀手)
jfr print --events jdk.ExceptionStatistics /tmp/incident.jfr

# ⑦ 导出 CPU 采样(可用于生成火焰图)
jfr print --events jdk.ExecutionSample --json /tmp/incident.jfr > samples.json

jfr print 输出的字段解读(以 SocketRead 为例):

jdk.SocketRead {
  startTime = 10:23:45.123
  duration = 812 ms                    ← 阻塞了 812 毫秒
  host = "10.0.1.42"                   ← 远端(数据库或下游)
  port = 5432                          ← 端口 5432 = PostgreSQL
  bytesRead = 1024
  eventThread = "io-8"                 ← 哪个线程
  stackTrace = [
    org.postgresql.core.v3.QueryExecutorImpl.receiveCommandResult(...)
    ...
  ]
}

这一条记录就完成了定位:「io-8 线程在 10:23:45 阻塞了 812 ms,等的是 PostgreSQL(10.0.1.42:5432)」。火焰图给不出这样的精确信息。

四、一个 JFR 自动取证脚本

#!/usr/bin/env bash
# tools/jfr-incident.sh <PID> [output_dir]
#
# 出问题时一键取证:dump JFR + 线程快照 + 指标 + 堆概况
set -uo pipefail

PID="${1:?usage: jfr-incident.sh <PID> [output_dir]}"
OUT="${2:-/tmp/incident-$(date +%Y%m%d-%H%M%S)}"
mkdir -p "$OUT"

echo "═══ 应急取证 → $OUT ═══"
echo

# ① JFR(最有价值,且重启后就没了)
if jcmd "$PID" JFR.check > /dev/null 2>&1; then
  echo "① dump JFR(滚动录制的过去 N 小时)"
  jcmd "$PID" JFR.dump name=rolling filename="$OUT/incident.jfr" 2>/dev/null \
    || { echo "   滚动录制不存在,临时录 60 秒"; \
         jcmd "$PID" JFR.start name=adhoc settings=profile duration=60s filename="$OUT/adhoc.jfr"; \
         sleep 65; }
  ls -lh "$OUT"/*.jfr 2>/dev/null
else
  echo "① ⚠️  JFR 不可用(进程可能已崩溃)"
fi

# ② 线程快照(连续 3 份,用于对比)
echo
echo "② 线程快照(连续 3 份,间隔 3 秒)"
for i in 1 2 3; do
  jcmd "$PID" Thread.print > "$OUT/threads-$i.txt" 2>/dev/null \
    && echo "   threads-$i.txt 已保存" || echo "   ❌ 进程不可用"
  sleep 3
done

# ③ 指标快照
echo
echo "③ 指标快照"
curl -s --max-time 5 http://127.0.0.1:8080/metrics > "$OUT/metrics.txt" 2>/dev/null \
  && echo "   metrics.txt 已保存" || echo "   ⚠️  指标端点不可达"

# ④ 堆与 GC 概况
echo
echo "④ 堆与 GC 概况"
jcmd "$PID" GC.heap_info > "$OUT/heap.txt" 2>/dev/null && echo "   heap.txt 已保存"
jcmd "$PID" VM.flags > "$OUT/jvm-flags.txt" 2>/dev/null && echo "   jvm-flags.txt 已保存"

# ⑤ 容器状态(如果在容器内)
echo
echo "⑤ 容器状态"
{
  echo "cpu.max: $(cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo n/a)"
  echo "memory.max: $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo n/a)"
  echo "--- cpu.stat ---"
  cat /sys/fs/cgroup/cpu.stat 2>/dev/null | head -5
} > "$OUT/container.txt"
echo "   container.txt 已保存"

echo
echo "═══ 取证完成 ═══"
echo
echo "快速分析入口:"
echo "  jfr summary $OUT/incident.jfr"
echo "  jfr print --events jdk.SocketRead $OUT/incident.jfr | head -60"
echo "  jfr print --events jdk.JavaMonitorEnter $OUT/incident.jfr | head -60"
echo "  对比 $OUT/threads-1.txt 与 threads-3.txt 找持续阻塞的栈"

五、jcmd 应急手册(按场景)

场景 命令 看什么
怀疑卡住 jcmd <pid> Thread.print ×3 多次快照里持续存在的栈
怀疑内存泄漏 jcmd <pid> GC.class_histogram | head -30 哪类对象最多
需要深入分析内存 jcmd <pid> GC.heap_dump /tmp/x.hprof 用 MAT 看引用链
确认线上参数 jcmd <pid> VM.flags 别相信文档,看实际值
确认配置生效 jcmd <pid> VM.system_properties 环境变量/系统属性
堆外内存增长 jcmd <pid> VM.native_memory summary 需 -XX:NativeMemoryTracking=summary
快速看 JFR 事件 jcmd <pid> JFR.start duration=30s filename=/tmp/x.jfr —
找 PID jcmd 列出所有 JVM 进程

线程快照的自动化判读

#!/usr/bin/env bash
# tools/analyze-threads.sh <threads-1.txt> [threads-2.txt ...]
#
# 从多份线程快照里找出"持续存在的问题"
set -uo pipefail

[ $# -lt 1 ] && { echo "usage: analyze-threads.sh <threads-*.txt>..."; exit 1; }

echo "═══ 线程状态分布(每份快照)═══"
for f in "$@"; do
  echo
  echo "[$f]"
  grep -oE "java.lang.Thread.State: [A-Z_]+" "$f" | sort | uniq -c | sort -rn
done

echo
echo "═══ 高频阻塞点(跨所有快照统计 Top 10)═══"
cat "$@" | grep -E "^\s+at " | sed 's/^[[:space:]]*//' | sort | uniq -c | sort -rn | head -10

echo
echo "═══ 关键模式检查 ═══"
for pattern in "HikariPool" "socketRead" "BLOCKED" "park" "FileOutputStream"; do
  COUNT=$(cat "$@" | grep -c "$pattern" || echo 0)
  [ "$COUNT" -gt 0 ] && echo "  $pattern: $COUNT 次"
done

echo
echo "判读:"
echo "  HikariPool 多        → 连接池排队(第 4.7 节)"
echo "  socketRead 多        → 等数据库/下游"
echo "  BLOCKED 多           → 锁竞争"
echo "  park 多              → 等锁/队列/池"
echo "  FileOutputStream 多  → 同步日志刷盘"

六、动手改造

改动 观察什么
把 settings=profile 改成 default 事件变少、文件变小——对比能找到的线索是否减少
去掉 dumponexit=true 并故意 OOM 崩溃后没有任何 JFR 文件——这就是那一行参数的价值
用 jfr print --events jdk.FileWrite 看日志开销 如果热路径上每次请求都有 FileWrite,说明日志是瓶颈(第 4.6 节)
用 jfr print --events jdk.ExceptionStatistics 高频异常(比如用它做流程控制)是隐藏的性能杀手
在 analyze-threads.sh 里加更多关键词 适配你自己的框架(比如 Redis 客户端、gRPC)

七、这段代码的局限

  • JFR 也有开销:settings=profile 约 1–2%,高频事件(如 jdk.ObjectAllocationSample)在分配极多时开销更大。
  • jfr print 输出很长,需要配合 head/grep 使用;JMC 的图形界面在探索性分析上更高效。
  • JFR 不记录所有事件:某些原生代码、直接内存操作可能不在事件里(需要 async-profiler 的 native 模式补充)。
  • GC.class_histogram 会触发 STW(Stop-The-World),在生产上对大堆执行可能停顿几秒——不要在峰值流量时执行。