JFR 是 JDK 自带的记录器:常态开着也不贵,出事时能把现场和过去几分钟的事件一起拿走。这篇只讲怎么用:录制从哪里发起、录完拿什么读、五百多个事件里先看哪几个。
jfr 命令行工具不能发起录制,它只处理录制文件;纯 CPU 负载上 defaultprofile 的开销落在测量噪声里(三档吞吐都是 131k ops/s 上下);分配型负载上,配置之间的差异(0.4%3%)比测量系统自己的轮间漂移(2%4%)还小。
数据来自本机(Apple M1 Pro / JDK 25,对照版本 JDK 21.0.8),原始输出贴在每段下面,失效的地方直接写出来。

实验环境与口径

1
2
3
4
5
6
7
8
9
10
11
12
13
$ sysctl -n machdep.cpu.brand_string hw.ncpu hw.memsize kern.osversion
Apple M1 Pro
10
17179869184
25G83

$ java -version
java version "25" 2025-09-16 LTS
Java(TM) SE Runtime Environment (build 25+37-LTS-3491)
Java HotSpot(TM) 64-Bit Server VM (build 25+37-LTS-3491, mixed mode, sharing)

$ /Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home/bin/java -version
java version "21.0.8" 2025-07-15 LTS

工具只用 JDK 自带的 java / javac / jcmd / jfr,没装 JMC 或 async-profiler。本机同时装了 11、17、21、25,所以命令都用绝对路径指定 JDK。

被测负载

「开销」和「体积」两节跑同一个类,--mode 切两种负载:cpu 只做算术,alloc 每个操作额外分配 2KB 并把最近 64 个留在 HashMap 里。单线程,每操作约 4 微秒,量的是单位操作耗时而不是 QPS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public final class Workload {

static final int CAP = 4_000_000; // 最多统计 400 万个操作
static final int INNER = 4_000; // 每个操作的算术轮数,本机约 4 微秒

static long burn(long seed) {
long x = seed;
for (int i = 0; i < INNER; i++) {
x ^= x << 13;
x ^= x >>> 7;
x ^= x << 17;
}
return x;
}

static long step(long seed, boolean alloc, Map<Integer, byte[]> live, int payload) {
if (!alloc) {
return burn(seed);
}
byte[] b = new byte[payload];
long x = burn(seed ^ payload);
b[0] = (byte) x;
live.put((int) (x & 0x3f), b); // 保留最近 64 个,其余可回收
return x;
}

main 解析 --mode / --warmup / --measure / --payload / --ops,先 warmup 3 秒再统计;--ops=N 是定量模式(固定跑 N 次、不做 warmup),用于「总共分配多少字节」这类对照实验。测量循环用 System.nanoTime() 记每操作墙钟耗时,用 ThreadMXBean.getCurrentThreadCpuTime() 记本线程 CPU 时间:

1
2
3
4
5
6
7
8
9
10
11
while (n < CAP) {
long t0 = System.nanoTime();
seed = step(seed, alloc, live, payload);
long t1 = System.nanoTime();
lat[(int) n++] = t1 - t0;
tEnd = t1;
if (t1 > deadline) { break; }
}
long cpuEnd = mx.getCurrentThreadCpuTime();
double cpuNsPerOp = (cpuEnd - cpuStart) / (double) n; // 每操作 CPU 纳秒
double cpuWallRatio = (cpuEnd - cpuStart) / (double) (tEnd - tStart);

测量纪律:每轮重启一个新 JVM,warmup 3 秒 + 统计 8 秒;每档配置 5 轮取中位数;不同配置交错轮转而不是一档跑完再跑下一档,每轮记下当时的 load average。本机不是空的(实验期间 load average 3.8~5.7),所以只看同批次的横向比较,绝对值不要外推。

1
2
3
4
5
$ $J25/bin/javac -d classes Workload.java
$ $J25/bin/java -cp classes Workload --mode=alloc --warmup=1 --measure=2
RESULT mode=alloc ops=257976 seconds=2.000 opsPerSec=129001 p50us=7.63 p99us=10.21 p999us=19.21 maxus=523.96 allocMBps=264.2 cpuNsPerOp=7743 cpuRatio=0.999
checksum=8478105385099004376 live=64
JVM 25 jfrInitialized=false args=[]

jfrInitialized=false 表示没开录制时 JFR 是惰性初始化的,FlightRecorder.isInitialized() 返回 false。JEP 328 的验收指标之一就是「未启用时没有可测量的开销」,这也是后面对照组的基线。

四种发起录制的方式

方式一:jcmd 挂运行中的进程

进程已经在跑、问题正在发生,就用这种。一次完整的三步操作(JFR.start → 干活 → JFR.dump 取快照 → JFR.stop 结束),目标进程跑的就是上面那个 Workload

1
2
3
4
5
6
7
8
9
10
11
12
13
$ jcmd 20498 JFR.start name=demo settings=profile filename=out/rec-jcmd.jfr
20498:
Started recording 1. No limit specified, using maxsize=250MB as default.

$ jcmd 20498 JFR.dump name=demo filename=/tmp/jfr-exp/out/rec-jcmd-snapshot.jfr
20498:
Dumped recording "demo", 372.5 kB written to:
/private/tmp/jfr-exp/out/rec-jcmd-snapshot.jfr

$ jcmd 20498 JFR.stop name=demo
20498:
Stopped recording "demo", 743.6 kB written to:
/private/tmp/jfr-exp/out/rec-jcmd.jfr

JFR.dump 是一次不打断录制的快照,所以同一次录制可以先取中间态(372.5 kB),结束时再得到完整文件(743.6 kB)。录制开着时 JFR.check verbose=true 会列出每个事件当前生效的开关,这是最快搞清「默认配置到底开了什么」的办法:

1
2
3
4
5
6
7
8
$ jcmd 20498 JFR.check verbose=true
20498:
Recording 1: name=demo maxsize=250.0MB (running)

Flight Recording (jdk.ActiveRecording)
[enabled=true]
Allocation Requiring GC (jdk.AllocationRequiringGC)
[stackTrace=true,enabled=false]

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
2
3
4
$ $J25/bin/java -XX:StartFlightRecording=settings=default,filename=out/rec-flag.jfr -cp classes Workload --mode=cpu --warmup=1 --measure=2
[0.181s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
RESULT mode=cpu ops=259812 seconds=2.000 opsPerSec=129906 p50us=7.63 p99us=8.29 p999us=22.00 maxus=191.29 allocMBps=0.0
JVM 25 jfrInitialized=true args=[-XX:StartFlightRecording=settings=default,filename=/tmp/jfr-exp/out/rec-flag.jfr]

三点直接读出来:录制在 0.181s 开始(早于 main)、jfrInitialized=true、退出时文件已写好(379679 字节)。启动参数可以叠多份,一次跑同时录两套配置——输出里会看到 Started recording 1 / 2

1
2
3
4
$ $J25/bin/java -XX:StartFlightRecording=settings=profile,filename=out/rec-flag-profile.jfr -XX:StartFlightRecording=settings=default,filename=out/rec-flag-default.jfr -version
[0.178s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default.
[0.186s][info][jfr,startup] Started recording 2. No limit specified, using maxsize=250MB as default.
java version "25" 2025-09-16 LTS

帮助文本说 dumponexit 默认 false,但只给 filename 不给 dumponexit 时文件照样在退出时写出来rec-flag.jfr 就是这么来的)。既不给 filename 也不给 dumponexit 才是真的什么都没有:

1
2
3
4
$ $J25/bin/java -XX:StartFlightRecording=settings=profile,maxage=1h,maxsize=8m,name=noexit -cp classes Workload --mode=cpu --warmup=1 --measure=1
[0.176s][info][jfr,startup] Started recording 1.
$ ls -lt *.jfr
ls: cannot access '*.jfr': No such file or directory

方式三:代码里的 jdk.jfr.Recording API

录制逻辑要跟业务挂钩时(只在这个批处理窗口录、出错时把最近 10 秒留下来)用 API。核心就这几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
Path out = Path.of("/tmp/jfr-exp/out/rec-api.jfr");
Recording r = new Recording();
r.setName("api-demo");
r.setMaxSize(16 * 1024 * 1024L);
r.enable("jdk.ExecutionSample").with("period", "10 ms");
r.enable("jdk.ObjectAllocationSample").with("throttle", "100/s");
r.enable("jdk.ThreadPark").withThreshold(Duration.ofMillis(1));
r.start(); // 状态 NEW -> RUNNING
// ... 干活 ...
r.dump(out); // 运行中快照,不打断录制
r.stop();
r.dump(out); // 停止后再 dump,把尾部补齐
r.close();

状态迁移:

1
2
3
4
5
6
$ $J25/bin/java -cp classes RecordingApiDemo /tmp/jfr-exp/out/rec-api.jfr
before start: state=NEW id=1 settings=8
after start: state=RUNNING size=0
after dump: state=RUNNING size=133396 bytes
after stop: state=STOPPED size=258684 bytes
after close: state=CLOSED fileSize=258684 bytes

dump() 不停录制(state 还是 RUNNING),只是把到现在为止的数据写一份出来;stop() 之后再 dump() 一次,文件从 133396 字节涨到 258684 字节——中间那段被补上了。enable(...).with("period", "10 ms") 就是 .jfc<setting name="period">10 ms</setting> 的等价物。

方式四:jfr 命令行工具——它不负责开始录制

最容易被误解的一条。jfr 工具的全部能力里没有 start

1
2
3
4
5
6
7
8
9
$ $J25/bin/jfr --help
Tool for working with Flight Recorder files

jfr print [--xml|--json|--exact]
[--categories <filter>]
[--events <filter>]
[--stack-depth <depth>]
<file>
Print contents of a recording file. See 'jfr help print' for details.

完整的命令表是 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
2
3
4
5
$ $J25/bin/jfr configure jdk.ExecutionSample#period=100ms --output /tmp/jfr-exp/exec100.jfc --verbose
Setting:
"jdk.ExecutionSample#period=100ms"
Configuration written successfully to:
/tmp/jfr-exp/exec100.jfc

也支持一层「选项组」抽象,不用背几百个事件名(不带参数调用时把当前模板的选项打出来):

1
2
3
4
5
6
7
8
9
10
11
12
13
$ $J25/bin/jfr configure
jfr configure: missing argument

Usage:
...
Options for default.jfc:

gc=<off|normal|detailed|high|all> (normal)

allocation-profiling=<off|low|medium|high|maximum> (low)

...

--verbose 能看到一个选项改了哪些事件设置,method-profiling=off 把两个采样事件一起关掉:

1
2
3
4
5
6
$ $J25/bin/jfr configure --verbose method-profiling=off --output /tmp/jfr-exp/sampling-off.jfc
Setting:
"jdk.ExecutionSample#enabled=false"
"jdk.ExecutionSample#period=999 d"
"jdk.NativeMethodSample#enabled=false"
"jdk.NativeMethodSample#period=999 d"

选项组的价值是意图明确,代价是映射不透明,--verbose 应该跟着一起用。上一篇文章《一次锁竞争的完整定位》把 locking-threshold 从 10 ms 调到 0 才看见微秒级锁,用的就是这个入口。

录完怎么读

jfr summary:先看事件分布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ jfr summary out/bench-profile-5.jfr

Version: 2.1
Chunks: 1
Start: 2026-09-17 04:43:14 (UTC)
Duration: 11 s

Event Type Count Size (bytes)
=============================================================
jdk.GCPhaseParallel 5109 128027
jdk.ObjectAllocationSample 2699 37744
jdk.ExecutionSample 845 8443
...
jdk.Checkpoint 57 51278
jdk.Metadata 1 110525

(只贴头几行与末尾几行,中间省略。)表格按事件数倒序;别只看 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
2
3
4
5
6
7
8
9
10
11
$ $J25/bin/jfr print --events jdk.ExecutionSample --stack-depth 3 out/bench-profile-5.jfr | sed -n '1,7p'
jdk.ExecutionSample {
startTime = 12:43:14.636 (2026-09-17)
sampledThread = "main" (javaThreadId = 3)
state = "STATE_RUNNABLE"
stackTrace = [
Workload.burn(long) line: 17
Workload.step(long, boolean, Map, int) line: 30
...
]
}

jfr view:给常见问题准备的表格

JDK 21 和 25 都带 view,但 25 的视图多得多(21 有 48 个,25 有 89 个):

25 比 21 多出来的视图里包括 cpu-time-hot-methodscpu-time-statisticspinned-threadstlabsvm-operationsdeoptimizations-by-site 等;两边都有的是 hot-methodsallocation-by-classgc-pause-phasessocket-reads-by-host 这些。

hot-methods 就是最常用的那个(等价于「谁在吃 CPU」):

1
2
3
4
5
6
7
$ $J25/bin/jfr view --width 110 hot-methods out/bench-profile-5.jfr

Method Samples Percent
--------------------------------------------------------------------------------------------- ------- -------
Workload.burn(long) 830 98.22%
java.util.HashMap.putVal(int, Object, Object, boolean, boolean) 7 0.83%
Workload.main(String[]) 6 0.71%

同一份文件里 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
2
3
4
5
6
7
8
9
$ $J25/bin/jfr metadata --events jdk.ExecutionSample out/bench-profile-5.jfr
@Name("jdk.ExecutionSample")
@Label("Java Execution Sample")
class ExecutionSample extends jdk.jfr.Event {
@Label("Start Time") @Timestamp("TICKS") long startTime;
@Label("Thread") Thread sampledThread;
@Label("Stack Trace") StackTrace stackTrace;
@Label("Thread State") String state;
}

@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.jfcjdk.SocketRead 是「阈值 20 ms、无节流」,JDK 25 改成「1 ms + 100/s」,jdk.JavaExceptionThrowenabled=false 变成默认开启。同一份代码在两边记录的不是同一批事件。
  • 事件名也在变:25 新增 jdk.CPUTimeSamplejdk.MethodTiming 等,21 的 jdk.GCLockerjdk.SafepointCleanupjdk.ZUnmap 在 25 里没有了。写死的过滤脚本会静默失效:jfr metadata 不带文件参数时打印当前 JDK 的完整事件表,升级后 diff 一遍比读发布说明快。

自定义事件

业务事件用 @Name / @Label / @Category / @StackTrace 注解,字段写在事件类里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public final class CheckoutDemo {

@Name("demo.Checkout")
@Label("结算请求")
@Category({"Demo", "Business"})
@StackTrace(true)
static class Checkout extends Event {
@Label("订单号") String orderId; // 字段标签会变成视图表头
@Label("金额(分)") int amountCents;
@Label("是否成功") boolean ok;
}

static boolean checkout(String orderId, int cents, int failAt) {
Checkout e = new Checkout();
e.orderId = orderId;
e.amountCents = cents;
e.begin();
try {
return e.ok = Integer.parseInt(orderId) != failAt;
} finally {
e.commit(); // finally 里 commit,异常也不丢事件
}
}

跑 20 次、其中 1 次失败,录制里就有 20 条事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ $J25/bin/jfr summary out/rec-custom.jfr | sed -n '/Checkout/p'
demo.Checkout 20 400

$ $J25/bin/jfr print --events demo.Checkout --stack-depth 3 out/rec-custom.jfr | sed -n '1,11p'
demo.Checkout {
startTime = 12:50:18.060 (2026-09-17)
duration = 0.262 ms
orderId = "0000"
amountCents = 100
ok = true
eventThread = "main" (javaThreadId = 3)
stackTrace = [ CheckoutDemo.checkout(...) line: 36, ... ]
}

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=1mdisk=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 行、不报错;ExecutionSamplejdk.ExecutionSample 都能用(同一份文件 9295 行)。
  • 启动参数里 filenamedumponexit 互相约束:只给 filename 会在退出时写文件;写成 dumponexit=false,filename=... 直接起不来——Filename can only be set for a time bound recording or if dumponexit=true.
  • jcmd JFR.start 不给 filenameJFR.stop 也不给,JFR 只回 Stopped recording "lost".,一个新文件都没有。
  • maxage / maxsizechunk 为单位生效,不是「体积上限」: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:StartFlightRecordingjcmd <pid> JFR.startjdk.jfr.Recording API。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 viewhot-methods / allocation-by-class / gc-pauses / cpu-load)→ jfr print --eventsjfr 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 的中文做表头。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集