JDK Flight Recorder 生产录制与证据治理手册
JFR 解决的是故障前后发生了什么
接口变慢而进程 CPU 没有打满,可能是线程等锁、Socket 阻塞、连接池排队,也可能是 cgroup 节流让可运行线程拿不到时间片。此时一张即时曲线只能说明查看那一刻的状态。JDK Flight Recorder,简称 JFR,把 JVM、JDK 库和应用事件写进同一时间轴,价值在于保住问题发生前后的上下文,而不是提供一个更漂亮的 CPU 排名。
JFR 是 JDK 内置能力,不是 JDK Mission Control 的附属插件。目标 JVM 负责采集和保留事件,jcmd 控制运行中的 recording,jfr 命令检查与转换已经导出的文件。JMC、VisualVM 或其他分析器可以读取部分产物,但它们不拥有录制状态。这个边界决定了生产基线应先证明录制独立可靠,再选择桌面分析工具。
先确认目标 JDK 真正提供什么
完整 JDK 通常包含 JFR 运行时、jcmd 控制入口和 jfr 文件工具,精简的 jlink 镜像却可能裁掉模块或命令。诊断工具与目标 JVM 应来自同一发行线,最好来自同一供应商构建;跨版本 Attach 偶尔能工作,不等于可以作为受支持基线。进入生产前先从目标环境取证:
java --version
command -v java jcmd jfr
jcmd -h
jfr help
jcmd "$PID" help JFR.start
jcmd "$PID" help JFR.dump
jcmd "$PID" help JFR.stopJDK 8 的许可历史、厂商回移与命令形态和现代 JDK 不同,不能把新版本示例直接复制过去。文章中的命令以当前 JDK 文档为校准对象,真正执行时仍以目标 VM 返回的 help 为准。组织若维护多个 LTS,应为每条发行线保存独立的 JFC、命令契约与开销基线。
recording、chunk 和文件不是同一个对象
JFR 事件先进入线程本地缓冲,再汇聚到全局缓冲。启用磁盘后,JVM 把数据写入 repository 中的 chunk;JFR.dump 可以在 recording 继续运行时导出窗口,JFR.stop 则结束指定 recording 并可同时写出文件。最终 .jfr 是可携带的证据制品,repository 是运行中状态,两者的容量、权限和清理策略不能混用。
maxage 控制保留的时间窗口,maxsize 控制该 recording 保留的数据量,较老 chunk 会被轮换。它们都不限制人工复制、多个并行 recording 或分析机下载的总量。repository 若与应用日志共用一个接近满盘的分区,持续录制会和故障本身争夺 I/O;若放在容器易失层,重启后又可能连残留 chunk 都找不到。
| 对象 | 谁持有 | 正常出口 | 常见误判 |
|---|---|---|---|
| recording | 目标 JVM | JFR.dump 或 JFR.stop | 客户端退出不代表 recording 已停 |
| repository | 目标 JVM 的运行目录 | 轮换、应急恢复、进程退出清理 | 可直接当成完整 .jfr 搬走 |
| chunk | repository 的分段数据 | jfr assemble 在隔离副本中组装 | 删除一个旧块不会破坏当前状态 |
.jfr 文件 | 操作者选择的证据目录 | jfr summary、分析器、受控传输 | 文件存在就代表覆盖了告警窗口 |
default 与 profile 交换的是成本和分辨率
JDK 自带的 default.jfc 面向低开销持续记录,profile.jfc 打开更多事件并提高采样密度,适合短时间剖析。官方给出的低开销经验只能用于理解设计目标,不能替代自己的延迟 SLO。高线程数、高分配率、低延迟服务和 CPU 配额紧张的容器,必须在真实负载模型下测量 recording 对 P99、CPU、GC pause、文件增长和 throttling 的影响。
| 配置 | 改变的证据 | 代价失控时的表现 |
|---|---|---|
settings=default | 保留较低密度的长期上下文 | 短热点可能没有足够样本 |
settings=profile | 增加方法、分配和等待相关事件 | CPU 与文件体积同时上升 |
duration | 决定自动停止时间 | 无界录制依赖人工停止 |
maxage / maxsize | 决定滚动保留窗口 | 告警到达前现场已被覆盖 |
事件 threshold | 只记录超过阈值的持续事件 | 过高看不见竞争,过低造成事件洪峰 |
stacktrace=true | 为事件附带调用栈 | 定位更直接,采集与存储更重 |
path-to-gc-roots | 追踪潜在泄漏对象到 GC roots | 可能带来明显额外停顿 |
不要原地修改 JDK 自带模板。自定义配置应复制进版本控制,注明目标 JDK、打开或关闭的事件、阈值、原因、负载结果和回退文件。JDK 升级后重新比较事件元数据;同名事件的字段、默认阈值和实现成本都可能变化。
生产上常用两种录制方式
短时剖析适合已有明确问题窗口的单个实例。先核对 PID、Attach 权限和证据目录,再给 recording 一个唯一名字:
test "${PID:-}" -gt 0 2>/dev/null || { echo "PID 必须是正整数" >&2; exit 2; }
OUT="$(mktemp -d "${TMPDIR:-/tmp}/jfr-${PID}-XXXXXXXX")"
chmod 700 "$OUT"
jcmd "$PID" JFR.start \
name=incident-profile \
settings=profile \
duration=120s \
disk=true \
maxsize=256m \
filename="$OUT/incident-%p-%t.jfr"
jcmd "$PID" JFR.check name=incident-profile录制期间同步观察业务延迟、错误率、进程 CPU、GC、cgroup throttling 和证据分区。指标明显恶化时,应按名字停止这次增强录制并保留已经得到的文件,而不是继续等待“样本更多一些”:
STOP_FILE="$OUT/stopped-$PID.jfr"
jcmd "$PID" JFR.stop name=incident-profile filename="$STOP_FILE"
jcmd "$PID" JFR.check持续环形基线适合难以复现、告警后才 Attach 已经太晚的抖动。启动参数把 repository 与最终文件分开:
-XX:StartFlightRecording=name=baseline,settings=default,disk=true,maxage=30m,maxsize=256m,dumponexit=true,filename=/var/lib/myapp/jfr/baseline-%p-%t.jfr
-XX:FlightRecorderOptions=repository=/var/lib/myapp/jfr/repositorydumponexit=true 只在 JVM 有机会执行正常退出处理时尝试落盘,不能覆盖 kill -9、宿主机掉电或存储失效。HotSpot 致命错误可能生成 emergency dump,这是另一条应急路径。最终文件损坏或缺失时,可以复制残留 repository,在隔离目录运行 jfr assemble;不能在仍工作的 repository 上原地整理、删除或试验恢复。
JFR.dump 截取窗口时不会停止基线
持续 recording 最实用的动作是导出最近一段,而不是停止整个基线:
jcmd "$PID" JFR.dump \
name=baseline \
maxage=15m \
filename="$OUT/baseline-last-window-%p-%t.jfr"
jcmd "$PID" JFR.check name=baseline成功后的 JFR.check 应仍显示 baseline 处于运行状态。事故脚本必须显式传 recording 名称,因为同一 JVM 可以同时存在基线与短时 profile。dump、stop 和客户端进程退出的语义不同,脚本若只看本地命令已经返回,很容易停错录制或在后台留下无界状态。
用小实验验证事件阈值会怎样欺骗判断
下面的实验只在随机临时目录启动小堆 JVM,制造 CPU 循环、短锁竞争和有界分配。它不绑定网络端口,也不生成 heap dump。
LAB="$(mktemp -d "${TMPDIR:-/tmp}/jfr-lab-XXXXXXXX")"
chmod 700 "$LAB"
cat > "$LAB/JfrLab.java" <<'JAVA'
import java.util.ArrayList;
import java.util.List;
public class JfrLab {
private static final Object LOCK = new Object();
private static final List<byte[]> RETAINED = new ArrayList<>();
public static void main(String[] args) throws Exception {
long end = System.nanoTime() + 70_000_000_000L;
Thread cpu = new Thread(() -> {
long value = 1;
while (System.nanoTime() < end) value = (value * 1664525 + 1013904223) ^ (value >>> 7);
System.out.println(value);
}, "lab-cpu");
Thread holder = new Thread(() -> {
while (System.nanoTime() < end) synchronized (LOCK) {
try { Thread.sleep(3); } catch (InterruptedException ignored) { return; }
}
}, "lab-lock-holder");
Thread waiter = new Thread(() -> {
while (System.nanoTime() < end) synchronized (LOCK) { Thread.onSpinWait(); }
}, "lab-lock-waiter");
cpu.start(); holder.start(); waiter.start();
for (int i = 0; i < 32; i++) { RETAINED.add(new byte[256 * 1024]); Thread.sleep(100); }
cpu.join(); holder.join(); waiter.join();
}
}
JAVA
javac -d "$LAB" "$LAB/JfrLab.java"
java -Xms128m -Xmx128m -cp "$LAB" JfrLab > "$LAB/app.log" 2>&1 &
PID=$!
jcmd "$PID" JFR.start name=lab-profile settings=profile duration=30s filename="$LAB/profile-%p-%t.jfr"录制结束后先用 CLI 检查结构,不急着打开 GUI:
sleep 35
JFR_FILE="$(find "$LAB" -maxdepth 1 -type f -name 'profile-*.jfr' -print -quit)"
test -n "$JFR_FILE" && test -s "$JFR_FILE"
jfr summary "$JFR_FILE"
jfr print --events jdk.CPULoad,jdk.ExecutionSample,jdk.JavaMonitorEnter "$JFR_FILE" | head -n 120ExecutionSample 应反复出现 lab-cpu,事件数量则会随机器和 JDK 改变。三毫秒锁竞争可能低于模板阈值,第一次文件中的 jdk.JavaMonitorEnter 很少甚至为零;这只能证明配置没有记录到,不能证明竞争不存在。对仍运行的实验把该事件阈值降为一毫秒,再短录一次,比较文件体积、事件数与业务扰动。阈值越低,证据越细,成本也越可能上升。
jcmd "$PID" JFR.start name=lab-lock settings=profile duration=15s \
filename="$LAB/lock-%p-%t.jfr" jdk.JavaMonitorEnter#threshold=1ms
sleep 20
LOCK_FILE="$(find "$LAB" -maxdepth 1 -type f -name 'lock-*.jfr' -print -quit)"
jfr summary "$LOCK_FILE"
jfr print --events jdk.JavaMonitorEnter "$LOCK_FILE" | head -n 100
wait "$PID" 2>/dev/null || true
rm -rf -- "$LAB"读文件之前先证明它属于这次问题
jfr summary 应确认起止时间、JVM 身份、事件类型和数量。随后把 recording 窗口与告警、发布、负载阶段对齐。CPU 样本的宽度表示被采到的次数,不是一次方法调用的精确耗时;allocation sample 指向高分配路径,不等于存活对象;JavaMonitorEnter 与 JavaMonitorWait 也表达不同的锁语义。
JFR 中的 host CPU 与 JVM CPU 还要和 cgroup 指标一起解释:
cat /sys/fs/cgroup/cpu.max 2>/dev/null || true
cat /sys/fs/cgroup/cpu.stat 2>/dev/null || true
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
cat /sys/fs/cgroup/memory.events 2>/dev/null || true配额节流会让 JVM CPU 看起来没有打满,却让可运行线程排队;容器 OOMKilled 也不会由 Java heap 事件完整解释。稳定结论至少需要事件覆盖故障窗口、调用栈指向具体所有者、业务或资源指标同步变化,并且修复后在同类负载下同时改善。
失败时先区分录制、导出和分析
recording 存在却没有热点,先查时间窗、模板、duration、事件开关、阈值和 stacktrace;真正瓶颈也可能是 I/O 等待而非 CPU。正确做法是缩小假设后短时增强相关事件,不是一次打开所有事件。
文件打不开时,检查是否仍在写入、磁盘是否曾满、JVM 如何终止,以及分析工具是否支持产生它的 JDK。先用同版本 jfr summary 验证结构;repository 残留块只在副本中组装。开启增强录制后延迟上升,则立即记录配置和受影响指标,停止该 recording,确认 JFR.check 状态,再从 profile 模板、过低阈值、stacktrace、GC root 路径、高频自定义事件和磁盘拥塞中定位成本来源。
.jfr 是敏感运行时制品
录制可能包含类名、方法栈、线程名、文件路径、Socket 地址、命令行、系统属性和自定义业务字段。自定义事件尤其容易把订单号、URL、消息键、SQL 或用户内容带入文件。事件类应定义字段白名单、脱敏规则和最大字符串长度,不记录认证头、Cookie、Token、完整 SQL 参数或请求体。
项目仓库保存 JFC、脚本、证据清单样例与保留策略,不保存真实 .jfr 或 repository。产物使用独立容量、最小 ACL、加密传输、文件校验、下载审计和明确销毁时间。容量由峰值事件速率乘以响应窗口后再留安全余量,并额外计算并行 recording、导出副本和分析机下载;单个 maxsize 无法替团队完成总量控制。
每次 JDK 或基础镜像升级都要重跑 JFC 加载、短录制、环形轮换、正常退出、异常残留恢复、开销基线与清理。需要桌面规则分析时进入 JDK Mission Control 手册,需要开发机即时采样时进入 VisualVM 手册,线程栈或 heap dump 升级则遵循 jcmd、jstack 与 jmap JVM 现场诊断手册。版本与命令细节继续以 JFR 性能诊断指南、jcmd 参考和 jfr 文件工具为准。
