JVM 线上排查实战(六):同样是 JDK 8,一个必须解锁商业特性,一个根本不用 —— JFR 实测

3 阅读7分钟

这个系列

「JVM 线上排查实战」的第六篇。前五篇讲的是出事之后怎么查:

  1. (一)先把 JVM 看清楚:进程、参数、默认值
  2. (二)CPU 飙高:找到那个线程
  3. (三)线程卡住:死锁、BLOCKED、线程池打满
  4. (四)内存:OOM 了先干什么
  5. (五)GC 日志:从 JDK 8 升 17,老启动参数会让进程直接起不来
  6. (六)JFR 飞行记录器:录一段现场下来慢慢看 —— 本篇

前五篇的工具都是「出事的那一刻你正好在场」才有用:jstack 看的是此刻的线程,jmap 抓的是此刻的堆。JFR 不一样,它是一直录着,出事之后把那段时间捞出来。

本篇实测环境(三个 JDK 装在同一台机器上,输出都是原文):

代号版本来源路径
Oracle 81.8.0_381,BUILD_TYPE="commercial"Oracle 官方包/opt/jdk8
OpenJDK 81.8.0_412CentOS 7 yum install java-1.8.0-openjdk-devel/usr/lib/jvm/java-1.8.0-openjdk
JDK 1717.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 8u3810.9❌❌
OpenJDK 8u4122.0✅✅
JDK 17.0.82.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
default1727,532318,068 字节702
profile1729,560330,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 8java -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 上没了,以及所有工具都用不了时最后那条后路。