第26章:JDK JFR、异步剖析与性能证据链

0 阅读28分钟

1. 项目背景

某支付平台的订单服务在一个周二的下午迎来了一次诡异的"幽灵慢请求"事件——Prometheus监控显示P99延迟从正常的20ms突然飙升至3000ms,持续了整整2分钟后又自动恢复正常。更令人抓狂的是,这2分钟内没有抛出任何ERROR级别日志,JVM的GC停顿时间完全正常(Young GC < 30ms,无Full GC),Grafana上的CPU使用率在45%~55%之间波动毫无异常,堆内存也在健康范围内(老年代使用率约62%)。运维团队翻遍了Prometheus、ELK和SkyWalking的监控面板,除了"请求变慢了"这个事实之外,找不到任何有价值的线索。业务方追责时,运维只能无奈地摊手:"所有指标都正常,我们也没有证据。"这件事最终成了悬案。

这其实是典型的"监控盲区"问题。传统的指标监控(metrics)系统——无论Grafana、Prometheus还是Zabbix——本质上都是采样快照,它们每隔10秒或30秒抓取一次聚合数据(avg/max/p99),但你无法回答:"线程A在14:32:17.523这一刻在做什么?它是否被线程B持有一把锁阻塞了300ms?socket read在这个时间窗口内发生了多少次超时重试?"这些问题的答案散落在JVM内部数百个事件源中,只有同时捕获CPU采样、内存分配、锁竞争、IO事件和GC事件,并以统一的纳秒级时间轴串联起来,才能形成一条完整的性能证据链。

这就是JDK Flight Recorder(JFR)的设计初衷。JFR是HotSpot虚拟机内置的低开销事件剖析器,以<1%的性能损耗持续捕获JVM内部的数百种事件。配合async-profiler生成CPU火焰图、GC日志提供停顿分布分析,三者联合可以构成从"宏观趋势"到"微观事件"再到"热点代码"的三层证据体系。本章将带你从零搭建这一整套性能证据链——从JFR持续录制与按需dump,到火焰图联合分析,再到形成可复用的性能诊断模板,让你在下一个"幽灵慢请求"面前拥有完整的证据还原能力。

2. 项目设计

"大师,救我!"小胖气喘吁吁地冲进茶水间,手里的咖啡差点洒出来。"上次那个支付服务慢请求的事,又被追责了,说我们没有证据。Prometheus明明显示一切正常啊!"

大师正在用勺子搅动杯中的龙井,闻言微微一笑:"Prometheus告诉你'什么东西变慢了',但它从来不告诉你'为什么变慢'。你需要的是JFR。"

"JFR?是不是又是什么重型APM工具,压测都不敢开的那种?"小胖一脸狐疑。

"正好相反,"大师放下茶杯,"JFR——JDK Flight Recorder——是HotSpot虚拟机内置的事件剖析器,从JDK 11开始就已经打包在OpenJDK中了。它的设计哲学和传统的采样型profiler完全不同:采样型profiler每隔N毫秒抓取一次线程栈,依靠统计概率来推断热点——就像每隔10分钟去食堂看一眼谁在吃饭,机会错过了就错过了。而JFR是基于事件的——每当发生一次锁竞争、一次GC暂停、一次socket read、一次TLAB分配失败,JVM就把这个事件记录下来,包含精确的时间戳、线程ID、栈追踪和关键参数。这相当于在每个关键节点安装了监控摄像头,事后可以逐帧回放。"

小白不知什么时候也凑了过来,插话道:"那这开销得有多大?总不能把所有事件都记下来吧?"

"好问题,"大师赞许地点头,"这就是JFR最精妙的设计。它并不是把所有事件都写入磁盘——那确实不行。JFR的做法是:事件在线程本地缓冲区(thread-local buffer)中快速写入,然后由一个全局的环形缓冲区(ring buffer)消费。当环形缓冲区满了,旧事件被新事件覆盖——这就是**连续录制(continuous recording)**模式。你可以在生产环境中以default.jfc配置持续运行,开销稳定在1%以下。"

小胖眼睛一亮:"所以我可以在生产环境一直开着JFR,出问题时把缓冲区dump出来就行了?"

"对。而且JFR有两套模板配置。"大师打开笔记本电脑,敲了几行命令:"default.jfc是给持续录制用的,只捕获GC、编译器、Java Monitor等关键事件的简要信息,开销约1%。profile.jfc是给按需剖析用的,额外开启了CPU采样(每10ms一个ExecutionSample)和对象分配采样(每TLAB一次ObjectAllocationInNewTLAB事件),开销约2%。"

# 查看所有JFR模板
java -XX:StartFlightRecording:help

# 查看某个模板的详细配置
jfr metadata default.jfc

小白托着下巴:"那这些事件到底有哪些?"

大师在IDE中打开JFR的事件元数据树,向两人展示:

"JFR的事件模型定义在src/hotspot/share/jfr/和src/jdk.jfr/share/classes/jdk/jfr/events/中。HotSpot内置了超过150种事件,组织成不同的类别。你想排查锁竞争问题,就看jdk.JavaMonitorEnter事件——它会记录哪个线程等了多久才拿到锁,阻塞它的线程是谁。想排查网络IO,就看jdk.SocketRead——每次socket读取都有时间戳和耗时。想排查内存分配,就看jdk.ObjectAllocationInNewTLAB和jdk.ObjectAllocationOutsideTLAB——它们记录了什么类型的对象被分配了多少字节,分配发生在哪个线程。"

"最核心的是这几个——"大师列出了关键事件清单:

  • jdk.ExecutionSample(CPU剖析):每10ms采样一次线程栈,统计方法和调用链的CPU占用。这是JFR的CPU profiling核心,原理和async-profiler的CPU采样类似,但JFR的事件记录包含了更丰富的线程状态信息(可区分RUNNABLE vs BLOCKED vs WAITING)。
  • jdk.ThreadDump(周期性线程dump):每60秒记录一次完整的线程栈快照。这个和jstack类似,但JFR的线程dump与时间轴对齐,可以看到某次GC期间所有线程在做什么。
  • jdk.GCPhasePause(GC阶段暂停):每次GC暂停的精确时长,比GC日志的精度更高(纳秒级),并且可以关联到触发GC的原因(Allocation Failure / GCLocker / Ergonomics等)。
  • jdk.JavaMonitorEnter(锁竞争):每次进入synchronized块时的等待时间,阻塞线程和持有锁的线程ID。这是排查Java锁争用的核心事件。
  • jdk.ObjectAllocationInNewTLAB / jdk.ObjectAllocationOutsideTLAB(对象分配):TLAB内/外的对象分配事件,记录了分配的类和大小。JFR默认对TLAB外的分配做全量记录,TLAB内的分配做采样记录。
  • jdk.SocketRead / jdk.SocketWrite(Socket读写):每次socket操作的字节数和耗时,是排查RPC调用慢、数据库查询慢的关键事件。
  • jdk.BiasedLockRevocation / jdk.BiasedLockClassRevocation(偏向锁撤销):偏向锁撤销事件会触发安全点(safepoint),导致所有线程暂停,这在低延迟场景下是常见的性能杀手。
  • jdk.CodeCacheFull / jdk.CompilerPhase(编译器事件):JIT编译相关事件,记录了哪个方法被编译、编译耗时、CodeCache使用量等。

"然后,你用什么工具来看这些事件呢?"小白问。

"分三个层次。"大师比划着:"第一层是命令行——jfr print可以把JFR文件以JSON或文本格式输出,适合在服务器上快速排查。第二层是JDK Mission Control(JMC)——这是一个图形化的JFR分析工具,可以按时间轴拖拽浏览、按事件类型筛选、按调用链展开火焰图。第三层是JDK 14引入的JFR Event Streaming API——可以让你的应用程序实时消费JFR事件流,把JFR数据集成到自己的监控系统中。"

// JDK 14+ JFR Event Streaming API 示例
try (var rs = new RecordingStream()) {
    rs.enable("jdk.ExecutionSample").withPeriod(Duration.ofMillis(10));
    rs.enable("jdk.GCPhasePause");
    rs.onEvent("jdk.ExecutionSample", event -> {
        var trace = event.getStackTrace();
        if (trace != null && trace.getFrames().size() > 0) {
            String topMethod = trace.getFrames().get(0).getMethod().getName();
            System.out.println("CPU hotspot: " + topMethod);
        }
    });
    rs.start();
    Thread.sleep(60000);
}

小胖挠着头:"等一下,JFR的CPU采样是固定频率的,而async-profiler可以以更高的采样频率(比如1000Hz)生成更精细的火焰图,这两者怎么配合?"

"这就回到最核心的问题了——性能证据链的三个层次。"大师在白板上画了一个三层金字塔:

"顶层是时序事件链,由JFR负责。当用户报告'14:32~14:34之间请求变慢',你打开JFR录音,把时间轴拖到14:32:00,你会看到:14:32:00.123,线程pool-3-thread-7尝试获取锁0x00000007a3b8c4f0,等待了2851ms后被线程pool-3-thread-2释放。同时,14:32:00.458,pool-3-thread-7执行了一次socket read到数据库,耗时2100ms才返回。这两件事在时间轴上是重叠的——所以这个线程先被锁等了3秒,又被数据库查了2秒。这就是时序事件链还原的'案情经过'。"

"中层是热点火焰图,由async-profiler负责。CPU火焰图告诉你:在这2分钟内,哪个方法吃掉了最多的CPU时间。如果火焰图上显示com.payment.service.OrderService.processOrder占用了78%的CPU,而且调用链中出现了HashMap.get和String.equals,你就知道瓶颈在数据结构和字符串处理上。"

"底层是GC停顿分布,由GC日志负责。GC日志告诉你:在这2分钟内,每次GC暂停了多久、回收了多少内存、晋升了多少对象。如果GC暂停时间突然从30ms跳到200ms,说明老年代或region碎片化严重,这也可能是慢请求的诱因。"

┌──────────────────────────────────────────────────┐
│              JFR 时序事件链(什么时候发生了什么)    │
├──────────────────────────────────────────────────┤
│          async-profiler 火焰图(CPU去哪了)        │
├──────────────────────────────────────────────────┤
│            GC日志 停顿分布(内存怎么了)            │
└──────────────────────────────────────────────────┘
        三层性能证据链——从宏观到微观的完整拼图

"所以标准的性能证据采集流程是——"大师总结道:"在服务启动脚本中同时挂载三样东西:-XX:StartFlightRecording做JFR持续录制、-XX:+PrintGCDetails -Xlog:gc*输出GC日志、以及在问题复现时启动async-profiler生成火焰图。三者在同一个时间窗口内采集,然后交叉比对分析。"

小胖恍然大悟:"我明白了!Prometheus告诉我有慢请求,JFR告诉我线程被什么阻塞了,async-profiler告诉我是哪个方法慢,GC日志告诉我是不是因为GC。这四者合在一起才算完整的证据链!"

"正是。"大师满意地收起电脑,"记住:**单一的监控数据永远只能看到问题的一个侧面。性能诊断的本质,是用多个维度的观测数据在时间轴上对齐,还原出完整的因果链。**JFR就是这根穿起所有证据的线。"

技术映射(全章汇总)

生活比喻技术概念源码位置
警匪片中的"行车记录仪"——事故前2分钟和事故后2分钟都被完整录制JFR连续录制 + 环形缓冲区机制src/hotspot/share/jfr/recorder/repository/jfrEmergencyDump.cpp
每个路口的监控摄像头,只记录"有车经过"的那一刻JFR基于事件的采集模型(Event-based)vs 定时采样(Sampling-based)src/hotspot/share/jfr/recorder/jfrEventSetting.cpp
行车记录仪的"均衡模式"和"高清模式"default.jfc(1%开销)vs profile.jfc(2%开销)src/jdk.jfr/share/classes/jdk/jfr/internal/jfc/default.jfc
刑侦队的"案情时间线"——几点几分谁做了什么JFR事件带纳秒时间戳 + 线程ID + 栈追踪src/hotspot/share/jfr/recorder/stacktrace/jfrStackTraceRepository.cpp
锁匠记录每把锁的"等待时长"和"谁是插队者"jdk.JavaMonitorEnter事件src/hotspot/share/jfr/metadata/metadata.xml:JavaMonitorEnter
银行柜台监控记录每笔业务的"办理时长"jdk.SocketRead / jdk.SocketWrite事件src/jdk.jfr/share/classes/jdk/jfr/events/SocketReadEvent.java
收银台"补货日志"——每次补多少货、什么货jdk.ObjectAllocationInNewTLAB / OutsideTLAB事件src/hotspot/share/jfr/metadata/metadata.xml:ObjectAllocationInNewTLAB
派出所"巡逻日志"——每隔10分钟沿街走一圈记录情况jdk.ExecutionSample(CPU采样) + jdk.ThreadDump(线程dump)src/hotspot/share/jfr/periodic/jfrThreadDumpEvent.cpp
三层防伪溯源体系——包装码、盒码、箱码互相对应JFR(时序) + async-profiler(火焰图) + GC日志(停顿分布)三层证据链JFR: src/hotspot/share/jfr/, async-profiler: src/hotspot/share/ (perf_events), GC log: src/hotspot/share/gc/
24小时便利店的监控录像——7×24循环录制JFR连续录制 + jcmd JFR.dump按需导出src/hotspot/share/jfr/recorder/repository/jfrRepository.cpp
"现场直播"——不用等录完就能看到画面JDK 14+ JFR Event Streaming API 实时事件流src/jdk.jfr/share/classes/jdk/jfr/consumer/RecordingStream.java

3. 项目实战

3.1 环境准备

本章实战需准备以下环境:

组件版本说明
JDK21+JFR已内置,GraphQL/JMC支持需21以上
JDK Mission Control (JMC)9.x从adoptium.net/jmc下载的独立IDE
async-profiler3.0从github.com/async-profi…
操作系统Linux x86_64(perf_events需要)/ macOS(DTrace模式)async-profiler的system profiling backend依赖

验证环境:

# 确认JFR可用
java -XX:StartFlightRecording

# 确认async-profiler可运行
./profiler.sh --version

# 确认jmc可用
jmc --version

3.2 分步实现

步骤一:JFR持续录制与按需dump

在生产环境中,JFR的最佳实践是"持续录制 + 按需dump"模式。类似于行车记录仪的循环录制——一直开着但不占存储,出事故时一键锁定证据。具体配置如下。

启动JVM时开启持续录制:

java \
  -XX:StartFlightRecording:filename=continuous.jfr,maxsize=250M,maxage=2h \
  -XX:FlightRecorderOptions:stackdepth=128,globalbuffersize=64m,threadbuffersize=8m \
  -jar payment-service.jar

关键参数解释:

  • filename=continuous.jfr:持续录制的输出文件,环形写入
  • maxsize=250M:单个JFR文件最大250MB,超过时旧数据被覆盖(类似环形缓冲区)
  • maxage=2h:只保留最近2小时的事件数据
  • stackdepth=128:JFR记录的事件栈深度,默认64,加大到128可以在火焰图中捕获更深的调用链
  • globalbuffersize=64m:全局缓冲区大小,大缓冲区减少事件丢失
  • threadbuffersize=8m:每个线程本地缓冲区大小

启动后,JFR在后台以约0.8%~1.2%的CPU开销持续运行,捕获GC、编译器、Java Monitor等基本事件。可以通过jcmd查看录制状态:

$ jcmd $(pgrep -f payment-service) JFR.check
# 输出:
# Recording 1: name=StartupRecording, duration=1496s, filename=continuous.jfr
#   configured=true, disk=true, running=true

$ jcmd $(pgrep -f payment-service) JFR.status name=StartupRecording
# 输出详细的录制配置和当前状态

问题发生时的按需dump:

当收到告警或发现延迟飙升时,有两种方式获取证据:

方式一:从持续录制中dump最近的数据:

$ jcmd <pid> JFR.dump name=StartupRecording filename=incident-20250122-1432.jfr compress=true
# 输出:
# Dumped recording "StartupRecording", 1873.1 MB written to:
# /opt/app/incident-20250122-1432.jfr

这个dump操作把环形缓冲区中最近2小时(maxage=2h)的事件数据写入文件。由于包含了问题发生前2小时内的事件,你可以在JMC中看到问题发生前后的完整时间线。

方式二:立即启动一个高精度剖析录制(profile模板):

$ jcmd <pid> JFR.start name=urgentprofiling settings=profile duration=120s filename=incident-profile.jfr
# 输出:
# Started recording "urgentprofiling". The result will be written to:
# /opt/app/incident-profile.jfr

这个录制持续120秒,使用profile模板——开启ExecutionSample(CPU采样每10ms)、对象分配采样、更详细的锁事件等。120秒后自动结束并写入文件。

常见jcmd JFR命令速查:

# 列出所有录制
jcmd <pid> JFR.check

# 查看某个录制的详细状态和配置
jcmd <pid> JFR.status name=<recording-name>

# 从持续录制中导出数据
jcmd <pid> JFR.dump name=<recording-name> filename=dump.jfr

# 启动一个新的剖析录制
jcmd <pid> JFR.start name=test settings=profile duration=60s filename=test.jfr

# 停止一个录制
jcmd <pid> JFR.stop name=test

# 停止并立即dump
jcmd <pid> JFR.stop name=test filename=stopped.jfr

# 更改录制配置(运行时调整)
jcmd <pid> JFR.configure name=test stackdepth=256

步骤二:模拟慢请求并取证

下面我们编写一个Java程序来模拟生产环境中的慢请求场景,在JFR录制下重现问题,然后用jfr print分析事件。

模拟程序——PaymentSlowServer.java:

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 模拟支付服务的三种慢请求场景:
 * 1. synchronized锁竞争(模拟第三方支付通道的全局锁)
 * 2. 大对象分配突发(模拟报表导出时的大量对象创建)
 * 3. Socket超时(模拟调用银行接口超时)
 */
public class PaymentSlowServer {

    // 模拟支付通道的全局锁——只有拿到这把锁才能调用某个支付通道
    private static final Object CHANNEL_LOCK = new Object();
    private static final AtomicInteger requestCount = new AtomicInteger(0);

    // 业务线程池:模拟Tomcat worker线程
    private static final ExecutorService workers = Executors.newFixedThreadPool(8);

    public static void main(String[] args) throws Exception {
        System.out.println("[PaymentService] Started. PID=" + ProcessHandle.current().pid());
        System.out.println("[PaymentService] Run: jcmd " + ProcessHandle.current().pid() + " JFR.start name=test settings=profile duration=72s filename=payment-slow.jfr");

        // 提供足够的启动时间让你手动或脚本启动JFR录制
        TimeUnit.SECONDS.sleep(5);
        System.out.println("[PaymentService] Beginning workload simulation...");

        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3);

        // 场景一:每隔一段时间,模拟锁竞争——多个线程争夺同一把全局锁
        scheduler.scheduleAtFixedRate(() -> {
            for (int i = 0; i < 4; i++) {
                workers.submit(PaymentSlowServer::processPaymentViaLockedChannel);
            }
        }, 0, 3, TimeUnit.SECONDS);

        // 场景二:定期触发大对象分配——模拟报表/导出功能
        scheduler.scheduleAtFixedRate(() -> {
            workers.submit(PaymentSlowServer::generateLargeReport);
        }, 5, 8, TimeUnit.SECONDS);

        // 场景三:定期触发耗时操作——模拟银行接口调用
        scheduler.scheduleAtFixedRate(() -> {
            workers.submit(PaymentSlowServer::callBankSlowAPI);
        }, 2, 6, TimeUnit.SECONDS);

        // 持续运行直到JFR录制结束
        TimeUnit.SECONDS.sleep(75);
        System.out.println("[PaymentService] Completed. JFR recording should have been dumped.");
        scheduler.shutdown();
        workers.shutdown();
    }

    /** 场景一:锁竞争——多次小额操作争抢一把全局锁 */
    static void processPaymentViaLockedChannel() {
        long start = System.nanoTime();
        synchronized (CHANNEL_LOCK) {
            // 模拟持有锁期间的处理逻辑:计算手续费、校验签名等
            cpuIntensiveWork(200 + ThreadLocalRandom.current().nextInt(300));
        }
        int n = requestCount.incrementAndGet();
        long cost = (System.nanoTime() - start) / 1_000_000;
        System.out.printf("[Thread-%s] Payment #%d processed, cost=%dms%n",
                Thread.currentThread().getName(), n, cost);
    }

    /** 场景二:大对象分配——模拟报表生成时分配大量byte[]和String */
    static void generateLargeReport() {
        System.out.printf("[Thread-%s] Generating large report...%n",
                Thread.currentThread().getName());
        // 分配多个大数组来触发ObjectAllocationOutsideTLAB事件
        byte[][] chunks = new byte[100][];
        for (int i = 0; i < 100; i++) {
            chunks[i] = new byte[1_000_000 + ThreadLocalRandom.current().nextInt(500_000)]; // 1~1.5MB each
        }
        // 模拟数据填充
        for (byte[] chunk : chunks) {
            for (int j = 0; j < chunk.length; j += 4096) {
                chunk[j] = (byte) (j & 0xFF);
            }
        }
        System.out.printf("[Thread-%s] Report generation done, ~%dMB allocated%n",
                Thread.currentThread().getName(), 100);
    }

    /** 场景三:CPU密集型操作 */
    static void callBankSlowAPI() {
        System.out.printf("[Thread-%s] Calling bank slow API...%n",
                Thread.currentThread().getName());
        try {
            // 模拟网络延迟
            TimeUnit.MILLISECONDS.sleep(500 + ThreadLocalRandom.current().nextInt(2000));
            // 模拟响应解析的CPU消耗
            cpuIntensiveWork(100 + ThreadLocalRandom.current().nextInt(200));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** 模拟CPU密集型计算——用于制造可见的CPU采样点 */
    static void cpuIntensiveWork(int millis) {
        long end = System.nanoTime() + millis * 1_000_000L;
        while (System.nanoTime() < end) {
            // 模拟计算:SHA-256类运算、JSON序列化等
            double d = System.nanoTime() * 0.0001;
            for (int i = 0; i < 100; i++) {
                d = Math.sin(d) + Math.cos(d);
            }
        }
    }
}

运行模拟并启动JFR:

启动脚本(run-simulation.sh):

#!/bin/bash
echo "=== 启动模拟程序 ==="
javac PaymentSlowServer.java
java -Xmx2g \
  -XX:StartFlightRecording:filename=continuous.jfr,maxsize=200M,maxage=30m \
  -XX:FlightRecorderOptions:stackdepth=256 \
  PaymentSlowServer &
PID=$!
echo "PID=$PID"

# 等待程序初始化
sleep 8

# 启动profile录制
echo "=== 启动JFR Profile录制 (120秒) ==="
jcmd $PID JFR.start name=simulation_profile settings=profile duration=120s filename=simulation-profile.jfr

echo "=== 录制中... 等待120秒 ==="
wait $PID

echo "=== 录制完成,分析JFR文件 ==="

分析JFR文件——使用jfr print:

JFR录制完成后,可以用jfr print命令行工具快速查看关键事件。以下是一些典型分析场景:

# 1. 查看录制的概览信息
jfr print --events +jdk.ActiveRecording simulation-profile.jfr

# 2. 按类别查看所有事件的统计
jfr print --categories "Java Development Kit,Java Application" simulation-profile.jfr

# 3. 重点:查看锁竞争事件(按等待时间倒序)
jfr print --events jdk.JavaMonitorEnter simulation-profile.jfr | head -80

# 典型输出(已注释):
# jdk.JavaMonitorEnter {
#   startTime = 14:32:17.523
#   duration = 2851.342 ms     <-- 等了2851ms才拿到锁!
#   eventThread = "pool-3-thread-7" (javaThreadId = 31)
#   stackTrace = [
#     com.example.PaymentSlowServer.processPaymentViaLockedChannel() line: 45
#     ...
#   ]
#   monitorClass = com.example.PaymentSlowServer$CHANNEL_LOCK (objectClass = java.lang.Object)
#   address = 0x00000007A3B8C4F0
# }

# 4. 查看对象分配事件(TLAB外的大对象分配)
jfr print --events jdk.ObjectAllocationOutsideTLAB simulation-profile.jfr | head -60

# 典型输出:
# jdk.ObjectAllocationOutsideTLAB {
#   startTime = 14:32:19.102
#   objectClass = byte[]  (objectClass = byte[])
#   allocationSize = 1.23 MB   <-- 大对象分配!
#   eventThread = "pool-3-thread-3" (javaThreadId = 33)
#   stackTrace = [
#     com.example.PaymentSlowServer.generateLargeReport() line: 75
#     ...
#   ]
# }

# 5. 查看CPU采样事件(找出CPU热点方法)
jfr print --events jdk.ExecutionSample simulation-profile.jfr | head -100

# 6. 查看GC暂停事件
jfr print --events jdk.GCPhasePause simulation-profile.jfr

# 7. 查看socket事件(如果有网络调用)
jfr print --events jdk.SocketRead,jdk.SocketWrite simulation-profile.jfr

高级分析——用jfr filter按条件过滤事件:

# 找出duration > 100ms 的锁等待事件
jfr print --events jdk.JavaMonitorEnter --stack-depth 0 simulation-profile.jfr | \
  grep -A 3 "duration.*[0-9]\{4,\}"

# 按线程分组查看事件(找出哪个线程最"忙")
jfr print --events jdk.ExecutionSample simulation-profile.jfr | \
  grep "eventThread" | sort | uniq -c | sort -rn | head -10

步骤三:火焰图生成与联合分析

在同样的负载下,我们同时启动async-profiler生成CPU火焰图,与JFR事件时间轴交叉分析。

# 假设PaymentSlowServer的PID是12345
# 启动async-profiler,采样60秒,生成火焰图
./profiler.sh -d 60 -f /tmp/payment-cpu-flamegraph.html 12345

# 也可以生成JFR兼容格式的输出
./profiler.sh -d 60 -e cpu -o jfr -f /tmp/payment-cpu.jfr 12345

启动async-profiler的关键参数:

参数说明
-d 60采样持续时间60秒
-e cpu采样CPU事件
-i 1ms采样间隔1ms(即1000Hz频率)
-f flamegraph.html输出火焰图HTML
-o jfr输出JFR格式(可在JMC中打开)
--all-user只包含用户态栈帧,排除JVM内部调用

联合分析与交叉验证:

拿到JFR的事件时间线和async-profiler的火焰图后,如何协同分析?

  1. 时间轴对齐:在JFR中找到"慢请求发生的时间窗口"(比如14:32:00 ~ 14:34:00)。确认async-profiler的采样时间覆盖了这个窗口。

  2. 锁事件定位根因:在JFR的JavaMonitorEnter事件中找到了pool-3-thread-7等待锁2851ms。在JMC中双击这个事件,查看栈追踪,确认是processPaymentViaLockedChannel方法在争夺CHANNEL_LOCK。

  3. 火焰图验证热点:打开payment-cpu-flamegraph.html,搜索processPaymentViaLockedChannel方法,你会发现它在火焰图中的宽度很宽(说明CPU占用高),但这可能是Math.sin/cos的CPU消耗——注意区分"自旋锁等待消耗CPU"和"真正的业务计算消耗CPU"。

  4. GC事件排除假想:JFR的GCPhasePause事件显示Young GC正常(<30ms),无Full GC——排除了"GC引起stalling"的假设。

  5. 分配事件佐证:ObjectAllocationOutsideTLAB事件显示generateLargeReport方法在2分钟内分配了约800MB对象——这是高分配压力的证据。虽然GC能回收,但频繁分配→频繁GC→CPU消耗增加→间接造成其他线程抢不到CPU时间片。

跨工具关联技巧:

# 从JFR中提取锁事件的精确时间戳,匹配到火焰图的时间范围
jfr print --events jdk.JavaMonitorEnter --categories "Java Application" simulation-profile.jfr | \
  grep -E "startTime|duration|eventThread" > /tmp/lock-events.txt

# 用async-profiler的--lock profiling模式独立分析锁竞争
./profiler.sh -d 30 -e lock -f /tmp/payment-lock.html 12345

async-profiler的-e lock模式会追踪所有Object.wait()和synchronized进入等待的时间,生成的锁火焰图可以直观看到哪把锁竞争最激烈、哪个调用路径持有锁的时间最长。

步骤四:建立性能证据链模板

将上述工具和方法封装为可复用的模板,供运维和开发团队在问题排查时一键使用。

一、JFR录制配置模板(放到启动脚本中):

# === JFR持续录制(生产环境 24×7)===
JFR_OPTS="
  -XX:StartFlightRecording:filename=${APP_HOME}/logs/jfr/${APP_NAME}-%t.jfr,maxsize=500M,maxage=4h
  -XX:FlightRecorderOptions:stackdepth=256,globalbuffersize=128m,threadbuffersize=16m
  -XX:+UnlockDiagnosticVMOptions
  -XX:+DebugNonSafepoints
"

# === GC日志(生产环境 24×7)===
GC_LOG_OPTS="
  -Xlog:gc*,safepoint:file=${APP_HOME}/logs/gc/${APP_NAME}-gc-%t.log:time,level,tags:filecount=10,filesize=100M
"

# === 合并到启动命令行 ===
java $JFR_OPTS $GC_LOG_OPTS -jar ${APP_HOME}/app.jar

二、问题排查脚本(perf-evidence-collect.sh):

#!/bin/bash
# perf-evidence-collect.sh
# 性能证据收集脚本——问题发生时执行
# 用法: ./perf-evidence-collect.sh <PID> <输出目录>

PID=$1
OUTDIR=${2:-./perf-evidence-$(date +%Y%m%d-%H%M%S)}
DURATION=180  # 采集时长(秒)

mkdir -p $OUTDIR
echo "=== 性能证据收集 ==="
echo "PID: $PID"
echo "输出目录: $OUTDIR"
echo "采集时长: ${DURATION}s"
echo ""

# 步骤1: 从JFR持续录制中dump最近数据
echo "[1/4] Dumping JFR continuous recording..."
jcmd $PID JFR.dump name=StartupRecording \
  filename=$OUTDIR/jfr-continuous-dump.jfr compress=true
echo "  -> $OUTDIR/jfr-continuous-dump.jfr"

# 步骤2: 启动JFR高精度剖析录制
echo "[2/4] Starting JFR profile recording (${DURATION}s)..."
jcmd $PID JFR.start name=perf_evidence \
  settings=profile \
  duration=${DURATION}s \
  filename=$OUTDIR/jfr-profile-recording.jfr
echo "  -> $OUTDIR/jfr-profile-recording.jfr"

# 步骤3: 启动async-profiler生成火焰图
echo "[3/4] Starting async-profiler..."
ASYNC_PROFILER_HOME=${ASYNC_PROFILER_HOME:-/opt/async-profiler}
RECORDING_START=$(date +%s)
(
  sleep 5  # 等JFR开始采集
  $ASYNC_PROFILER_HOME/profiler.sh -d $((DURATION - 5)) -f $OUTDIR/async-cpu-flamegraph.html $PID
  echo "  -> $OUTDIR/async-cpu-flamegraph.html"

  # 也生成锁火焰图
  $ASYNC_PROFILER_HOME/profiler.sh -d $((DURATION - 5)) -e lock -f $OUTDIR/async-lock-flamegraph.html $PID
  echo "  -> $OUTDIR/async-lock-flamegraph.html"

  # 生成分配火焰图
  $ASYNC_PROFILER_HOME/profiler.sh -d $((DURATION - 5)) -e alloc -f $OUTDIR/async-alloc-flamegraph.html $PID
  echo "  -> $OUTDIR/async-alloc-flamegraph.html"
) &

# 步骤4: 收集线程dump(多次采集)
echo "[4/4] Collecting thread dumps..."
for i in $(seq 1 5); do
  jstack $PID > $OUTDIR/threaddump-$i.txt 2>&1
  sleep $((DURATION / 5))
done
echo "  -> $OUTDIR/threaddump-*.txt"

# 等待所有采集完成
wait
echo ""
echo "=== 证据收集完成 ==="
echo "证据包目录: $OUTDIR"
echo ""
echo "证据清单:"
ls -lh $OUTDIR/
echo ""
echo "下一步:"
echo "1. 用JMC打开 $OUTDIR/jfr-profile-recording.jfr"
echo "2. 浏览器打开 $OUTDIR/async-cpu-flamegraph.html"
echo "3. 分析GC日志: tail -1000 $APP_HOME/logs/gc/*.log"
echo "4. 交叉比对锁等待事件、CPU热点方法和GC停顿时间"

三、性能证据报告模板(EVIDENCE_REPORT_TEMPLATE.md):

# 性能问题证据报告

## 基础信息
- 问题时间: 2025-01-22 14:30 ~ 14:34 (UTC+8)
- 影响服务: payment-service
- 症状: P99从20ms飙升到3000ms,持续2分钟
- 监控告警: Prometheus P99 > 1000ms 触发

## 采集的证据
| 证据 | 文件 | 说明 |
|------|------|------|
| JFR持续录制dump | jfr-continuous-dump.jfr | 问题前后4小时全量事件 |
| JFR剖析录制 | jfr-profile-recording.jfr | 问题时间窗口的高精度profile |
| CPU火焰图 | async-cpu-flamegraph.html | async-profiler CPU采样 |
| 锁火焰图 | async-lock-flamegraph.html | async-profiler锁竞争分析 |
| GC日志 | gc-payment-service-*.log | GC停顿分布 |
| 线程dump | threaddump-*.txt | 问题期间的多次线程快照 |

## 根因分析
### JFR事件时间线
| 时间 | 线程 | 事件 | 详情 |
|------|------|------|------|
| 14:32:00.123 | pool-3-thread-7 | JavaMonitorEnter | 等待锁2851ms |
| 14:32:00.458 | pool-3-thread-7 | ObjectAllocationOutsideTLAB | 分配byte[] 1.23MB |
| 14:32:01.230 | pool-3-thread-2 | GCPhasePause | Young GC 22ms |

### 火焰图热点
- 方法 `processPaymentViaLockedChannel` 占CPU 47%
- 调用链: ... -> PaymentSlowServer.processPaymentViaLockedChannel -> Math.sin/cos

### 结论
根因: `CHANNEL_LOCK`全局锁竞争导致线程排队等待,单个线程持有锁期间执行了CPU密集型操作(约200-500ms),后续线程累计等待时间超过2.8s。

## 建议措施
1. 短期: 将CHANNEL_LOCK拆分为按支付通道id分段的细粒度锁(如ConcurrentHashMap<ChannelId, Object>)
2. 中期: 引入支付通道的异步调用模式,避免在锁内执行耗时计算
3. 长期: 评估使用ReentrantLock的tryLock超时机制,避免死等

## 验证结果
- 锁拆分后,同场景P99从3000ms降压到35ms
- CPU火焰图中processPaymentViaLockedChannel宽度显著缩小

四、例行性能剖析checklist:

检查项工具频率关键指标
CPU热点方法async-profiler / JFR ExecutionSample每月/大促前单一方法CPU占比 > 20%
锁竞争热点JFR JavaMonitorEnter / async-profiler lock每月单次等待 > 500ms
GC停顿分布GC日志 / JFR GCPhasePause每周P99 GC暂停 > 200ms
大对象分配JFR ObjectAllocationOutsideTLAB每月单次分配 > 10MB 或频率 > 100/s
Socket IO耗时JFR SocketRead/SocketWrite每周P99耗时 > 1000ms
线程状态分布jstack / JFR ThreadDump问题排查时BLOCKED线程数 > 线程池大小的10%
safepoint耗时JFR SafepointBegin/SafepointEnd大促前单次safepoint > 50ms

3.3 测试验证

测试场景预期JFR证据预期火焰图证据预期GC日志证据验证结果
synchronized锁竞争JavaMonitorEnter事件显示等待时长>500ms,阻塞线程ID和栈追踪清晰锁火焰图显示CHANNEL_LOCK为热点,processPaymentViaLockedChannel占比高N/A(无额外GC压力)P99延迟从模拟的2000ms+可在JFR时间线精确还原
大对象分配突发ObjectAllocationOutsideTLAB事件显示>1MB分配,连续多次分配火焰图显示generateLargeReport及byte[]分配栈Young GC频率增加但无Full GCJFR分配事件计数与GC日志中的Allocation Failure次数吻合
CPU密集型操作ExecutionSample采样集中在cpuIntensiveWork的Math调用CPU火焰图显示Math.sin/cos占据大量宽度N/A火焰图热点与JFR CPU采样事件高度一致
组合场景同一时间线内三种事件混合,可按时序逐事件还原可分别查看CPU/锁/分配三张火焰图,分别定位三类热点GC停顿正常(<30ms),排除GC嫌疑三层证据相互印证,定位到根因
录制开销验证jfr print查看录制统计,globalBufferLost=0, threadBufferLost=0N/AN/A<1% CPU开销,default模板下事件丢失率为0

4. 项目总结

4.1 优点与缺点

维度JFR (JDK Flight Recorder)async-profilerLinux perfJMX Metrics (Prometheus)
数据模型事件驱动(Event-based),包含精确时间戳、线程、栈追踪采样(Sampling-based),统计频率采样(Sampling-based),基于PMU计数器聚合指标(Aggregated metrics)
CPU开销default: ~1%,profile: ~2%取决于采样频率,通常1%~5%<1%(基于PMU硬件计数器)几乎为零(读取已有计数器)
可观测维度CPU、内存分配、锁竞争、IO、GC、编译器CPU、锁、分配、Java方法调用CPU、cache miss、分支预测、缺页等JVM预定义的数值指标
时间精度纳秒级(每个事件都有精确时间戳)毫秒级(取决于采样间隔)毫秒级秒级(Prometheus采集间隔)
生产环境安全性高(HotSpot内置,官方保证<1%)中(需要attach到进程,依赖perf_events权限)高(系统级工具)高(标准JMX协议)
历史回放能力强(可以dump最近N小时的数据)弱(只能实时采样,无法回溯)弱(只能实时采样)中(Grafana可以看历史,但只有聚合数据)
可视化JMC(强大但需独立安装)火焰图(直观易懂)perf report/flamegraph(需额外工具)Grafana(成熟生态)
Java生态集成度HotSpot原生,JDK 11+默认包含需单独下载,版本需匹配JDK需单独安装perf,需root或perf_event_paranoid配置标准JMX,任何monitoring系统都支持
学习曲线中高(事件类型多,JMC操作需要学习)低(火焰图直观,参数少)高(需要理解PMU和系统底层)低(配置exporter即可)

4.2 适用场景

5个典型适用场景:

  1. 生产环境"幽灵慢请求"排查:当所有metrics正常但用户反馈慢时,JFR是唯一的证据来源。通过JavaMonitorEnter、SocketRead、GCPhasePause的时序交叉分析,可以还原出完整的"线程时间线"——线程在那一刻到底在等锁、等IO还是等GC。

  2. 大促/压测的性能基线建立:在压测或大促期间启用profile模式的JFR录制,捕获CPU采样、对象分配和锁事件的完整数据。事后作为性能基线对比,可以精确定位"哪个版本引入了性能退化"以及"退化了多少毫秒"。

  3. JVM层面的安全审计与异常行为分析:JFR的jdk.JavaExceptionThrow、jdk.JavaErrorThrow、jdk.DeserializationEvent等事件可以追踪安全异常和反序列化操作,结合jdk.SecurityPropertyModification事件可以构建JVM层面的安全审计链条。

  4. 微服务调用链的JVM侧补全:分布式追踪系统(如Jaeger/Zipkin)只能看到服务间的调用关系,看不到JVM内部发生了什么。在span的trace_id上关联JFR事件,可以让一个请求在JVM内部的完整执行路径变得可视化——从线程分配到锁获取到IO等待到GC暂停。

  5. JVM参数调优的证据驱动决策:调整GC参数、堆大小、CodeCache大小、编译阈值等JVM参数时,基于JFR的GCPhasePause(GC暂停分布)、CompilerPhase(编译耗时)、CodeCacheFull(代码缓存满)等事件做出数据驱动的决策,而不是"凭感觉调整"。

2个不适用场景:

  1. 操作系统级别的性能问题(如磁盘I/O调度算法、页回收机制、NUMA节点绑定等):JFR只能看到JVM层面的事件,无法穿透到操作系统层面。此类问题需要使用Linux perf、eBPF(如BCC/bpftrace)或ftrace来排查。

  2. 极高频率的事件全量采集(如每次方法调用的出入参记录):JFR不适合做"全量方法插桩"式的tracing(这应该用BTrace、Arthas或OpenTelemetry agent),因为事件量级会达到每秒数百万次,即使JFR的开销很低,环形缓冲区也会迅速溢出导致事件丢失。

4.3 注意事项

注意事项描述建议
录制文件大小profile模式可能产生数百MB到数GB的JFR文件,尤其是对象分配事件开启后使用maxsize限制单个文件大小;使用maxage限制时间范围;启用compress=true压缩dump
环形缓冲区溢出当JVM产生的事件速率超过全局缓冲区的消耗速率时,事件会被丢弃(bufferLost计数器增加)增大globalbuffersize(如256m);增大threadbuffersize(如16m);减少不必要的事件种类
JDK版本差异JDK 8的JFR是商业特性(Oracle JDK only),JDK 11+ JFR在OpenJDK中可用。JDK 14引入Event Streaming,JDK 17+持续优化了JFR的内存和CPU开销确认运行环境的JDK版本;生产环境建议JDK 17+以获取最好的JFR稳定性和性能
权限要求JFR的jcmd命令需要与目标JVM相同的用户或root权限;async-profiler需要perf_event_paranoid <= 1或root在容器环境中配置securityContext.privileged或设置perf_event_paranoid=1
JMC兼容性JMC 8.x只支持JDK 8/11的JFR文件,JMC 9.x支持JDK 21的JFR文件格式使用与JDK版本匹配的JMC版本
DLL/so依赖async-profiler需要动态链接libasyncProfiler.so,在Alpine Linux等musl-libc环境需要额外配置使用async-profiler的Docker容器版本,或编译musl兼容的so
持续录制对磁盘IO的影响虽然JFR的CPU开销低,但持续写入磁盘(尤其是SSD)会有一定IO影响使用tmpfs或/dev/shm作为JFR输出目录(页面缓存级别的写入)

4.4 常见踩坑经验

踩坑一:生产环境开启了profile模式持续录制

某团队在启动脚本中错误地配置了-XX:StartFlightRecording:settings=profile,maxage=24h,导致JFR以profile模式(含CPU采样和对象分配采样)在生产环境24×7运行。虽然单个采样点的开销很小,但累积效应导致JVM整体吞吐量下降了约3.5%,并且JFR文件迅速涨到8GB/小时,填满了磁盘。最终日志系统也因磁盘满而中断,造成了更大的故障。

教训:持续录制必须使用default模板(settings=default或直接不指定settings),profile模板仅用于按需的短时间(60~300秒)剖析。

踩坑二:Windows环境下的async-profiler盲区

某团队在Windows Server上部署Java应用,直接用async-profiler的profiler.sh脚本尝试采样,结果报错"perf_event_open failed"。排查后发现async-profiler的Linux版本依赖perf_event_open系统调用,在Windows上完全不工作。虽然macOS可以用DTrace模式来部分支持,但Windows下async-profiler的功能受限严重。

教训:在非Linux环境下,使用JFR的CPU采样(settings=profile)替代async-profiler的CPU火焰图。JFR是纯Java实现,在所有平台都能正常工作。如需火焰图,可以用JMC打开JFR文件后导出火焰图。

踩坑三:容器内存限制导致JFR文件写入失败

某团队在Kubernetes pod中运行Java应用,pod的memory limit设为2GB。JFR录制正常进行,但在使用JFR.dump导出文件时失败,错误日志显示"Could not allocate memory"。实际上JFR的dump操作需要额外的堆外内存来序列化事件数据——如果JVM本身已经接近2GB的内存限制,dump时分配的内存会触发OOM Killer杀死pod。

教训:在容器环境中为JFR的dump操作预留buffer——JVM的-Xmx应比容器的memory limit低至少300~500MB,或者将JFR的globalbuffersize和maxsize设置得保守一些(如globalbuffersize=32m, maxsize=200m),并在dump前先执行一次JFR.check确认剩余空间。

4.5 思考题

  1. JFR与OpenTelemetry的集成:JDK 14引入的JFR Event Streaming API使得JFR事件可以实时推送到外部系统。如果让你设计一个"JFR-to-OpenTelemetry"桥接器,将JFR的ExecutionSample、JavaMonitorEnter、GCPhasePause等事件转换为OpenTelemetry的Span和Metric,你会如何设计事件到OTLP的映射模型?特别要考虑:JFR事件是离散的、瞬时的,而OTLP Span是有duration的——如何从离散的锁竞争事件和CPU采样事件中推断出有意义的"慢请求Span"?提示:思考如何利用JFR事件的eventThread字段和startTime字段来还原线程的时间线。

  2. eBPF时代的JFR定位:随着Linux eBPF技术的成熟,越来越多的性能观测工具(如Pixie、Falco、Cilium)可以从操作系统级别捕获网络调用、系统调用、文件IO等。在eBPF能够以极低开销捕获JVM外部性能数据的背景下,JFR作为JVM内部事件收集器的独特价值在哪里?两者如何协同工作以构建更完整的性能观测体系?提示:思考JFR能捕获但eBPF无法直接获取的信息(如Java方法级调用栈、对象分配类型、GC内部阶段、编译器行为)。


下一章预告:第27章将深入CDS/AppCDS启动优化与AOT演进——启动时间与内存占用的系统工程。我们将探索"启动慢"这个微服务时代的核心痛点,从类数据共享(CDS)到应用类数据共享(AppCDS),再到GraalVM Native Image的AOT编译,揭示从"秒级启动"到"毫秒级启动"的系统工程实践。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理