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),在生产上对大堆执行可能停顿几秒——不要在峰值流量时执行。