这个系列
「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 7yum 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.jfrOpenJDK 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)写监控脚本去 grepfilename=的话,同样是「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.jfrdump 完录制还在继续,可以反复 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/jfrJDK 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/<名字>.jfrkill -TERM(正常退出):
$ kill -TERM 6478 $ ls -la exit_term.jfr -rw-r--r--. 1 root root 377151 9月 23 22:46 exit_term.jfrkill -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 上没了,以及所有工具都用不了时最后那条后路。