
最近又做了一次全链路压测其中一个 Spring Boot 服务的接口响应时间从正常几十毫秒飙到了七八百毫秒CPU 直接冲到核数的三倍多。按以往的习惯我先翻监控曲线、再做线程 dump、看堆内存折腾了大半天结论还是模模糊糊栈都堵在同一个地方但说不清根因。最后真正帮我锁死问题的是 JFR——JDK 自带的 Flight Recorder飞行记录器。这篇内容就是围绕 JFR 在 Spring Boot 应用性能诊断中的实战价值来写的适合正在被线上 CPU 飙高、接口变慢、GC 频繁这类问题折磨的 Java 后端开发者尤其是用 Spring Boot 部署服务的同学。JFR 最打动我的点就三个它在 JDK 里内置、开销低到可以长开、记录的是事件流而不是某个瞬间的快照。如果你还没认真用过它这篇可以当成一份从原理到落地的上手笔记。1. 一次线上故障后我为什么把 JFR 放进了必查清单1.1 一个典型的“看起来很好查”的线上问题先还原一下当时的场景。某个订单相关服务压测 100 并发原本 RT 稳定在 30~50ms结果某个版本上线后RT 开始出现锯齿状的毛刺高的时候到了 700ms 以上CPU 也一路上涨。第一反应是有死循环或者锁竞争于是按老套路操作先jstack抓线程栈看到大量线程卡在java.lang.Thread.sleep和数据库连接获取的地方再jmap -dump拉堆快照发现老年代占用偏高但没有明显的泄漏对象。问题就麻烦在这里线程栈和堆快照都是“某一个瞬间的现场照片”可线上问题往往是间歇性的、有周期甚至完全随机的。你抓的瞬间未必是问题爆发的瞬间。那天我连续做了三次线程 dump每一次栈顶都长得不太一样完全拼不出完整故事。后来冷静下来给应用临时开了一段 JFR 录制跑了五分钟数据一打开问题链路清清楚楚GC 暂停时间在压测中段开始恶化方法采样里反复出现同一个 Service 方法锁事件显示线程等待时间高度集中。整个过程从开启录制到定位根因用了不到半小时。那次之后我养成了一个习惯凡是 Java 服务性能异常第一轮排查就把 JFR 开了再说而不是先对着零散的监控曲线猜。1.2 JFR 和常用诊断手段的定位差异很多人排查性能问题用的还是 jstack、jmap、jstat 这三板斧。它们不是没用但和 JFR 的定位完全不同。拿 jstack 来说它的输出是线程栈的瞬时快照适合看“这一刻大家都在干嘛”但无法回答“过去五分钟里CPU 时间到底消耗在哪些方法上”。jmap 的堆转储能让你分析内存分布但文件动辄几个 GB而且分析的是结果不是过程——你不知道这些对象是在什么时机、由哪段代码触发分配的。jstat 能看 GC 的统计曲线但也只是统计没有方法级上下文。JFR 不一样它更像持续在飞机上记录飞行数据的黑匣子把 JVM 内部各种事件按时间轴一件件录下来事后可以回放。以最容易理解的方法采样事件为例JFR 会周期性采样所有线程的执行栈录制结束之后你能看到“这段采样时间内哪些方法占用的采样比例最高”。这不是猜的是一个带时间戳和聚合比例的统计视图。类似的还有锁竞争、GC 暂停、堆内分配、文件 IO、Socket IO、异常抛出等场景每一类都是一个事件流。1.3 JFR 最适合 Spring Boot 的三个理由第一零安装、零 Agent。Spring Boot 应用本身就运行在 JDK 之上JFR 是 JDK 自带的能力。相比那些需要额外下载 Agent、修改启动参数、甚至引入字节码增强的工具JFR 在合规性和便利性上有天然优势尤其是在带安全管控的运维环境里少一个 Agent 就少一个审批环节。第二低开销能够长时间开着。JFR 默认的事件集合和配置下官方给的开销通常在 1% 以内。这意味着你可以把它当作一个常驻的监控能力让它在后台持续录制并把数据滚动写到磁盘上。等到出问题时历史窗口里的数据是可回放的而不需要每次都去现场复现。第三对 Spring Boot 应用这种“黑盒服务”格外友好。Spring Boot 封装了很多东西内嵌容器、连接池、自动配置业务代码深藏在框架调用链里。你在 JFR 里看到的堆栈往往能一路从采样栈穿到具体的业务类和方法直接定位到是哪个入口触发的。提示JFR 不是“压测工具”也不是“APM 系统”它不负责给你画请求链路和响应时间分位数。它的强项在 JVM 内部的执行细节CPU 热点、内存分配、GC、锁、IO这几个维度恰恰是 Spring Boot 应用性能问题最集中的爆发点。2. 先把原理弄明白JFR 到底在记录什么2.1 黑匣子逻辑从“抓现场”到“全程录像”想要用好 JFR不能只当它是一个开关。它的底层逻辑是事件采集与环形缓冲JVM 在运行过程中持续产生大量事件JFR 按照一套事件定义去采集写入一个内存环形缓冲区也可以同时落盘。你可以把环形缓冲想象成一个循环录像带最新的数据不断覆盖最旧的数据。当你执行 dump 操作或者录制时长达到预设值时缓冲区数据会固化成一个.jfr文件。在默认配置下JFR 采集的事件数量相当惊人。它和“日志”完全不同日志是业务代码主动写出来的JFR 事件是 JVM 在关键路径上自己产生的比如一次垃圾回收的开始与结束、一个线程进入阻塞等待、一次锁的获取耗时、一段方法被采样命中。这些事件不需要你在业务代码里埋点也不需要引第三方包天然无侵入。这里要纠正一个常见的误解JFR 并不是只记录“异常事件”。它的录制体系覆盖正常业务运行正因为有大量正常事件的基线数据你才能事后对比“哪个环节异常”。这也是它和故障现场的 jstack 最大的思维差异——前者是“全程录像”后者是“赶到现场拍一张照片”。2.2 围绕 Spring Boot 最容易踩的几个事件类型JFR 里的事件类型很多但结合 Spring Boot 应用的典型故障我建议重点先认识这几类GC 事件包括 GC 的开始、结束、暂停耗时、回收前后堆占用。Spring Boot 应用堆内存通常比较大GC 暂停直接造成接口 RT 尖刺这一类几乎必看。方法采样与 CPU 负载JFR 会按固定频率采样线程栈之后聚合出方法的热度占比。定位 CPU 飙高就靠它你能直接看到哪个业务方法在消耗 CPU。堆分配采样Allocation Profiling它能按线程和调用栈统计对象分配的大小与次数。很多性能问题不是 GC 参数不对而是某个业务方法在疯狂创建大对象导致 GC 压力陡增这组事件能帮你抓“分配元凶”。锁竞争与 Monitor WaitSpring Boot 服务里的锁竞争高发区包括数据库连接池获取、Redis 连接、内存队列、业务锁。通过锁事件你能看到等待时间最长的锁以及持锁线程的栈。Socket IO 与 File IO内嵌容器处理请求下游调用外部服务全部落在这里。接口变慢时Socket 读写的耗时数据能告诉我们时间到底是在本地代码上消耗的还是等在了 IO 上。Java 异常包括异常类型、抛出次数、堆栈。有些性能退化是异常风暴导致的这个视图可以快速给出异常热点。这六类事件基本覆盖了 Spring Boot 服务从“CPU 高”“接口慢”“内存紧张”到“连接池耗尽”的全部常见症状。2.3 “低开销”背后是取舍default vs profileJFR 启动录制时有个settings参数最常用的是default和profile两套预设配置。很多新手不理解为什么会有两套以为是功能差异实际上是采集粒度与开销的取舍。default配置适合长时间运行采集频率低、事件种类偏保守开销通常可以控制在 1% 以内。生产环境长期开启我会用这套。profile配置适合短时间的深入分析采样频率更高、更多事件被打开比如更细粒度的堆分配采样开销会明显上升到 1%~3% 左右但换来的细节也会丰富很多。按我自己的习惯线上如果遇到棘手问题我会用profile配置录 5 到 15 分钟足够做一轮深度分析了。注意没有免费的午餐。想要低开销必然取舍掉部分采样频率。日常长开用 default故障深挖用 profile两种用法要按场景切换不要图省事一档到底。3. Spring Boot 应用接入 JFR 的三种姿势3.1 启动参数方式长期开启的标准姿势最简单可靠的方式是在启动命令里直接带上 JFR 的录制参数。以 Spring Boot 的 fat jar 为例java -XX:FlightRecorder \ -XX:StartFlightRecordingfilename/opt/app/recording/app.jfr,disktrue,maxsize512m,maxage12h,settingsdefault,nameapp_recording \ -jar app.jar解释一下关键参数-XX:FlightRecorder打开 JFR 功能开关。JDK 11 之后一般默认可用但显式写上更稳妥。filename录制数据落盘的文件路径。disktrue允许写入磁盘配合下面的 maxsize/maxage 实现滚动覆盖。maxsize512m录制的磁盘文件最大 512MB超过后最老的数据被覆盖。maxage12h只保留最近 12 小时的数据。settingsdefault使用默认的采集配置。nameapp_recording给这个录制起个名称方便后续用 jcmd 管理。这套参数组合适合生产环境长期挂着相当于给每个 Spring Boot 实例装了一个永不关闭的黑匣子。文件写满或者时间窗口到了之后老数据自动被覆盖不用担心磁盘被塞爆。启动参数方式好在哪应用从一开始就在记录遇到问题随时可以 dump 最近时间窗的数据。不需要等故障发生后才手忙脚乱去开启录制。3.2 jcmd 动态操作排查正在跑的进程有些时候你没法重启应用比如现场已经出问题了再重启可能导致现场丢失。这时候用jcmd在运行中的 JVM 上动态开启 JFR。先找到进程号jps -l假设输出的 PID 是 12345接下来依次执行# 开启录制使用 profile 配置持续 60 秒 jcmd 12345 JFR.start namedump_on_demand settingsprofile duration60s filename/tmp/app_profile.jfr # 查看录制状态 jcmd 12345 JFR.check # 如果开启时没有指定 filename可以在某一时刻手动 dump jcmd 12345 JFR.dump namedump_on_demand filename/tmp/app_dump.jfr # 停止录制 jcmd 12345 JFR.stop namedump_on_demand动态开启最适合什么场景就是那种“问题还在继续但不确定什么时候复现”的情况。先开一个长时间的 profile 录制挂着一旦采集到想要的异常或者栈再停止并 dump 文件。我自己的习惯是遇到偶发性问题先JFR.start开 30 分钟 profile 录制同时让同事持续触发压测或者复现操作。等到问题再次出现或者录制时间到再JFR.dump把数据拉出来。这样命中概率远高于手动掐时间抓线程栈。3.3 容器与编排环境里的落地细节现在 Spring Boot 应用基本都在容器里跑JFR 的使用方式要相应调整。最大的变化是容器生命周期短、Pod 可能会被调度重建如果 JFR 数据只写在容器本地磁盘Pod 一重建数据就没了。我的建议是把 JFR 录制文件目录挂载到持久卷或者宿主机路径避免数据随容器销毁。在容器优雅停机流程里预留几秒钟执行jcmd PID JFR.dump和JFR.stop把最后一段运行数据保留下来。很多启动即崩溃或启动初期异常的问题靠停机前 dump 反而容易抓到。注意容器内 PID 1 的情况。在 Docker 或 Kubernetes 环境里如果 Java 进程是容器的 1 号进程jcmd 连接时偶尔会遇到权限或信号处理问题。实际处理办法是确保容器内以非 root 用户运行且具备相应权限或者直接通过启动参数方式录制来绕开动态连接的麻烦。容器环境里我倾向于“启动参数常开 定期转储”的组合而不是依赖进容器敲 jcmd。原因很朴素不是所有线上环境都允许你随时exec进去操作自动化记录比事后人工介入更可靠。4. 拿到 .jfr 后我一般按这个顺序读数据4.1 先读“总览”把事件分类拉平找反常类别很多同学第一次用可视化工具打开.jfr文件面对一堆视图直接懵了又是线程、又是 GC、又是编译器不知道从哪里下手。我的习惯是先不碰任何具体视图而是看“事件类型”层面的统计——哪一类事件的耗时占比最高、哪一类事件的数量异常。举个例子打开文件后先看 GC 相关事件的总暂停时间如果发现某一段窗口内 GC 暂停占了总时长的 20% 以上那基本可以断定性能瓶颈和 GC 强相关。再看锁竞争事件的总等待时间如果线程等待时间高得离谱注意力就放在锁相关路径上。这一步的核心目的是快速划定问题的大类方向避免一上来就钻到某个晦涩的 JVM 内部事件里出不来。4.2 按症状选入口CPU、慢接口、内存各自看什么不同症状对应不同的分析入口我整理了一个常用对照思路症状优先查看的 JFR 数据关注点CPU 飙高Java 分析器 / CPU 采样方法采样占比 Top 的栈锁定热点方法接口 RT 变慢线程、锁、Socket IO 事件线程长时间处于阻塞等待的锁、Socket 读写耗时频繁 GC / Full GCGC 事件、堆分配采样各 GC 暂停耗时曲线、分配热点调用栈偶发超时 / 毛刺异常事件、线程暂停、GC 暂停是否与 GC 暂停重合是否有异常风暴连接池问题锁事件、线程 IO连接获取等待时间、连接借用调用的持有栈按这个表格去选入口比一个个视图乱点要快得多。比如 CPU 高直接打开“Java 分析器”视图按采样热度排序马上能看到业务方法的占比。慢接口则多关注“线程等待”和“锁”两个页面通常能看到线程停在哪里。4.3 从事件详情到业务代码的定位路径JFR 视图里很多堆栈看起来都是 JVM 内部类容易让人失望——“我看不懂这些栈怎么办”。我常用的技巧是在可视化工具里把包名过滤条件加进分析只保留自己服务的业务包前缀这样堆栈会瞬间干净很多直接露出真正的业务方法。举个例子之前排查一个 CPU 飙升问题原始堆栈里全是jdk.internal和javax开头的框架类看着无从下手。过滤掉非业务包后栈顶跳出一个xxxServiceImpl.batchProcess方法再往下看发现里面循环创建了大量中间对象。再用堆分配采样一验证果然这个方法就是分配大户。到这里根因已经不需要猜了直接打开源码改优化。这个过程没啥玄学采样栈负责“指路”把所有栈合并到最粗粒度筛选业务包一层层剥到具体方法。JFR 的价值就是帮你把“模糊的怀疑”变成“可复现的证据”。5. 实战一次 Spring Boot 接口变慢的完整定位过程5.1 现象、压测曲线和初步猜测前面的理论都是铺垫这里用一个具体案例把完整链路串起来。某个 Spring Boot 服务压测 500 并发核心查询接口 P99 从 60ms 劣化到 500ms 左右CPU 占用从 30% 涨到 280%。监控上看 GC 频率也明显上升老年代占用在高水位波动。初步猜测无非几种代码里出现了明显热点、SQL 查询变慢、连接池不够用、GC 参数不适配。常规工具先上jstack 看到大量线程处于WAITING集中在HikariCP.getConnection附近于是第一怀疑对象是连接池耗尽。但看监控数据连接池激活数并没有打满这个猜测立刻被削弱了。这种矛盾局面最适合用 JFR 把各个维度的事件统筹起来看。5.2 打开 JFR 后看到的三个关键证据给这个服务动态开启了一段 profile 录制15 分钟压测持续进行。数据打开后三个证据直接浮出水面第一个证据是 GC 暂停时间曲线。压测开始后GC 暂停均值从 12ms 一路爬升到 90ms而且老年代回收后占用下降幅度越来越小。这说明堆内存在非常明显的“每一轮请求都在制造大量长期存活对象”的迹象。第二个证据来自堆分配采样。按分配总大小排序排第一的是一个xxxQueryBuilder.buildFilter方法每次调用都会创建一个较大的byte[]和String分配总量占了整个窗口的 40% 以上。这个方法本身业务很简单就是在构造查询条件时反复拼接和转换字符串。第三个证据是锁等待事件。大量线程的锁等待集中在数据库连接获取上但等待的原因不是连接池真的满了而是持锁线程在持锁期间恰好碰到了 GC 暂停导致获取连接的线程集体排队。三个证据拼到一起故事就完整了业务方法大量分配对象 → GC 频率和暂停时间恶化 → 连接获取在 GC 暂停中被打断放大 → 请求 RT 飙升。5.3 根因确认与优化顺着 JFR 给出的栈把buildFilter方法打开一看确实存在两个典型的性能反模式一是在循环里用字符串拼接大量临时字符串每次拼接都会产生中间对象二是为了构造一个查询结构做了很多次无必要的数组拷贝。优化动作很常规把循环里的字符串拼接改成StringBuilder把重复创建的byte[]改成上层复用传入的缓冲区并调整了连接池获取连接的超时等待策略。改完重新压测接口 P99 回落到 65ms 左右GC 暂停均值也回到了 15ms 以内。这个案例之所以值得写出来是因为它典型展示了 JFR 最核心的用法把原本分散在 GC、内存分配、锁竞争几个维度的问题统一到同一条时间线上解释。如果没有事件流数据你可能还在纠结到底是连接池配置不对还是 GC 参数不对实际上两者都只是症状真正的病灶在业务方法里那几行不起眼的字符串拼接。提示JFR 给出的往往不是一句“这里有问题”而是一整套“问题是如何发生的”证据链。解读数据时不要只看单独一个事件类别要把 GC、分配、锁、IO 对照着看才能还原真相。6. 上生产的几个细节版本、文件管理与运维习惯6.1 JDK 版本差异与兼容性清单JFR 在不同 JDK 版本上的表现和使用方式有差异先说最容易踩的版本坑JDK 8JFR 是商业特性需要额外加-XX:UnlockCommercialFeatures -XX:FlightRecorder不加会被拒绝启动录制。JDK 11JFR 开源不再需要解锁商业特性直接用-XX:FlightRecorder或者 jcmd 都能开启。JDK 17FlightRecorder 功能开关默认就是打开的但这不代表自动录制你仍然需要显式启动录制任务。JDK 21使用方式与 17 基本一致JFR 事件体系更加完善直接按常规流程用即可。上生产前先用java -version确认 JDK 版本再看对应版本的 JFR 开启方式。Spring Boot 应用如果跑在容器镜像里务必检查基础镜像的 JDK 版本不要想当然地以为“装上就是 17”。有些镜像基础是 JDK 8启动脚本里没有解锁参数JFR 一开就直接启动失败。6.2 长时间录制与磁盘空间的管理JFR 长时间开启最怕的不是开销是磁盘被写满。默认如果不设置maxsize和maxage录制文件会一直增长直到占满挂载盘。给一个我实际在用的生产参数组合-XX:StartFlightRecordingfilename/data/jfr/app.jfr,disktrue,maxsize1024m,maxage24h,settingsdefault1024MB 循环写24 小时窗口滚动覆盖。同时建议在运维侧加一个定时检查单独对 JFR 数据目录做容量监控。一旦发现有服务异常增长比如单日超过 5GB就需要排查是不是有人手工启动了未限制大小的录制任务或者settings被误设成了无磁盘限制的模式。还有一点JFR 的 dump 文件在开启disktrue时会持续写入同一个文件建议在 dump 之前先JFR.check看一下当前录制状态。如果录制没有正常开启dump 出来的文件会很小甚至为空别白白浪费排查时间。6.3 三个我踩过的具体坑第一个坑是版本解锁参数忘记加。一次在 JDK 8 环境部署新版本启动脚本里只加了-XX:FlightRecorder漏了-XX:UnlockCommercialFeatures结果 JVM 直接报错并拒绝启动还是一个线上服务。后来养成了写部署脚本时先搜一遍“FlightRecorder”前后参数的习惯确保配套注释和参数一起出现。第二个坑是采样深度不够。默认的 JFR 采样栈深度有限业务调用链长的时候栈顶只能看到框架方法看不到底层业务代码。我排查一个深栈问题时就是调高了stackdepth参数才看清完整调用链的。启动录制时可以显式设置-XX:StartFlightRecordingstackdepth256,settingsprofile这个参数可以在事件记录里保留更深的调用栈定位跨多层框架的调用时非常有用。第三个坑是动态开启时只指定了 name 而没指定最终 dump 的路径结果过了好久才想起来还没有把数据固化到磁盘。JFR 的环形缓冲是循环覆盖的等你想起来 dump 的时候最关键的故障窗口早就被新数据冲掉了。所以动态开启时要么在一开始就把filename指定好要么定个闹钟按时执行JFR.dump。黑匣子虽好不把录像带抽出来关键画面一样留不住。最后再分享一个我现在的工作习惯每个月给所有核心 Spring Boot 服务做一次低峰期的短时 JFR 录制时长 15 分钟数据归档留作 baseline。真到某天线上出了问题打开同一服务之前的“健康表现”一对比哪个环节发生了偏移一目了然。JFR 不是一个只能在出故障时用的救命工具它的真正能量藏在平时积累的基线数据里。与其在故障发生后才想着临时开录制不如现在就把它变成一份常备的体检报告。