线上性能问题最难的不是「修」,而是「看」。采样器会带来可观开销,堆栈抓取会漏掉高频短方法,而 JFR(Java Flight Recorder)以极低的开销持续记录 JVM 运行时事件,配合 JMC(JDK Mission Control)图形化分析,已经成为生产环境性能分析的黄金搭档。本文从原理讲到生产实践,覆盖事件体系、录制、分析与常见排查套路。
一、JFR 是什么:低开销的可观测性
JFR 是内置于 HotSpot JVM 的事件记录器,从 JDK 11 起随 OpenJDK 免费分发。它通过**环形缓冲区(ring buffer)**持续记录事件,默认开销在 1% 以内,生产环境可以常开。
| 特性 | 说明 |
|---|---|
| 开销 | 默认 <1%,持续录制安全 |
| 数据 | 事件流(分配、锁、GC、方法采样、IO 等) |
| 记录方式 | 内存环形缓冲,随时转储为 jfr 文件 |
| 分析工具 | JMC 图形化,或命令行 jfr 工具 |
| 导出格式 | .jfr,可被多种工具解析 |
为什么 JFR 比传统采样器好:
传统 jstack 采样:抓瞬间快照,容易漏掉高频短方法、有锁争用看不到
JFR:连续记录事件 + 高精度时间戳,还原时间线上的完整图景
一句话总结: JFR 是「常开式」的低开销记录器,用环形缓冲区持续采集事件,1% 以内的开销换取生产环境全时段可回溯,这是它优于传统采样的根本。
二、JFR 原理与事件体系
2.1 事件类型
JFR 事件按生命周期分为三类:
| 事件类型 | 含义 | 示例 |
|---|---|---|
| 即时事件(Instant) | 某一时刻发生一次 | CPU 负载、GC 周期结束 |
| 持续事件(Duration) | 有开始结束时间 | 锁等待、方法执行、IO |
| 采样事件(Timed/Sample) | 周期性采样 | 方法执行采样、分配采样 |
JDK 自带几十种内置事件,覆盖:
JVM 内部:GC、类加载、JIT 编译、线程
应用层: 锁争用、方法调用、文件/网络 IO、内存分配
系统层: CPU 使用率、上下文切换、磁盘 IO
2.2 环形缓冲区与转储
录制过程:
1. 事件产生 → 写入内存环形缓冲(默认按 1MB 起配)
2. 缓冲满 → 覆盖最旧数据(除非设置了磁盘溢出)
3. 需要分析时 → dump 转储为 .jfr 文件
为什么安全:缓冲区写操作极轻量,事件不阻塞业务线程
一句话总结: JFR 事件分为即时、持续、采样三类,覆盖 JVM 与应用的方方面面;环形缓冲区 + 按需转储的设计让它既能持续记录又不占磁盘。
三、录制:命令行操作
3.1 启动时录制
# 启动 JVM 时开启 JFR,录制 60 秒,输出到 app.jfr
java -XX:StartFlightRecording=filename=app.jfr,duration=60s,settings=profile MyApp
# 用预设配置:default(低开销)或 profile(采样更密)
java -XX:StartFlightRecording=filename=app.jfr,settings=profile MyApp
profile 配置适合短期深度分析,default 配置适合生产常开。
3.2 运行时录制
# jcmd 动态开启录制,无需重启
jcmd <pid> JFR.start name=prod_recording settings=default disk=true maxage=1h
# 查看状态
jcmd <pid> JFR.check
# 转储到文件
jcmd <pid> JFR.dump filename=/tmp/prod.jfr
# 停止并保存
jcmd <pid> JFR.stop name=prod_recording filename=/tmp/prod_stop.jfr
# 用 jfr 命令行工具直接查看事件(无需 JMC)
jfr print --events jdk.GCHeapSummary /tmp/prod.jfr
jfr summary /tmp/prod.jfr
一句话总结: 录制有三种姿势——启动参数常开、jcmd 运行时动态启停、
jfr命令行离线分析;jcmd 动态录制让生产环境「出事再录」成为可能。
四、JMC 分析实战
4.1 打开与整体概览
JMC(JDK Mission Control)是 JFR 文件的图形化分析工具,左侧按问题域组织,右侧是图表与数据。
JMC 主要视图:
线程:线程状态分布、阻塞与等待时间
内存:GC 时间线、分配速率、对象统计
代码:方法采样热点(近似火焰图)
IO: 文件/网络读写吞吐与耗时
锁: 争用最激烈的锁对象
JVM:类加载、JIT 编译活动
4.2 分析路径示例
场景:接口 RT 变长,怀疑锁竞争
1. 打开「线程」→ 看线程状态:WAITING/BLOCKED 占比
2. 打开「锁」→ 找争用最高的锁实例
3. 双击锁 → 看哪个线程持锁最久、哪些线程在等待
4. 切到「代码」→ 看持锁线程的方法热点,定位临界区
# 用 jfr print 快速定位锁事件
jfr print --events jdk.JavaMonitorEnter --stack-depth 8 /tmp/prod.jfr \
| head -100
4.3 代码热点与近似火焰图
JMC 的「代码」视图基于方法采样事件(jdk.ExecutionSample),展示各方法自占 CPU 的比例,可以导出近似火焰图:
解读要点:
1. 顶部越宽 → 该调用路径消耗越多 CPU
2. 关注"自身采样"高的叶子节点(真正干活的方法)
3. 宽而平的火炬 → 大量小方法均摊,查分配
4. 一条竖线很高 → 深栈递归,查算法复杂度
# 命令行导出热点方法 Top N
jfr print --events jdk.ExecutionSample --stack-depth 20 /tmp/prod.jfr \
| jfr summary # 或结合脚本按栈聚合
4.4 与线程转储互补
jstack 线程转储:某一瞬间的线程状态快照,适合"现在卡在哪"
JFR:一段时间的完整事件流,适合"这段时间发生了什么"
最佳实践:
先用 JFR 看整体趋势与时间线
再在关键时间点用 jstack 抓现场堆栈
两者对账,锁定精确代码位置
一句话总结: JMC 按「线程、内存、代码、IO、锁、JVM」组织视图,分析套路是「看状态分布 → 定位异常类别 → 追到具体对象/方法」;代码视图的近似火焰图是 CPU 定位的入口,与 jstack 快照互补。
五、生产场景实践
5.1 持续录制(Always-On)
生产建议默认开启 JFR,用低开销的 default 配置 + 磁盘溢出策略,出事时随时转储最近一小时的数据。
# 生产常开:写磁盘,保留最近 1 小时
java -XX:StartFlightRecording=disk=true,maxage=1h,settings=default MyApp
# 出事后转储完整记录
jcmd <pid> JFR.dump filename=incident_$(date +%s).jfr
5.2 问题驱动录制策略
| 场景 | 配置建议 |
|---|---|
| 常开监控 | settings=default,disk=true,maxage=24h |
| 突发排查 | settings=profile,duration=5m,重启或 jcmd 启动 |
| 压测分析 | settings=profile,覆盖压测全程 |
| 低频偶发 | 常开 default + 触发 dump 的告警脚本 |
# 压测时配合 GC 日志一起分析
java -Xlog:gc*=info:file=gc.log -XX:StartFlightRecording=filename=perf.jfr,settings=profile MyApp
5.3 与 APM 体系集成
常见集成姿势:
1. 定时 jcmd JFR.dump 归档到对象存储,供事后分析
2. 配合指标系统:GC 停顿、CPU 采样在 JFR 里提取为时序指标
3. 出事时脚本自动转储:告警触发 → 转储最近记录 → 附带到工单
4. 压测报告自动归档 .jfr,与代码版本关联
# 一个简单的"告警自动转储"脚本片段
if curl -s localhost:9090/health | grep -q ERROR; then
jcmd <pid> JFR.dump filename=incident_$(date +%s).jfr
fi
一句话总结: 生产实践的核心是「default 常开 + 出事后转储」;压测和突发放大采样密度(profile),两者结合既保证可回溯又不背开销;再配合告警自动转储形成闭环。
六、JFR 与 GC、堆分析结合
6.1 从 JFR 看 GC
JFR 中与 GC 相关的关键事件:
jdk.GCPhasePause 单次停顿
jdk.GCHeapSummary 堆占用变化
jdk.AllocationRequiringGC 分配导致 GC 的压力
jdk.GarbageCollection 完整 GC 记录
判断方法:
停顿频繁但堆不高 → 分配速率过高,对象生命周期太短
堆持续高位并 Full GC → 存在大对象/泄漏
停顿集中在晋升 → 老年代容量配置问题
6.2 从 JFR 看内存分配
# 分配采样:找出"谁在大量分配"
jfr print --events jdk.ObjectAllocationSample --stack-depth 8 /tmp/prod.jfr \
| head -50
结合 MAT 的思路:
JFR 告诉你"哪些调用栈在疯狂分配"
MAT 告诉你"这些对象最终堆在哪里、被谁引用"
两者一前一后,形成完整的分配-存活-泄漏证据链
一句话总结: JFR 擅长回答「谁在分配、何时停顿」,堆转储回答「对象存哪、被谁引用」——JFR 定位方向 + MAT 定位泄漏根因是最有效的组合拳。
七、常见问题排查套路
7.1 CPU 高
1. 开 profile 录制 → 「代码」视图看方法采样热点
2. 区分应用代码 vs JVM 内部(GC/JIT)
3. 热点在应用方法 → 优化算法/缓存
4. 热点在 GC → 结合 GC 事件调整堆与分配
7.2 线程阻塞与 RT 抖动
1. 「线程」视图看 WAITING/BLOCKED 占比
2. 「锁」视图找争用点
3. 「IO」视图看网络/磁盘耗时
4. 结合虚拟线程:大量阻塞是常态,重点看载体线程是否被 Pin
7.3 偶发长停顿(STW)
1. 查 jdk.GCPhasePause 事件的长停顿
2. 查 jdk.ThreadPark、jdk.JavaMonitorEnter 是否有长等待
3. 查安全点(Safepoint):jdk.ExecutionSample 期间 JVM 是否在等待安全点
4. 对账系统日志时间戳,锁定停顿窗口
7.4 内存不断增长但没到 OOM
1. JFR 分配采样:看谁在持续分配大对象
2. 堆占用趋势:G1 周期后堆是否逐级抬升
3. 类加载事件:是否频繁加载新类(动态代理/反射泄漏)
4. 配合堆转储:确认对象被全局缓存长期引用
# 查分配最凶的调用栈
jfr print --events jdk.ObjectAllocationSample --stack-depth 16 /tmp/prod.jfr \
| sort | uniq -c | sort -rn | head -20
一句话总结: JFR 的排查套路是「先看异常类别(CPU/线程/GC/IO),再逐层下钻到具体事件与方法栈」——用事件还原时间线,比抓快照靠谱得多。
八、实战陷阱清单
| 陷阱 | 现象 | 对策 |
|---|---|---|
| 忘记开 JFR | 出事时无数据 | 生产常开 default 配置 |
| 磁盘无限增长 | 空间耗尽 | 设置 maxage/maxsize |
| 采样密度过高 | 开销超预期 | 用 default 常开,profile 只用于短期 |
| 只看采样热点 | 漏掉高频短方法 | 结合持续事件(锁/IO/分配) |
| 转储前就 stop | 数据丢失 | 先 dump 再 stop |
| JFR 文件过大 | 分析卡顿 | 按时间窗/事件过滤导出 |
| 忽略时间戳校准 | 与日志对不上 | 统一 NTP,记录 jvm 启动时间 |
九、总结
| 维度 | JFR 价值 |
|---|---|
| 开销 | <1%,生产可常开 |
| 覆盖 | JVM 内部 + 应用 + 系统层事件 |
| 操作 | jcmd 动态启停,jfr 离线分析 |
| 分析 | JMC 图形化,问题域视图齐全 |
| 结合 | 与 GC 日志、堆转储形成证据链 |
一句话记住:JFR 是 JVM 自带的全景记录仪,低开销地记录一切,出事时按需回放。先把生产环境 default 常开,把 jcmd 转储练熟,再用 JMC 或 jfr 命令行把事件翻译成结论——这是每个 Java 工程师都应该提前部署的观测能力。
延伸阅读
- JVM 内存与 GC 调优实战 — GC 停顿与堆分析的底层原理
- Java 性能优化:从代码到 JVM 的全链路调优 — 性能问题的定位方法论
- OOM 排查与堆转储分析 — 堆转储与 MAT 分析,JFR 的最佳搭档
- Java 日志最佳实践 — 日志与 JFR 时间戳对账的实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。