Java——JFR 实战:录制、事件与开销实测
JFR 是 JDK 自带的记录器:常态开着也不贵,出事时能把现场和过去几分钟的事件一起拿走。这篇只讲怎么用:录制从哪里发起、录完拿什么读、五百多个事件里先看哪几个。
jfr命令行工具不能发起录制,它只处理录制文件;纯 CPU 负载上default与profile的开销落在测量噪声里(三档吞吐都是 131k ops/s 上下);分配型负载上,配置之间的差异(0.4%3%)比测量系统自己的轮间漂移(2%4%)还小。
数据来自本机(Apple M1 Pro / JDK 25,对照版本 JDK 21.0.8),原始输出贴在每段下面,失效的地方直接写出来。
实验环境与口径
1 | $ sysctl -n machdep.cpu.brand_string hw.ncpu hw.memsize kern.osversion |
工具只用 JDK 自带的 java / javac / jcmd / jfr,没装 JMC 或 async-profiler。本机同时装了 11、17、21、25,所以命令都用绝对路径指定 JDK。
被测负载
「开销」和「体积」两节跑同一个类,--mode 切两种负载:cpu 只做算术,alloc 每个操作额外分配 2KB 并把最近 64 个留在 HashMap 里。单线程,每操作约 4 微秒,量的是单位操作耗时而不是 QPS:
1 | public final class Workload { |
main 解析 --mode / --warmup / --measure / --payload / --ops,先 warmup 3 秒再统计;--ops=N 是定量模式(固定跑 N 次、不做 warmup),用于「总共分配多少字节」这类对照实验。测量循环用 System.nanoTime() 记每操作墙钟耗时,用 ThreadMXBean.getCurrentThreadCpuTime() 记本线程 CPU 时间:
1 | while (n < CAP) { |
测量纪律:每轮重启一个新 JVM,warmup 3 秒 + 统计 8 秒;每档配置 5 轮取中位数;不同配置交错轮转而不是一档跑完再跑下一档,每轮记下当时的 load average。本机不是空的(实验期间 load average 3.8~5.7),所以只看同批次的横向比较,绝对值不要外推。
1 | $ $J25/bin/javac -d classes Workload.java |
jfrInitialized=false 表示没开录制时 JFR 是惰性初始化的,FlightRecorder.isInitialized() 返回 false。JEP 328 的验收指标之一就是「未启用时没有可测量的开销」,这也是后面对照组的基线。
四种发起录制的方式
方式一:jcmd 挂运行中的进程
进程已经在跑、问题正在发生,就用这种。一次完整的三步操作(JFR.start → 干活 → JFR.dump 取快照 → JFR.stop 结束),目标进程跑的就是上面那个 Workload:
1 | $ jcmd 20498 JFR.start name=demo settings=profile filename=out/rec-jcmd.jfr |
JFR.dump 是一次不打断录制的快照,所以同一次录制可以先取中间态(372.5 kB),结束时再得到完整文件(743.6 kB)。录制开着时 JFR.check verbose=true 会列出每个事件当前生效的开关,这是最快搞清「默认配置到底开了什么」的办法:
1 | $ jcmd 20498 JFR.check verbose=true |
jcmd 还有个 JFR.configure,管全局参数:堆栈深度(本机 64)、内存上限(10 MB)、单 chunk 上限(12 MB)。
jcmd <pid> help JFR.start 列出的参数(delay / disk / dumponexit / duration / filename / maxage / maxsize / name / settings / report-on-exit)与启动参数那套是同一组名字。
方式二:启动参数 -XX:StartFlightRecording
进程可以重启,或者要录 JVM 启动阶段(录制在 0.18 秒就开始,比 main 早)。参数一共十一个:delay / disk / dumponexit / duration / filename / maxage / maxsize / name / path-to-gc-roots / report-on-exit / settings,完整说明用 -XX:StartFlightRecording=help 打印(和 jcmd <pid> help JFR.start 是同一组名字)。
实际跑一次:
1 | $ $J25/bin/java -XX:StartFlightRecording=settings=default,filename=out/rec-flag.jfr -cp classes Workload --mode=cpu --warmup=1 --measure=2 |
三点直接读出来:录制在 0.181s 开始(早于 main)、jfrInitialized=true、退出时文件已写好(379679 字节)。启动参数可以叠多份,一次跑同时录两套配置——输出里会看到 Started recording 1 / 2:
1 | $ $J25/bin/java -XX:StartFlightRecording=settings=profile,filename=out/rec-flag-profile.jfr -XX:StartFlightRecording=settings=default,filename=out/rec-flag-default.jfr -version |
帮助文本说 dumponexit 默认 false,但只给 filename 不给 dumponexit 时文件照样在退出时写出来(rec-flag.jfr 就是这么来的)。既不给 filename 也不给 dumponexit 才是真的什么都没有:
1 | $ $J25/bin/java -XX:StartFlightRecording=settings=profile,maxage=1h,maxsize=8m,name=noexit -cp classes Workload --mode=cpu --warmup=1 --measure=1 |
方式三:代码里的 jdk.jfr.Recording API
录制逻辑要跟业务挂钩时(只在这个批处理窗口录、出错时把最近 10 秒留下来)用 API。核心就这几行:
1 | Path out = Path.of("/tmp/jfr-exp/out/rec-api.jfr"); |
状态迁移:
1 | $ $J25/bin/java -cp classes RecordingApiDemo /tmp/jfr-exp/out/rec-api.jfr |
dump() 不停录制(state 还是 RUNNING),只是把到现在为止的数据写一份出来;stop() 之后再 dump() 一次,文件从 133396 字节涨到 258684 字节——中间那段被补上了。enable(...).with("period", "10 ms") 就是 .jfc 里 <setting name="period">10 ms</setting> 的等价物。
方式四:jfr 命令行工具——它不负责开始录制
最容易被误解的一条。jfr 工具的全部能力里没有 start:
1 | $ $J25/bin/jfr --help |
完整的命令表是 print / view / configure / metadata / scrub / summary / assemble / disassemble / version / help——没有 start。它处理的是录制文件:读(summary / print / view / metadata)、裁剪与拆合(scrub / disassemble / assemble)、生成配置(configure)。要开始录制只有三条路:启动参数、jcmd、代码 API(JMC 这类图形界面走的也是同一套 jdk.jfr / jdk.management.jfr)。
所以「方式四」负责的是配置那一半——jfr configure 生成 .jfc,再交给前三种方式去录。它支持直接改单个事件设置:
1 | $ $J25/bin/jfr configure jdk.ExecutionSample#period=100ms --output /tmp/jfr-exp/exec100.jfc --verbose |
也支持一层「选项组」抽象,不用背几百个事件名(不带参数调用时把当前模板的选项打出来):
1 | $ $J25/bin/jfr configure |
加 --verbose 能看到一个选项改了哪些事件设置,method-profiling=off 把两个采样事件一起关掉:
1 | $ $J25/bin/jfr configure --verbose method-profiling=off --output /tmp/jfr-exp/sampling-off.jfc |
选项组的价值是意图明确,代价是映射不透明,--verbose 应该跟着一起用。上一篇文章《一次锁竞争的完整定位》把 locking-threshold 从 10 ms 调到 0 才看见微秒级锁,用的就是这个入口。
录完怎么读
jfr summary:先看事件分布
1 | $ jfr summary out/bench-profile-5.jfr |
(只贴头几行与末尾几行,中间省略。)表格按事件数倒序;别只看 Count 列,Size 列更说明问题:
| 事件 | Count | Size (bytes) |
|---|---|---|
jdk.GCPhaseParallel |
5109 | 128027 |
jdk.Checkpoint |
57 | 51278 |
jdk.Metadata |
1 | 110525 |
jdk.Metadata 只有 1 个事件却占 110 kB,是全文件最大的一块。所以减小文件体积靠换模板、压 chunk、scrub 掉不需要的事件,关采样事件作用不大:jdk.ExecutionSample 845 个才 8443 字节。
jfr print:看单条事件的原貌
print 把事件逐条展开,输出量很大(这份文件不加过滤 9000 多行),基本都配 --events 和 --stack-depth:
1 | $ $J25/bin/jfr print --events jdk.ExecutionSample --stack-depth 3 out/bench-profile-5.jfr | sed -n '1,7p' |
jfr view:给常见问题准备的表格
JDK 21 和 25 都带 view,但 25 的视图多得多(21 有 48 个,25 有 89 个):
25 比 21 多出来的视图里包括 cpu-time-hot-methods、cpu-time-statistics、pinned-threads、tlabs、vm-operations、deoptimizations-by-site 等;两边都有的是 hot-methods、allocation-by-class、gc-pause-phases、socket-reads-by-host 这些。
hot-methods 就是最常用的那个(等价于「谁在吃 CPU」):
1 | $ $J25/bin/jfr view --width 110 hot-methods out/bench-profile-5.jfr |
同一份文件里 allocation-by-class 回答「分配压力来自谁」(本负载全是 byte[],98.58%),gc-pauses 回答「GC 停了多少」(19 次、共 22.3 ms),cpu-load 回答「JVM 用了多少 CPU」(9.98%,机器整体 21.95%)。
jfr metadata:事件的字段和标签
不知道某个事件有哪些字段、什么单位、是不是带堆栈,jfr metadata --events 直接给定义:
1 | $ $J25/bin/jfr metadata --events jdk.ExecutionSample out/bench-profile-5.jfr |
@Label / @Category / @Description 这些注解的用处在这里能看到:它们最终会变成 view 的表头和列名,中文标签也一样。
事件体系:哪些事件回答哪些问题
JDK 25 的 default.jfc 里有两百多个事件定义,默认开着约六十个。「默认开关」一列是 default.jfc / profile.jfc 里的实测值:
| 事件 | 回答什么问题 | default | profile |
|---|---|---|---|
jdk.ExecutionSample |
哪些 Java 方法在吃 CPU(线程栈采样) | 20 ms 周期 | 10 ms 周期 |
jdk.ObjectAllocationSample |
分配压力来自哪个类(带 weight 估算) |
throttle 150/s | 300/s |
jdk.JavaMonitorEnter |
谁在等锁、等了多久 | 阈值 20 ms | 10 ms |
jdk.ThreadPark |
LockSupport.park / 并发容器里的等待 |
阈值 20 ms | 10 ms |
jdk.SocketRead / jdk.SocketWrite |
网络 IO 慢在哪、对端是谁 | 阈值 1 ms,100/s | 1 ms,300/s |
jdk.GCPhasePause |
GC 停顿的相位与时长 | 阈值 0 ms | 0 ms |
jdk.ThreadStart |
线程是不是在被反复创建 | 开,带栈 | 开,带栈 |
jdk.JavaExceptionThrow |
异常风暴(哪个异常、谁抛的) | 开,100/s | 300/s |
jdk.ClassLoad |
类加载 | 关 | 关 |
jdk.CPUTimeSample |
CPU 时间采样(JEP 509,仅 Linux) | 关 | 关 |
JDK 21 与 25 在这个表上有两处会被误判的差异:
- 同名事件的默认值变了:JDK 21 的
default.jfc里jdk.SocketRead是「阈值 20 ms、无节流」,JDK 25 改成「1 ms + 100/s」,jdk.JavaExceptionThrow从enabled=false变成默认开启。同一份代码在两边记录的不是同一批事件。 - 事件名也在变:25 新增
jdk.CPUTimeSample、jdk.MethodTiming等,21 的jdk.GCLocker、jdk.SafepointCleanup、jdk.ZUnmap在 25 里没有了。写死的过滤脚本会静默失效:jfr metadata不带文件参数时打印当前 JDK 的完整事件表,升级后 diff 一遍比读发布说明快。
自定义事件
业务事件用 @Name / @Label / @Category / @StackTrace 注解,字段写在事件类里:
1 | public final class CheckoutDemo { |
跑 20 次、其中 1 次失败,录制里就有 20 条事件:
1 | $ $J25/bin/jfr summary out/rec-custom.jfr | sed -n '/Checkout/p' |
jfr view 还能直接按事件名出表,表头用的是 @Label 的中文。自定义事件默认开启,20 条共 400 字节——比自己写日志便宜,还天然带上线程、时间、堆栈和 duration。
开销实测
方法:每轮新 JVM,warmup 3 秒 + 统计 8 秒,5 轮取中位数,配置交错轮转(同一轮里每档各跑一次)。指标有三个:吞吐、每操作墙钟 p50/p99、每操作 CPU 时间(ThreadMXBean)——第三个是关键,能把「机器调度噪声」和「本进程真多花了 CPU」分开。
纯 CPU 负载:测不出差别
| 配置 | 吞吐中位数 (ops/s) | 相对不录制 | p50 | p99 |
|---|---|---|---|---|
| 不录制 | 131109 | — | 7.46 µs | 8.25 µs |
default(ExecutionSample 20 ms) |
131117 | +0.01% | 7.46 µs | 8.25 µs |
profile(10 ms,分配采样 300/s) |
131151 | +0.03% | 7.46 µs | 8.25 µs |
三档的 p50 与 p99 一模一样,吞吐差异 0.03%——比同一档配置的轮间波动还小。一个不做分配、不做 IO 的循环里,采样事件没有可观察的开销。
分配型负载:差异落在漂移里
同一个负载换成 alloc(每个操作分配 2 KB,约 264 MB/s),情况不一样:
| 配置 | 吞吐中位数 (ops/s) | 相对不录制 | 每操作 CPU 时间 | 相对 | p99 |
|---|---|---|---|---|---|
| 不录制 | 129364 | — | 7718 ns | — | 8.42 µs |
default |
128685 | -0.5% | 7750 ns | +0.4% | 8.67 µs |
settings=none(JFR 开着,零事件) |
126338 | -2.3% | 7897 ns | +2.3% | 8.67 µs |
sampling-off(关掉两个采样事件) |
125341 | -3.1% | 7952 ns | +3.0% | 9.13 µs |
profile |
125722 | -2.8% | 7929 ns | +2.7% | 8.88 µs |
alloc-20k(分配采样 150/s → 20000/s) |
125713 | -2.8% | 7932 ns | +2.8% | 9.38 µs |
不录制的 5 轮是 77087735 ns(跨轮极差 0.35%),开着 JFR 的配置在 77388010 ns 之间摆。三个事实要一起看:把采样事件全关掉(sampling-off)或把分配采样放大 130 倍(alloc-20k)都落在同一档,和开满事件的 profile 没有量级差别;一个事件都不开的 settings=none 也在这一档;而第二轮复测里 sampling-off 又跑出 7726 ns。同一配置跨轮能差 2%~4%,只有「不录制」这一组在 14 轮里始终最快。
GC 也被排除了:测量窗口里 1314 次 young GC,MXBean 记到的停顿只有 714 ms(约 0.1%),填不满那 200 ms 的差。
结论写成可核验的形式:在这台机器、这个负载上,配置之间的差异(中位 0.4%3%)小于测量系统自身的轮间漂移(2%4%);唯一稳定的是不录制最快。 profile.jfc 自己写的是「typically around 2 % overhead」,JEP 328 的验收指标是「SPECjbb2015 上 ≤1%」——数量级吻合,但任何单轮测出的「JFR 开销 X%」都是噪声级结论。
体积与阈值控制
同一个负载(alloc)两档配置,文件体积只差 6%,事件数却差一倍:
| 文件 | 大小 | ExecutionSample | ObjectAllocationSample | Duration |
|---|---|---|---|---|
| bench-default-5.jfr | 549593 B | 404 | 1614 | 11 s |
| bench-profile-5.jfr | 582488 B | 845 | 2699 | 11 s |
体积的大头是 metadata、checkpoint 这类一次性数据,所以压体积的顺序是:换模板 → 压 chunk → 最后才考虑关采样事件。阈值改动的效果倒是线性的——jfr configure jdk.ExecutionSample#period=100ms 之后,同样 8 秒里 ExecutionSample 从 395 条掉到 104 条。
maxage / maxsize 是保留窗口而不是体积上限,而且以 chunk 为单位生效。32 秒的 profile 录制(maxchunksize=1m,disk=true,maxage=5s,maxsize=2m)得到 912278 字节、Duration 32 s;同一负载不设上限是 921155 字节、Duration 32 s——看不出任何差别,因为总量还没超出一个 chunk。要让它们生效得先让 chunk 轮转起来,而 chunk 最小值是 1 MB:-XX:FlightRecorderOptions=maxchunksize=256k 会让 JVM 直接拒绝启动(Max chunk size must be at least 1048576);disk=false 时 JVM 会警告 Option maxage has no effect with disk=false.。
踩坑
jfr print --events过滤器大小写敏感且静默失败:--events executionsample输出 0 行、不报错;ExecutionSample与jdk.ExecutionSample都能用(同一份文件 9295 行)。- 启动参数里
filename与dumponexit互相约束:只给filename会在退出时写文件;写成dumponexit=false,filename=...直接起不来——Filename can only be set for a time bound recording or if dumponexit=true. jcmd JFR.start不给filename、JFR.stop也不给,JFR 只回Stopped recording "lost".,一个新文件都没有。maxage/maxsize以 chunk 为单位生效,不是「体积上限」:32 秒的 profile 录制加上它们之后大小几乎不变(见上一节),因为总量没超过一个 chunk;disk=false时 JVM 警告Option maxage has no effect with disk=false.。jfr view的可用性取决于 JDK 版本和录制里有没有对应事件:JDK 21 上没有cpu-time-hot-methods;JDK 25 上有,但 macOS 上打开jdk.CPUTimeSample时 JVM 警告CPU time method sampling not supported in JFR on your platform(JEP 509 只支持 Linux)。没有事件时视图输出No events found for 'Pinned Virtual Threads'.。ObjectAllocationSample是采样,不能当分配计数用:100 万次分配(共 2048 MB)在default下只记到 1145 条(≈147/s,正好是节流值);throttle 提到 20000/s 也只有 2019 条。每条样本自带weight(实测15.8 MB),官方给的本来就是「估算总量」。
和另外两篇的关系
《一次锁竞争的完整定位》和《JVM 内存与 GC 定位实战》把 JFR 当取证工具:前者从 jdk.JavaMonitorEnter 量出 8 秒里累计等待 429.5 秒的锁竞争,后者用分配事件追内存泄漏的分配点。那两篇关心「JFR 说了什么」,这篇关心「JFR 这个工具本身」——怎么发起录制、文件怎么读、事件怎么配、它自己要花多少钱。而开销决定了它在什么场景能常态开启。
总结
- 录制只有三条发起路径:
-XX:StartFlightRecording、jcmd <pid> JFR.start、jdk.jfr.RecordingAPI。jfr命令行工具不负责开始录制,它是文件侧工具(读、裁剪、拆合、生成.jfc)。 jcmd适合已经出问题的进程:JFR.dump是不打断录制的快照(372.5 kB),JFR.stop给完整文件(743.6 kB),JFR.check verbose=true列出每个事件的生效开关。- 读文件的顺序:
jfr summary(注意 Size 列,jdk.Metadata一个事件占 110 kB)→jfr view(hot-methods/allocation-by-class/gc-pauses/cpu-load)→jfr print --events→jfr metadata。 - JDK 21 与 25 的默认配置差异会改变结论:
jdk.SocketRead从「20 ms 无节流」变成「1 ms + 100/s」,jdk.JavaExceptionThrow从默认关变成默认开,JDK 25 的jfr view比 21 多 40 多个视图,jdk.CPUTimeSample只在 Linux 上有数据。 - 开销:纯 CPU 负载上三档差 0.03%(p50 完全相同);分配型负载上配置之间的中位差在 0.4%
3.1%,但同一配置跨轮极差就有 2%4%,并伴随 GC 计数不变——所以「JFR 的开销」在这台机器上小于这套测量方法的误差,唯一稳定的是不录制组始终最快。 - 自定义事件不需要额外注册(20 条共 400 字节),
jfr view demo.Checkout会用@Label的中文做表头。
参考资料
- JEP 328: Flight Recorder — Closed / Delivered,Release 11
- JEP 349: JFR Event Streaming — Closed / Delivered,Release 14
- JEP 509: JFR CPU-Time Profiling — Closed / Delivered,Release 25(仅 Linux)
- JEP 518: JFR Cooperative Sampling — Closed / Delivered,Release 25
- JEP 167: Event-Based JVM Tracing — Closed / Delivered,Release 7u40
jfr/jcmd/jdk.jfrAPI(JDK 25 文档)
系列索引:Java 系列,语言特性与运行时的长文集






