Java性能优化02:线上 Java 性能问题排查总纲,覆盖 CPU、线程、内存与 GC
Java 性能优化系列第 2 篇。本文解决一个实际问题:线上 Java 服务突然变慢时,第一步看什么,第二步用什么工具,怎样避免还没找到原因就先把现场破坏掉。
“接口慢了”只是现象,不是原因。
同一个现象,可能是代码把 CPU 跑满,也可能是线程在等数据库;可能是对象分配太快导致 GC 频繁,也可能是下游接口变慢。此时直接扩大线程池、增加堆内存或修改 GC 参数,就像没检查便先吃药:偶尔碰巧有效,更常见的是掩盖问题或制造新问题。
这篇文章不追求一次讲透所有工具,而是先建立一条稳定的排查路线:
- 先确认影响范围和异常时间。
- 从操作系统或容器判断哪类资源异常。
- 进入 JVM(Java 虚拟机)查看堆、线程和 JFR 证据。JFR(Java Flight Recorder)是 JDK 自带的运行记录工具,可以把一段时间内的 CPU、GC、锁和 I/O(磁盘、网络等输入输出)事件保存下来。
- 检查数据库、缓存、下游接口等外部依赖。
- 形成一个可以验证的猜测,再决定改哪里。
一、先保业务,再保现场
线上故障的第一目标不是找出最漂亮的根因,而是控制用户影响。
如果错误率还在快速上升,可以先回滚刚发布的版本、限制入口流量、摘除异常实例或临时扩容。采取这些动作时,要记录准确时间。后面只有把监控、日志和变更时间对齐,才能判断哪个动作真正有效。
只要业务情况允许,在重启进程前尽量保存下面的信息:
| 要保存什么 | 为什么 |
|---|---|
| 异常开始和恢复时间 | 用来对齐 CPU、GC、日志与下游监控 |
| 受影响接口、实例和用户范围 | 判断是单实例、单机房还是整个服务的问题 |
| 最近发布、配置和流量变化 | 性能问题经常由变化触发,但“刚改过”还不能直接证明因果 |
| CPU、内存、磁盘和网络快照 | 判断先查计算、内存还是 I/O 等待 |
| 线程转储和短时 JFR | 保存 JVM 当时正在执行或等待什么 |
不要一上来就做 Heap Dump(堆转储,即把整个 Java 堆写入文件)。堆很大时,它可能暂停应用、产生大文件并占满磁盘。先用低风险证据缩小范围,确认确实需要分析对象分布或泄漏时再做。
二、一张图记住排查路线
这张图最重要的不是工具名称,而是“现象 -> 证据 -> 工具”的顺序。
- CPU 高,先确认哪个进程和线程在消耗 CPU,再找热点方法。
- 延迟高但 CPU 不高,优先检查线程、连接池和外部依赖是否在等待。
- 内存上涨,要区分 Java 堆、堆外内存和操作系统看到的进程内存。
- GC 频繁,要先查分配速率和对象存活,而不是先换收集器。
- 偶发抖动,要把 CPU、GC、锁和 I/O 放到同一时间轴上观察。
这里有一条很实用的原则:先使用能排除大类问题的便宜证据,再使用更重的工具。
三、第一层:先看操作系统或容器
JVM 运行在操作系统之上。主机 CPU 被其他进程占满、容器被限流、磁盘很慢或者网络连接堆积时,只看 Java 堆没有意义。
Linux 主机先看这些
uptimetop-H-p<PID>pidstat-p<PID>1pidstat-t-p<PID>1vmstat1iostat-xz1ss-s这些命令分别帮助回答:
- 整台机器的负载是否异常?
- 是哪个 Java 进程、哪个操作系统线程在消耗 CPU?
- CPU 时间花在用户态还是内核态?用户态通常对应应用代码执行,内核态通常对应系统调用和内核工作。
- 是否频繁换页、磁盘等待或网络连接异常?
PID是进程编号。top -H -p <PID>会继续展开这个进程里的操作系统线程,但它仍然只说明“谁消耗资源”,不能直接说明是哪段 Java 代码。
容器里不要只看宿主机总量
dockerstats --no-stream<容器名>dockertop<容器名>-eopid,tid,pcpu,pmem,stat,comm容器可能只有 1 核 CPU 和 512 MB 内存,而宿主机还有大量空闲资源。此时宿主机看起来很轻松,容器内部却已经到上限。
还要注意:容器内外看到的 PID 可能不同。执行jcmd时,最省事的做法是在同一个容器、同一个用户和同一个进程命名空间中运行 JDK 工具。
怎么根据系统指标选方向
| 看到的现象 | 优先方向 | 还不能直接得出的结论 |
|---|---|---|
| Java 进程 CPU 持续高 | 热线程、JFR、CPU 火焰图 | 不能只凭 CPU 高就说是死循环 |
| CPU 不高,接口仍然很慢 | 线程等待、连接池、数据库和下游 | 不能直接增加线程池 |
| 进程内存 RSS 持续涨 | Java 堆、堆外内存、线程栈、映射文件 | 不能把 RSS 全部当作 Java 堆 |
| 磁盘等待高 | 日志、临时文件、数据库或容器存储 | 不能只调 JVM 参数 |
| 网络重传或连接大量堆积 | 网络、连接池、超时和下游容量 | 不能只看本服务代码 |
RSS 可以简单理解为操作系统当前放在物理内存中的进程内存。它包含 Java 堆,但不只包含 Java 堆。
四、第二层:用 jcmd 给 JVM 拍快照
jcmd是 JDK 自带的 JVM 诊断命令。它能查询版本、启动参数、堆概况、线程、JFR 和本地内存,不需要给应用增加依赖。
先找 Java 进程:
jcmd-l再采集第一批信息:
jcmd<PID>VM.version jcmd<PID>VM.command_line jcmd<PID>VM.flags jcmd<PID>VM.uptime jcmd<PID>GC.heap_info jcmd<PID>Thread.print-ljcmd<PID>VM.native_memory summaryscale=MB不要只把输出保存下来,还要知道每条命令回答什么:
| 命令 | 回答的问题 |
|---|---|
VM.version | 当前进程到底使用哪个 JDK,而不是登录终端默认的 Java 版本 |
VM.command_line、VM.flags | 堆大小、GC 和启动参数是否与预期一致 |
VM.uptime | 进程是否刚重启,当前数据是否受启动和预热影响 |
GC.heap_info | 当前使用什么收集器,堆和 Metaspace 大致用了多少 |
Thread.print -l | 平台线程正在执行、等待或竞争什么 |
VM.native_memory | JVM 管理的堆外内存主要分布在哪里 |
NMT 是 Native Memory Tracking,也就是 JVM 本地内存跟踪。它必须在进程启动时开启:
-XX:NativeMemoryTracking=summary如果线上进程没有预先开启,jcmd会明确提示 NMT 未启用,不能在故障发生后临时补出之前的数据。NMT 本身也不是整个进程内存的完整账本,第三方本地库和某些映射内存还需要结合 RSS 等系统指标判断。
JDK 21 虚拟线程怎样导出
传统平台线程可以先用Thread.print -l。如果应用使用了大量 JDK 21 虚拟线程,建议导出专门的线程文件:
jcmd<PID>Thread.dump_to_file-format=json-overwrite/tmp/threads.jsonJSON 格式更适合大量虚拟线程。线程很多时,这条命令的成本也会增加,不要高频连续执行。
五、第三层:线程状态要结合调用栈看
Thread Dump(线程转储)是某一时刻所有线程调用栈的快照。常见状态可以先这样理解:
| 状态 | 说人话 | 排查重点 |
|---|---|---|
RUNNABLE | 正在运行,或者可以运行 | 是否反复停在同一个业务方法;再与线程 CPU 或 JFR 对照 |
BLOCKED | 等待进入synchronized保护的区域 | 锁被谁持有,是否出现大量线程等同一把锁 |
WAITING | 没有超时时间地等待某个条件 | 是正常空闲,还是在等连接、任务结果或下游响应 |
TIMED_WAITING | 等待一段指定时间 | sleep、带超时等待、连接或队列轮询是否符合预期 |
看到大量WAITING不一定有问题。空闲线程池本来就应该等待任务。真正值得关注的是:请求已经排队或超时,而多次线程转储中的大量业务线程仍反复停在相同位置。
同样,RUNNABLE也不等于一定在疯狂使用 CPU。线程可能正在本地方法或网络调用中。要确认 CPU 热点,需要把操作系统线程 CPU、线程调用栈和 JFR 或 async-profiler 放在一起看。
建议在异常仍持续时,每隔 5 到 10 秒采集一次,连续采集 3 次。一个瞬间只能提出猜测,多份相似快照才更有说服力。
六、第四层:别忘了数据库、缓存和下游接口
Java 服务自身 CPU 和 GC 都正常,接口仍然可能很慢。
例如线程栈长期停在下面这些位置:
- 获取数据库连接:可能是连接池用满,也可能是数据库响应慢。
- Socket 读取:可能在等下游响应,而不是本机 CPU 不够。
- Redis 客户端:可能是缓存服务、网络或连接池问题。
- 文件读写:可能是磁盘、日志或共享存储变慢。
这时至少要对齐四组数据:
- 本接口的请求量、p95/p99 和错误率。
- 数据库、Redis、HTTP 客户端连接池的活跃数与等待数。
- 下游接口在相同时间段的延迟和错误率。
- 超时、重试和熔断是否让一次请求放大成多次调用。
线程栈只能告诉你“线程正在等哪里”,不能单独证明“对方服务就是根因”。还要用下游监控、链路追踪或对照实验补齐证据。
七、五类常见现象应该怎样查
1. CPU 持续高
排查顺序:
- 用
top、pidstat或容器指标确认确实是目标 Java 进程。 - 找出高 CPU 线程,并记录线程编号。
- 在线程转储中查对应调用栈,或者直接录制短时 JFR。
- 用 JFR/JMC 或 async-profiler 查看热点方法。
- 结合代码和对照实验确认是否为算法、序列化、日志、正则或锁自旋等问题。火焰图会把采样到的调用栈按占比画成不同宽度,越宽通常代表占用的 CPU 时间越多。
Linux 的线程编号通常是十进制,线程转储中的nid常显示为十六进制,可以这样转换:
printf'%x\n'<线程编号>第 4 篇会完整演示从热线程和火焰图定位 CPU 热点。本篇只负责把排查入口找对。
2. 延迟高,但 CPU 不高
优先问三个问题:
- 请求是否在线程池或队列里排队?
- 线程是否在等锁、数据库连接或下游接口?
- 超时和重试是否设置合理?
此时盲目增加线程可能让数据库和下游承受更多并发,最终错误率更高。先看多份线程转储、连接池等待和下游延迟,再决定是否调整并发模型。
3. 内存持续上涨
先区分三个概念:
- Java 堆:普通 Java 对象主要存放的位置。
- Metaspace:类元数据使用的本地内存。
- 堆外内存:线程栈、直接缓冲区、JIT 代码、本地库等使用的内存。
一次GC.heap_info只能看到当时的快照。判断泄漏要观察多次 GC 后的低点是否仍持续上升。确定是堆内对象问题后,再考虑类直方图或 Heap Dump;如果 Java 堆稳定但 RSS 上涨,应继续看 NMT、直接内存、线程数和本地库。
第 5 篇会专门演示内存分配和泄漏排查。
4. GC 频繁或停顿长
至少要看:
- GC 类型、频率和每次停顿时间。
- GC 前后堆占用下降了多少。
- 对象分配速率是否过高。
- 老年代在 GC 后是否仍持续上涨。
- 长延迟是否真的与 GC 时间重合。
频繁但很短的 Young GC(主要回收短命对象)不一定是故障;一次长时间 Full GC(对整个堆影响更大)则可能直接影响 p99。不要只数 GC 次数,更不要看到 GC 就先增加堆内存。第 6 篇会从 GC 日志和 JFR 继续查到根因。
5. 偶发延迟抖动
偶发问题最怕各看各的时间范围。应用日志是 10:05,监控截图却是 10:20,就无法组成证据链。
JFR 的优势是能在同一时间轴记录 CPU 采样、GC、线程等待、锁竞争、文件和 Socket I/O。结合请求指标和链路追踪,才能判断延迟峰值到底与什么同时发生。
八、用脚本一次保存第一批现场
仓库提供了scripts/collect-jvm-diagnostics.sh。它只依赖 JDK 21 自带的jcmd和jfr:
shscripts/collect-jvm-diagnostics.sh<PID>results/diagnostics30三个参数分别是 Java 进程号、输出目录和 JFR 录制秒数。脚本会保存:
- JVM 版本、命令行、重要参数和运行时间。
- 堆概况和线程转储。
- NMT 摘要;没有预先开启时保留失败提示。
- 一段
settings=profile的 JFR。 - JFR 事件摘要、CPU 负载、热点方法和 GC 停顿汇总。
脚本默认不会生成 Heap Dump,也不会执行类直方图。原因不是这两个工具没用,而是它们在大堆和高负载场景中可能产生明显停顿与磁盘压力,应该在确认必要性后单独执行。
settings=profile比 JFR 默认配置记录更多事件。线上首次使用时应先在同版本、相近负载环境验证开销,并把录制时间控制在 30 到 60 秒。不要因为 JFR 通常开销较低,就把“低开销”误解成“零开销”。
九、一次真实诊断实验
下面不是编造的故障描述,而是使用本系列项目实际采集的数据。
实验环境
| 项目 | 配置 |
|---|---|
| 采集时间 | 2026-10-14 |
| 应用 | java-performance-lab的三个接口 |
| Java | Eclipse Temurin 21.0.9 LTS |
| JVM | G1,-Xms256m -Xmx256m,开启 NMT summary |
| 运行方式 | Windows 11 上的 Linux 容器 |
| 诊断负载 | 16 个虚拟用户,持续 35 秒,report参数为 350,000 |
| JFR | settings=profile,采集 15 秒 |
负载脚本是load-tests/diagnostics.js。结果如下:
| 请求数 | 吞吐量 | 平均延迟 | p95 | p99 | 错误率 |
|---|---|---|---|---|---|
| 17,346 | 494.50 req/s | 35.33 ms | 103.46 ms | 118.40 ms | 0% |
这组数据只用于制造可重复的诊断现场,不代表生产容量,也不用于和第 1 篇基线比较,因为负载模型和report数据规模都不同。
第一步:jcmd 看到了什么
快照显示:
- 使用 G1,总堆为 256 MB,采集时使用约 162.6 MB。
- NMT 显示 JVM 共预留 1,823 MB 地址空间,但实际提交约 531 MB。
- 93 个线程预留约 92 MB 栈空间,当时实际提交约 8 MB。
- 一个请求线程处于
RUNNABLE,调用栈停在ReportService.isPrime。
这里最容易犯的错,是看到reserved=1823MB就说进程已经占用了 1.8 GB。reserved是预留地址空间,判断实际压力应继续看committed和操作系统 RSS。
另一份线程栈显示报价请求在HttpClient.send等待下游结果。这说明同一个进程里可以同时存在 CPU 计算和 I/O 等待,不能看到一个等待线程就把整个问题归为网络。
本次实验的Thread Dump 节选、NMT 摘要和堆概况都保存在仓库中。
第二步:JFR 能否印证 CPU 猜测
15 秒 JFR 给出三组关键证据:
- JVM 用户态 CPU 平均为 71.94%,最高为 77.02%。
ReportService.isPrime占 51.97% 的执行采样,generate占 47.39%。- 这段时间只有 1 次 GC 停顿,最长 5.57 ms。
线程转储和 JFR 独立指向同一段质数计算代码,而 GC 停顿很短。当前最合理的判断是:这个实验场景的主要矛盾是 CPU 计算,不是 GC。
这仍然不是“优化已经完成”。下一步应该针对质数计算做代码级基准和对照实验,再用相同负载验证吞吐、延迟和业务结果。第 3 篇会先解决测试是否可信,第 4 篇再完成 CPU 热点优化。
仓库中保留了JFR 热点摘要、CPU 摘要、GC 停顿摘要和k6 原始摘要。大体积.jfr文件不提交仓库,可以用采集脚本重新生成。
十、工具怎么选
工具不是越多越好。选择标准是:它能不能回答当前问题,产生的风险是否可接受。
| 工具 | 最适合回答什么 | 使用提醒 |
|---|---|---|
top、pidstat、vmstat、iostat、ss | 主机或进程的 CPU、内存、磁盘、网络是否异常 | 先确认容器限制和 PID 映射 |
jcmd | JVM 版本、参数、堆概况、线程、JFR、NMT | 必须使用 JDK 工具,并有权限连接目标进程 |
| JFR | CPU、分配、GC、锁、线程与 I/O 的时间线 | 线上先短时录制,验证配置开销 |
| JDK Mission Control(JMC) | 在图形界面中离线分析.jfr文件 | 适合查看事件时间线和调用树,不必装在生产机 |
| async-profiler | CPU、分配和锁热点,生成火焰图 | Linux 权限和内核设置可能限制采样,先在测试环境验证 |
| Arthas | 运行时查看线程、方法耗时和调用路径 | trace、watch等命令可能增加开销,范围必须收窄 |
| Eclipse MAT | 离线分析 Heap Dump 和对象引用关系 | Heap Dump 的生成成本通常高于离线分析本身 |
| NMT | JVM 管理的本地内存分类 | 必须启动时开启,reserved 不等于实际占用 |
一个稳妥的常见组合是:
- 系统或容器指标判断大方向。
jcmd保存基本现场和多份线程转储。- JFR 记录 30 到 60 秒时间线。
- 仍无法确认 CPU、分配或锁热点时,再使用 async-profiler。
- 需要在线观察具体方法时,谨慎使用 Arthas。
十一、哪些排查动作要格外谨慎
Heap Dump
文件大小通常接近实际堆使用量,生成时可能触发停顿。执行前先确认磁盘空间、实例是否可摘流量、文件如何安全转移以及是否包含敏感业务数据。
类直方图
jcmd <PID> GC.class_histogram能按类统计对象,但某些用法会带来明显停顿。先用 JFR 分配采样或已有监控判断是否真的需要。
强制 GC
不要为了“看看回收后还有多少”就在线上执行jcmd <PID> GC.run。它会主动触发 GC,既可能制造延迟,又改变你想观察的现场。
Arthastrace和watch
动态增强大量方法、输出大对象或匹配高频调用,可能明显增加 CPU 和日志量。必须限制类、方法、条件和执行次数。
高频线程转储
一秒采集几十次不会让证据更可靠,反而增加进程压力和分析噪声。通常间隔 5 到 10 秒的 3 份快照已经能看出主要趋势。
十二、把排查结果写成证据链
一次可以复盘的排查记录,至少包含下面内容:
- 异常时间和影响范围: - 最近发生的发布、配置或流量变化: - 系统或容器资源现象: - JVM 版本、启动参数和运行时间: - 线程转储中的重复调用栈: - JFR、GC 日志或剖析工具发现: - 数据库、缓存和下游同时间段表现: - 当前假设,以及哪些证据支持或反对它: - 临时止损动作和结果: - 下一步对照实验:请注意“哪些证据反对它”这一项。只收集支持自己猜测的证据,很容易把相关性误写成因果性。
小结
线上 Java 服务变慢时,可以先按下面的顺序处理:
- 控制用户影响,记录所有止损动作的准确时间。
- 保存异常时间、范围、最近变更和流量变化。
- 从操作系统或容器确认 CPU、内存、磁盘和网络大方向。
- 用
jcmd保存 JVM 参数、堆概况和多份线程转储。 - 用短时 JFR 把 CPU、GC、锁和 I/O 放到同一时间轴。
- 检查数据库、缓存、下游和连接池,不把等待问题误判成计算问题。
- 让不同证据相互印证,再决定改代码、参数还是架构。
- 谨慎使用 Heap Dump、强制 GC 和动态方法跟踪等高成本动作。
下一篇《Java性能优化:让测试结果可信,JMH、压测与常见陷阱》会回答:为什么同一段代码第一次和第十次速度不同,为什么错误的 JMH 写法会得到完全相反的结论,以及接口压测怎样避免“测得很准,但测错了东西”。
系列导航:上一篇:Java性能优化:性能优化不是调参数,先建立目标、指标与优化闭环 | 系列目录 | 下一篇:Java性能优化:让测试结果可信,JMH、压测与常见陷阱(待写)