JFR、JMC 与 VisualVM JVM 性能证据手册
一次“CPU 不高但请求很慢”的现场
发布十分钟后,接口 P99 从 180 ms 升到 2 s,容器 CPU只有 35%,堆也没打满。此时打开 VisualVM看一眼曲线,或者临时开最重的 profiler,都很容易得出错误结论:低 CPU可能是线程在等锁、Socket或连接池;堆曲线稳定也可能伴随频繁分配与 GC;采样窗口错过高峰,则“没有热点”只说明没有录到。
JFR、JMC 和 VisualVM在这条链路上扮演不同角色:JFR在 JVM 内把事件写入内存缓冲和磁盘 chunk;JMC离线读取 .jfr,把 CPU、GC、锁、I/O和线程事件放回同一时间轴;VisualVM适合开发机上的即时监控、线程观察、采样和 dump浏览。生产优先把 JFR文件安全带走离线分析,而不是为了一个 GUI永久开放远程管理端口。
从目标 JDK 开始安装
现代 JDK已经包含 JFR运行时、jcmd控制入口和 jfr文件工具,但精简 jlink镜像可能裁掉相关模块或命令。先在与目标同版本、最好同发行版的 JDK上核对:
java -version
command -v jcmd jfr
jcmd -h
jfr help跨 JDK版本使用 attach工具不是受支持组合。JDK 8的 JFR可用性、许可历史和厂商回移差异较大,不能把 JDK 11+ 的命令直接复制过去;先查询目标发行版。团队新建诊断基线时,优先选择仍在支持周期内的 LTS或组织批准版本,并把 jcmd <pid> help JFR.start纳入镜像验收。
JMC不是常规 JDK安装的一部分,需要从 OpenJDK JMC列出的下游发行版或组织软件仓库安装。当前 Oracle JMC 9系列需要 64 位 JDK 21或更高版本来运行 JMC本身;这不等于被分析的录制必须由 JDK 21产生。安装后先在非生产 .jfr文件上验证打开、规则页、事件浏览和导出能力,再进入受控分析机。
VisualVM也应独立安装,下载与插件都要经过版本锁定、校验和供应链审批:
visualvm --help
visualvm --jdkhome /path/to/approved-jdk--jdkhome选择的是运行 VisualVM的 JDK,不会把目标 JVM升级。插件拥有观察甚至控制目标进程的能力,只安装确有用途的来源,并把 VisualVM用户目录当作敏感配置目录管理,因为其中可能保存远程主机和 JMX连接信息。
先选问题窗口,再选事件
JFR不是定时截图。事件先写入每线程缓冲,再汇聚到全局缓冲;启用磁盘录制时,JVM把数据写成 repository中的 chunk,最终 dump或 stop时组装成 .jfr文件。maxage和 maxsize限制的是保留窗口,较老 chunk会被轮换。若 repository与应用日志共用一个接近满盘的分区,持续录制就可能与事故争夺磁盘。
default.jfc为长期低开销记录设计,profile.jfc采集更多样本和事件,适合短时间剖析。Oracle给出的经验值是:多数应用用默认设置做定时 profiling录制时开销低于 2%,持续 standard录制通常没有可测影响;default.jfc通常低于 1%。这些是产品经验,不是你的延迟 SLO承诺。低延迟、超高线程数、高分配率和 cgroup CPU紧张的服务必须用自己的负载模型测扰动。
参数改变了什么
| 字段 | 影响对象 | 配错后的现场 |
|---|---|---|
settings=default | 较少事件与较低采样密度 | 适合持续基线,但短热点可能样本不足 |
settings=profile | 更多事件与方法样本 | 证据更细,CPU、文件和存储开销上升 |
duration | 录制何时自动停止 | 0s表示持续运行,忘记配容量上限会长期增长 |
disk=true | 事件写入磁盘 repository | 能保留更长窗口,也会产生磁盘 I/O |
maxage | 最老可保留时间 | 太短会在告警到达前覆盖现场 |
maxsize | 最多保留的数据量 | 太小丢窗口,太大挤占分区;不能小于最大 chunk |
filename | stop/dump产物位置 | 相对路径落在进程工作目录,容易失控或找不到 |
dumponexit | JVM正常退出时是否把录制落盘 | 不覆盖 kill -9、掉电等无法完成退出处理的场景;目录不可写时同样无法兑现 |
事件 threshold | 只记录超过阈值的持续事件 | 太高看不见短竞争,太低事件量和开销暴涨 |
stacktrace=true | 事件携带调用栈 | 定位更直接,记录与存储成本更高 |
path-to-gc-roots | 记录潜在泄漏对象到 GC roots的路径 | 有价值但耗时,可能带来额外停顿 |
不要修改 JDK自带的 default.jfc和 profile.jfc。需要自定义时复制到版本控制,记录目标 JDK、修改的事件、阈值、理由、负载验证和回退文件;升级 JDK后重新比较事件元数据。
两种生产录制姿势
运行中短时剖析
先确认 PID、attach权限、目标能力和输出目录。文件名使用目标 JVM支持的 %p、%t占位符,避免覆盖旧证据:
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" help JFR.start
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-profileJFR.start的影响等级为低,但 profile事件量、阈值和业务负载决定真实成本。录制期间同步观察 P99、错误率、进程 CPU、cgroup throttling、GC pause和证据分区;一旦明显恶化,用录制名停止并落盘:
STOP_FILE="$OUT/stopped-$PID-$(date -u +%Y%m%dT%H%M%SZ).jfr"
jcmd "$PID" JFR.stop name=incident-profile filename="$STOP_FILE"
jcmd "$PID" JFR.checkJFR.dump会在录制继续运行时导出副本,适合从持续环形记录截取最近窗口:
jcmd "$PID" JFR.dump \
name=baseline \
maxage=15m \
filename="$OUT/baseline-last-15m-%p-%t.jfr"dump之后 recording仍在运行;stop之后才终止指定 recording。事故脚本必须显式写 recording名称,否则多个并行录制时容易导错或停错。
JVM启动时保留环形基线
对无法稳定复现的偶发抖动,启动时持续记录比告警后 attach更可靠:
-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/repositoryrepository存放运行中的 chunk,filename是停止或正常退出时形成的录制文件,两者生命周期不同。dumponexit=true只保证 JVM有机会执行正常退出流程时尝试落盘,不能把 kill -9、宿主机掉电或存储失效变成完整录制。HotSpot发生致命错误时可能生成 JFR emergency dump,这是独立于 dumponexit的应急路径;若最终文件缺失或损坏,还可以复制残留 repository chunk,在隔离目录用 jfr assemble尝试恢复。三条路径都必须演练,且任何一条都不能承诺所有崩溃必然留下完整证据。
目录要由应用 UID独占写入,使用独立容量配额并监控剩余空间;不要指向共享 /tmp、镜像只读层或应用日志轮转目录。maxage=30m和 maxsize=256m只是示例,真实值由“告警发现延迟 + 人员响应时间 + 事件速率”测得。
变更启动参数要先灰度一个实例。回退时删除新增 JVM参数并滚动重启,确认 jcmd "$PID" JFR.check没有意外 recording、repository不再增长、延迟与 CPU回到基线,再按保留策略清理旧 chunk和导出文件。
一个可复制的正反实验
这个实验在随机临时目录启动 128 MiB测试 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 locker = 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(); locker.start(); waiter.start();
for (int i = 0; i < 32; i++) {
RETAINED.add(new byte[256 * 1024]);
Thread.sleep(100);
}
cpu.join(); locker.join(); waiter.join();
}
}
JAVA
javac -d "$LAB" "$LAB/JfrLab.java"
java -Xms128m -Xmx128m -cp "$LAB" JfrLab > "$LAB/app.log" 2>&1 &
PID=$!
printf 'lab=%s pid=%s\n' "$LAB" "$PID"先跑 30秒 profile录制:
jcmd "$PID" JFR.start \
name=lab-profile \
settings=profile \
duration=30s \
filename="$LAB/profile-%p-%t.jfr"
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 120预期 summary列出 CPU load、execution sample和 monitor相关事件;ExecutionSample中 lab-cpu调用栈反复出现。具体事件数随机器和 JDK变化,不能把某个固定数量写成门禁。
反向实验验证“阈值会让问题消失”。3 ms锁竞争可能低于 profile模板的 monitor阈值,第一份文件里 jdk.JavaMonitorEnter很少甚至为零,这不代表没有竞争。对仍在运行的小堆实验,把阈值降到 1 ms再录 15秒:
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预期第二份 recording出现更多 monitor enter事件,并能关联 lab-lock-holder与 lab-lock-waiter。这也展示了代价:阈值越低,事件数、栈采集、文件体积和 CPU越可能上升。生产只能在短窗口、单实例和同步监控下做同类调整。
实验结束后等待自有子进程并清理随机目录:
wait "$PID" 2>/dev/null || true
rm -rf -- "$LAB"没有运行 JFR.stop name=<name>就直接删目录是不完整清理;上面的两次 recording有 duration,会自动停止。手工中断实验时,应先 JFR.check,停止仍在运行的 recording,再终止测试进程和删除目录。
JMC 中按因果顺序读录制
打开 .jfr后先看录制起止时间、JVM版本、主机和进程信息,确认文件属于告警实例。随后框选告警前后的同一时间窗,不要拿整段平均值冲淡尖峰。
规则结果只是入口。 JMC规则能指出异常 GC、锁竞争或 I/O,但阈值不理解你的业务 SLO,必须回到事件与栈。先看 CPU Load。 JVM CPU低而机器 CPU高,可能是邻居进程;JVM CPU高再看 thread CPU和 execution samples。方法样本宽度代表被采到的次数,不等于精确业务耗时占比。把 GC pause放回请求窗口。 看 pause总时长、最长暂停、GC原因、分配速率和回收后 live set。堆使用高但回收稳定,不等于泄漏;低堆使用也可能因高分配率频繁 GC。
区分两类锁事件。 jdk.JavaMonitorEnter表示进入 monitor时竞争,jdk.JavaMonitorWait对应 Object.wait等等待语义。等待连接池或 LockSupport.park还要结合线程栈和业务池指标。I/O事件看持续时间和端点。 Socket/File read/write能提示慢端点或大传输,但事件阈值、异步框架和 native实现可能让记录不完整。与 Trace、连接池和系统调用证据交叉验证。
内存先看趋势,再升级证据。 Allocation sample适合找高分配路径,不直接等于存活对象;Old Object或 GC root路径更接近泄漏调查,但成本更高。完整对象保留关系仍可能需要受控 heap dump。
“热点方法出现最多”只能形成假设。结论至少要同时满足:事件覆盖故障时间窗,调用栈指向具体所有者,业务或资源指标同步变化,修复后同负载下对应事件和 SLO一起改善。
VisualVM 适合开发反馈,不适合无边界常驻
本地开发时,VisualVM能快速回答进程 CPU、堆、类、线程是否异常,以及 CPU或内存采样器把样本集中在哪些调用栈。先用 Monitor建立趋势,再开 Sampler;不要一连接就启用更重的 instrumentation profiler。采样结果同样受周期和窗口影响,短方法、native代码和未被采到的线程可能缺失。
VisualVM可以读取 heap dump,但 dump风险并不会因为按钮在 GUI里就降低。生产 dump仍应走 jcmd、jstack 与 jmap JVM 现场诊断手册 的摘流、容量、权限和销毁流程。
远程实时连接常依赖 JMX。不要使用“无认证、无 TLS、监听所有接口”的快速配置,也不要暴露 RMI随机端口到公网。更稳的顺序是:优先导出 JFR离线分析;确需实时连接时,复用平台批准的 JMX管理面,固定 registry与 RMI端口,绑定管理网地址,启用认证和 TLS,通过防火墙或 SSH隧道限制来源,并在操作后关闭临时入口。JMX口令文件、truststore和 keystore由密钥系统下发,权限只给运行用户。
容器会改变证据解释
容器内 PID、有效 UID/GID和 attach边界与 jcmd相同。应进入正确 container确认 ps、id和 jcmd "$PID" help;临时调试容器只共享网络时看不到目标 PID。持续 JFR目录必须位于有容量与生命周期策略的挂载卷,不能假设容器重启后易失层还在。
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.max 2>/dev/null || true
cat /sys/fs/cgroup/memory.events 2>/dev/null || true配额节流可能让 JVM CPU看起来没有打满,却让可运行线程排队;容器 OOMKilled也不会由 Java heap事件完整解释。JFR只能记录 JVM看到的世界,平台指标负责补上 cgroup、节点竞争、驱逐和重启。
把录制能力接入发布与值守
项目中保存配置、脚本和验证基线,不保存真实 .jfr:
performance/jfr/
baseline.jfc
incident-profile.jfc
dump-recent.sh
evidence-manifest.example
runbook.md
retention-policy.mdbaseline.jfc只从目标 JDK自带配置复制后做最小修改;incident-profile.jfc记录每个新增事件、阈值和 stacktrace成本。dump-recent.sh接受明确 PID、recording名称、时间窗口和已存在的证据根目录,在其下用 mktemp -d创建本次目录,不使用固定文件名,也不默认停止持续 recording。
CI不需要对每次提交运行长时间 profiler,但应检查 JFC可被目标 jfr configure或冒烟 JVM加载、启动参数没有重复 recording名、目录由非 root用户可写、.jfr和 repository被 .gitignore排除。发布验证比较录制开启前后的吞吐、P95/P99、错误率、CPU、GC pause、分配率、文件增长和 cgroup throttling;连续多轮稳定,而不是一次平均值接近,才能批准默认开启。
自定义 JFR事件可以把订单号、SQL、URL、消息键甚至请求体带进 recording。事件类应在代码评审中定义字段白名单、脱敏规则和最大字符串长度,不记录认证头、Cookie、Token、完整 SQL参数或用户内容。高基数字段还会放大常量池与文件体积。
失败证据怎么读
recording存在但没有热点
先核对录制时间与告警窗口,再看 jfr summary中的事件数量。常见原因是 default采样密度不足、事件未启用、duration太短、阈值太高、栈未开启,或真正瓶颈是 I/O等待而不是 CPU。正确动作是缩小假设后短时增强相关事件,而不是一次打开全部事件。
文件打不开或不完整
检查文件是否非空、写入是否结束、磁盘是否曾满、JVM如何终止,以及分析工具是否支持产生该文件的 JDK。正常退出优先检查 dumponexit目标文件和目录权限;HotSpot致命错误检查错误报告指向的 emergency dump;kill -9、掉电或最终文件不完整时,不要假设退出钩子已经运行。先用同版本 JDK的 jfr summary <file>验证结构;repository残留 chunk可在隔离副本上用 jfr assemble尝试恢复,不能在运行中的 repository原地整理或删除。
开启后延迟上升
立即记录 recording配置、开始时间和受影响指标,停止本次增强 recording,保留可用文件并确认 JFR.check状态。重点检查 profile模板、过低阈值、stacktrace、path-to-gc-roots、高频自定义事件、磁盘拥塞和 CPU throttling。恢复后用受控负载逐项启用,找到成本来源。
JMC或 VisualVM连不上目标
本地 attach先查 PID命名空间、UID/GID、目标 JDK和 DisableAttachMechanism。远程 JMX再查地址、固定端口、防火墙、TLS信任和认证文件。不要为了验证连接临时改成 0.0.0.0、关闭认证或关闭 TLS;优先把 JFR文件复制到分析机。
安全、容量与长期治理
.jfr可能包含类名、方法栈、线程名、文件路径、Socket地址、命令行、系统属性和自定义业务字段。它的权限应高于普通指标,至少采用最小访问、加密传输、隔离分析、文件校验、下载审计和到期销毁。代理只影响下载、插件更新和文件传输,不参与本地 JFR;代理凭证不能写进 JMC配置导出或 shell历史。
容量规划要用实测事件速率:在峰值负载下记录单位时间 chunk增长,乘以要保留的响应窗口,再加磁盘安全余量。持续 recording数量、profile并发数、repository总大小和导出频率都要设上限。maxsize只限制单个 recording的保留数据,不会替团队限制所有 recording、复制文件和人工下载的总量。
团队职责应可审计:应用 owner提出要回答的问题;性能 owner设计最小事件集和对照实验;平台 owner管理 JDK、PID命名空间、repository与 cgroup监控;安全 owner审批字段、传输与保留;事故指挥者决定是否停止录制、摘流或升级 heap证据。任何人都不能以“再多录一点”为理由绕过停止条件。
每次 JDK、JMC、VisualVM或基础镜像升级,都要重跑小型正反实验、JFC加载、文件兼容、开销基线、磁盘轮换和清理演练。治理指标可以记录录制覆盖率、告警前可回溯时长、导出失败率、单位小时体积、分析访问次数和按期销毁率。真正成熟的 JFR方案不是面板更多,而是发生问题时能拿到恰好足够、可复核、可销毁的证据。
版本能力可继续查阅 JDK Flight Recorder 性能诊断指南、jcmd 中的 JFR 命令、jfr 文件工具、JDK Mission Control 安装指南和 VisualVM 文档。
