这个系列
「JVM 线上排查实战」的第六篇。前五篇讲的是出事之后怎么查:
- (一)先把 JVM 看清楚:进程、参数、默认值
- (二)CPU 飙高:找到那个线程
- (三)线程卡住:死锁、BLOCKED、线程池打满
- (四)内存:OOM 了先干什么
- (五)GC 日志:从 JDK 8 升 17,老启动参数会让进程直接起不来
- (六)JFR 飞行记录器:录一段现场下来慢慢看 —— 本篇
前五篇的工具都是「出事的那一刻你正好在场」才有用:jstack 看的是此刻的线程,jmap 抓的是此刻的堆。JFR 不一样,它是一直录着,出事之后把那段时间捞出来。
本篇实测环境(三个 JDK 装在同一台机器上,输出都是原文):
| 代号 | 版本 | 来源 | 路径 |
|---|---|---|---|
| Oracle 8 | 1.8.0_381,BUILD_TYPE="commercial" | Oracle 官方包 | /opt/jdk8 |
| OpenJDK 8 | 1.8.0_412 | CentOS 7 yum install java-1.8.0-openjdk-devel | /usr/lib/jvm/java-1.8.0-openjdk |
| JDK 17 | 17.0.8,IMPLEMENTOR="Oracle Corporation" | Oracle 官方包 | /usr/java |
系统 CentOS 7.9.2009,内核 3.10.0-1160.71.1.el7.x86_64。
先说结论
- "JDK 8 用 JFR 要先加
-XX:+UnlockCommercialFeatures"这句话只对 Oracle JDK 成立。 同一台机器上 OpenJDK 8u412 不加任何参数就能录 - 反过来在 JDK 17 上加这个参数,进程直接起不来(
Unrecognized VM option)—— 和(五)里那批 GC 参数是同一个形状 - 进程已经起来了、启动参数里什么都没加,也不用重启:Oracle 8 上先
jcmd <pid> VM.unlock_commercial_features再JFR.start;OpenJDK 8 和 17 直接JFR.start - Oracle JDK 8 没有
jfr命令行工具,OpenJDK 8 和 17 都有 - Oracle JDK 8 录出来的文件是
0.9版,jfr工具直接拒绝读(OpenJDK 8 的和 17 的都拒绝);OpenJDK 8 录的是2.0,17 录的是2.1,都能读 settings=profile比默认多一倍采样:同一个进程同样录 15 秒,jdk.ExecutionSample从 702 条变成 1408 条,而文件只从 318,068 字节涨到 330,901 字节kill -9会让dumponexit=true留下一个 0 字节的.jfr—— 文件在,内容没有。kill -TERM则正常写出 377,151 字节
1. 开启:同一条命令,三个 JDK 三种结果 ✅
先用最省事的办法试:java <参数> -version,起不来的当场就知道。
Oracle JDK 8 —— 不解锁就起不来:
$ /opt/jdk8/bin/java -XX:StartFlightRecording=duration=5s,filename=/root/jvmlab/t2.jfr -version
Error: To use 'StartFlightRecording', first unlock using -XX:+UnlockCommercialFeatures.
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
加上解锁参数就正常了:
$ /opt/jdk8/bin/java -XX:+UnlockCommercialFeatures -XX:+FlightRecorder -version
java version "1.8.0_381"
Java(TM) SE Runtime Environment (build 1.8.0_381-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.381-b09, mixed mode)
OpenJDK 8 —— 什么都不用加:
$ /usr/lib/jvm/java-1.8.0-openjdk/bin/java -XX:StartFlightRecording=duration=5s,filename=/root/jvmlab/o1.jfr -version
Started recording 1. The result will be written to:
/root/jvmlab/o1.jfr
openjdk version "1.8.0_412"
OpenJDK Runtime Environment (build 1.8.0_412-b08)
OpenJDK 64-Bit Server VM (build 25.412-b08, mixed mode)
加上 -XX:+UnlockCommercialFeatures 它也不报错(照常起来,版本号照常打印),所以老启动脚本原样拿到 OpenJDK 8 上不会炸。
JDK 17 —— 加了解锁参数反而起不来:
$ /usr/java/bin/java -XX:+UnlockCommercialFeatures -XX:+FlightRecorder -version
Unrecognized VM option 'UnlockCommercialFeatures'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
不加就对了:
$ /usr/java/bin/java -XX:StartFlightRecording=duration=5s,filename=/root/jvmlab/t4.jfr -version
[0.233s][info][jfr,startup] Started recording 1. The result will be written to:
[0.233s][info][jfr,startup]
[0.233s][info][jfr,startup] /root/jvmlab/t4.jfr
java version "17.0.8" 2023-07-18 LTS
🔑 这就是那个坑
网上绝大多数 JFR 教程都写着「JDK 8 要先 -XX:+UnlockCommercialFeatures」。照抄的后果分两头:
- 你的线上是 OpenJDK 8:参数是多余的(但不会炸,所以你也发现不了自己抄错了)
- 你把这份启动脚本升到 JDK 17:进程直接起不来
怎么一眼分清自己是哪个 8:看 release 文件。
$ grep -E 'IMPLEMENTOR|JAVA_VERSION|BUILD_TYPE' /opt/jdk8/release
JAVA_VERSION="1.8.0_381"
BUILD_TYPE="commercial"
BUILD_TYPE="commercial" = Oracle JDK。CentOS 的 yum 装的 OpenJDK 8 连 release 文件都没有:
$ grep -E 'IMPLEMENTOR|BUILD_TYPE' /usr/lib/jvm/java-1.8.0-openjdk/release
grep: /usr/lib/jvm/java-1.8.0-openjdk/release: 没有那个文件或目录
最直接的还是 java -version 第一行:java version 是 Oracle,openjdk version 是 OpenJDK。
2. 进程已经在跑了,没加参数还能不能开 ✅
线上真实的情况通常是:出事了才想起来要 JFR,而进程是三个月前起的。
Oracle JDK 8 —— 直接 JFR.start 会被拒:
$ /opt/jdk8/bin/jcmd 5842 JFR.start name=r1 duration=20s filename=/root/jvmlab/r8n.jfr
5842:
Java Flight Recorder not enabled.
Use VM.unlock_commercial_features to enable.
按它说的做,不用重启进程:
$ /opt/jdk8/bin/jcmd 5842 VM.unlock_commercial_features
5842:
Commercial Features now unlocked.
$ /opt/jdk8/bin/jcmd 5842 JFR.start name=r1 duration=20s filename=/root/jvmlab/r8n.jfr
5842:
Started recording 1. The result will be written to:
/root/jvmlab/r8n.jfr
OpenJDK 8 和 JDK 17 —— 启动参数里什么都没加,直接就能开:
$ /usr/lib/jvm/java-1.8.0-openjdk/bin/jcmd 7625 JFR.start name=r1 duration=15s settings=profile filename=/root/jvmlab/oj.jfr
7625:
Started recording 1. The result will be written to:
/root/jvmlab/oj.jfr
$ /usr/java/bin/jcmd 5739 JFR.start name=r1 duration=20s settings=profile filename=/root/jvmlab/r17.jfr
5739:
Started recording 1. The result will be written to:
/root/jvmlab/r17.jfr
⚠️
jcmd要用和目标进程同一个 JDK 更稳妥。跨版本的情况(比如用 17 的jcmd去打 8 的进程)在下一篇(七)里专门测,jcmd/jstack能过,jmap -heap不能。
3. 查状态:两个版本输出格式不一样 ✅
$ /opt/jdk8/bin/jcmd 5737 JFR.check
5737:
Recording: recording=1 name="r1" duration=20s filename="/root/jvmlab/r8.jfr" compress=false (running)
$ /usr/java/bin/jcmd 5739 JFR.check
5739:
Recording 1: name=r1 duration=20s (running)
Oracle JDK 8 的那行带着 filename= 和 compress=,17 的没有。OpenJDK 8 的格式和 17 一样:
$ /usr/lib/jvm/java-1.8.0-openjdk/bin/jcmd 7625 JFR.check
7625:
Recording 1: name=r1 duration=15s (running)
写监控脚本去 grep filename= 的话,同样是「JDK 8」,两个发行版一个有一个没有。
4. 不限时长地录,中途捞一段出来 ✅
线上更常见的用法是不设 duration,让它一直录着,出事了再 dump:
$ /usr/java/bin/jcmd 5739 JFR.start name=live settings=default
5739:
Started recording 2. No limit specified, using maxsize=250MB as default.
Use jcmd 5739 JFR.dump name=live filename=FILEPATH to copy recording data to file.
注意它自己说的:没设上限时默认 maxsize=250MB,是个环形缓冲,超了就丢最旧的。录了 12 秒之后 dump:
$ /usr/java/bin/jcmd 5739 JFR.dump name=live filename=/root/jvmlab/live1.jfr
5739:
Dumped recording "live", 373.7 kB written to:
/root/jvmlab/live1.jfr
$ ls -la live1.jfr
-rw-r--r--. 1 root root 382694 9月 23 22:44 live1.jfr
dump 完录制还在继续,可以反复 dump。停止用 JFR.stop name=live。
5. 读:jfr 命令在不在,以及读不读得动 ✅
Oracle JDK 8 没有这个命令:
$ ls /opt/jdk8/bin/jfr
ls: 无法访问/opt/jdk8/bin/jfr: 没有那个文件或目录
$ /opt/jdk8/bin/jfr summary r8.jfr
bash:行1: /opt/jdk8/bin/jfr: 没有那个文件或目录
OpenJDK 8 有:
$ ls /usr/lib/jvm/java-1.8.0-openjdk/bin/jfr
/usr/lib/jvm/java-1.8.0-openjdk/bin/jfr
JDK 17 读自己录的,一切正常:
$ /usr/java/bin/jfr summary r17.jfr
Version: 2.1
Chunks: 1
Start: 2026-09-23 14:42:42 (UTC)
Duration: 20 s
Event Type Count Size (bytes)
==================================================================
jdk.GCPhaseParallel 4947 131511
jdk.ExecutionSample 1765 18998
jdk.PromoteObjectInNewPLAB 1231 22077
jdk.ObjectAllocationSample 961 14102
jdk.ThreadSleep 901 13304
jdk.TenuringDistribution 900 10691
jdk.PromoteObjectOutsidePLAB 575 9582
jdk.GCPhasePauseLevel1 537 21507
🔴 Oracle JDK 8 录的文件,jfr 工具读不了
$ /usr/java/bin/jfr summary r8.jfr
jfr summary: could not read recording at /root/jvmlab/r8.jfr. File version 0.9. Only Flight Recorder files of version 1.x and 2.x can be read by this JDK.
换 OpenJDK 8 自己的 jfr 去读,一样不行,报的是同一句话:
$ /usr/lib/jvm/java-1.8.0-openjdk/bin/jfr summary r8.jfr
jfr summary: could not read recording at /root/jvmlab/r8.jfr. File version 0.9. Only Flight Recorder files of version 1.x and 2.x can be read by this JDK.
而 OpenJDK 8 录的文件是 2.0 版,JDK 17 读得动:
$ /usr/java/bin/jfr summary oj.jfr
Version: 2.0
Chunks: 1
Start: 2026-09-23 14:56:08 (UTC)
Duration: 15 s
Event Type Count Size (bytes)
=============================================================
jdk.BooleanFlag 804 27243
jdk.JavaMonitorWait 607 17605
三个版本的文件版本号,实测如下:
| 录制方 | .jfr 文件版本 | 17 的 jfr 能读 | OpenJDK 8 的 jfr 能读 |
|---|---|---|---|
| Oracle JDK 8u381 | 0.9 | ❌ | ❌ |
| OpenJDK 8u412 | 2.0 | ✅ | ✅ |
| JDK 17.0.8 | 2.1 | ✅ | ✅(OpenJDK 8 的 jfr 读 17 录的 2.1 文件也正常) |
🔑 所以在 Oracle JDK 8 的线上开 JFR,要先想清楚谁来读这个文件 —— 手上这三个 JDK 的命令行工具都读不了它。
6. 从录到的数据里找热点方法 ✅
jfr summary 只给个数量概览,真要定位热点看 jdk.ExecutionSample 的栈:
$ /usr/java/bin/jfr print --events jdk.ExecutionSample r17.jfr | grep -E 'JfrDemo\.' | head -6
JfrDemo.hash(int) line: 16
JfrDemo.lambda$main$0() line: 7
JfrDemo.hash(int) line: 16
JfrDemo.lambda$main$0() line: 7
JfrDemo.hash(int) line: 16
JfrDemo.lambda$main$0() line: 7
测试程序里烧 CPU 的就是 hash(),被 worker-1 线程在死循环里调 —— 采样栈直接指到了行号。这是 JFR 比 jstack 强的地方:jstack 是你手动敲的那一瞬间的快照,JFR 是这段时间里成百上千次采样。
7. settings=profile 到底多收了多少 ✅
同一个进程(JDK 17),先录 15 秒 default,再录 15 秒 profile:
| settings | 事件类型数 | 事件总数 | 文件大小 | jdk.ExecutionSample |
|---|---|---|---|---|
default | 172 | 7,532 | 318,068 字节 | 702 |
profile | 172 | 9,560 | 330,901 字节 | 1,408 |
事件类型一样多(都是 172 种),差别在采样密度:执行采样正好翻了一倍,而文件只大了 4%。
⚠️ 这是一个空转的测试程序的读数,不是你线上那套的读数;换成真实应用两边的绝对值都会变。这里能说的只有一句:从
default换到profile,涨的主要是采样条数,不是文件体积。
8. 进程退出时能不能留下文件 ✅
dumponexit=true 的意思是「进程退出时把录制写出来」。分两种退出方式实测,同一个启动参数:
-XX:StartFlightRecording=dumponexit=true,filename=/root/jvmlab/<名字>.jfr
kill -TERM(正常退出):
$ kill -TERM 6478
$ ls -la exit_term.jfr
-rw-r--r--. 1 root root 377151 9月 23 22:46 exit_term.jfr
kill -9:
$ kill -9 6517
$ ls -la exit_kill9.jfr
-rw-r--r--. 1 root root 0 9月 23 22:46 exit_kill9.jfr
🔴 注意这个 0 不是"没有文件"
kill -9 之后文件是存在的,只是 0 字节。如果你的排查脚本判断的是「.jfr 文件在不在」,它会告诉你「录到了」;直到你把这个文件拖回本地准备分析,才发现里面什么都没有。
JFR 文件是进程退出时才落盘的(前面 JFR.dump 那种主动导出除外),而 kill -9 不给进程任何执行退出逻辑的机会。所以:排查期间别用 kill -9 停进程,否则你录了一整天的现场在那一刻全没了。
9. 本篇速查
| 你的情况 | 怎么做 |
|---|---|
| 不知道自己是哪个 JDK 8 | java -version 第一行:java version = Oracle,openjdk version = OpenJDK;或 grep BUILD_TYPE $JAVA_HOME/release |
| Oracle JDK 8,进程已经在跑 | jcmd <pid> VM.unlock_commercial_features 再 JFR.start,不用重启 |
| OpenJDK 8 / JDK 17,进程已经在跑 | 直接 jcmd <pid> JFR.start name=r1 duration=60s settings=profile filename=/tmp/a.jfr |
| 想一直录着,出事再捞 | JFR.start name=live settings=default(默认 maxsize=250MB 环形缓冲)→ 出事时 JFR.dump name=live filename=… |
| 启动脚本要两个版本通用 | 别写 -XX:+UnlockCommercialFeatures(17 上起不来),Oracle 8 改用运行时 VM.unlock_commercial_features |
| 看录到了什么 | jfr summary a.jfr |
| 找热点方法 | jfr print --events jdk.ExecutionSample a.jfr |
| Oracle JDK 8 录的文件读不了 | 报错是 File version 0.9。手上的 jfr 命令行工具(8 和 17 的)都读不了它 |
| 停进程 | 用 kill(TERM),别用 kill -9 —— 后者会留下 0 字节的 .jfr |
下一篇
(七)讲工具连不上的时候怎么办:jps 看不到进程、jstack 报「不允许的操作」、attach 挂住不返回、jstack -F 在 17 上没了,以及所有工具都用不了时最后那条后路。