做了这么多年 Java 开发,我越来越觉得 JVM 调优这件事被神化了。不少人一听说线上 Full GC 频繁、CPU 飙高、接口超时,第一反应就是加内存、改 GC 参数,结果折腾半天,问题该在还是在。我自己的经验是,真正的 JVM 调优,核心在“用工具看清现场”,而不是“凭感觉调参数”。所以这篇东西我不想从理论讲起,直接从我用过的工具链和几次真实调优经历出发,把每个工具什么时候用、怎么用、能看出什么问题、以及操作里那些容易坑到你的细节,一次说清楚。
这套内容适合谁?如果你刚接触 JVM,想从头建立排查思路;或者你已经有一两年经验,但面对线上 GC 日志、堆转储文件不知道从哪下手;又或者你只是想把几种主流调优工具串起来用——这篇都能给你一个能直接套用的操作路径。我会尽可能把命令、参数、分析思路都写具体一点,因为这些东西,光看概念是记不住的,只有放到真实场景里才立得住。
1. 调优之前先看清“问题主体”:你是调堆、调代码、还是调配置
很多调优之所以失败,是因为一开始就把方向搞错了。JVM 调优的入口不是某个工具,而是一连串问题:当前应用是内存紧张导致频繁 GC,还是 CPU 被某个线程占满,又或者是等待锁导致响应变慢?这些问题虽然都表现在“服务变慢”上,但处理思路完全不同。
1.1 先分清三种常见“性能假象”
第一种假象叫“堆内存不足”。看起来是老年代持续增长、Full GC 越来越频繁,像是内存配置不够,实际可能是代码里有个集合在无限往里面塞数据。这时候你把 -Xmx 从 4G 调到 8G,只是把问题往后拖延了几天,根本问题还在。
第二种假象叫“GC 频繁”。表面上是垃圾收集活动特别频繁,导致业务线程停顿,但细看可能不是堆大小的问题,而是对象分配速率太高。也就是单位时间内新建对象的数量过大,超过了回收器的处理能力。这时候你调堆大小没用,得找到谁在疯狂创建对象。
第三种假象叫“CPU 打满”。很多人的第一反应是线程死循环,但有时候是锁竞争激烈导致自旋,又或者是正则表达式灾难性回溯,甚至只是业务高峰期正常的资源占用。不同原因对应的工具完全不同。
所以我的建议是,动手调参之前,先把指标收集齐。最少要看这几样:CPU 使用率、内存使用曲线、线程数、GC 停顿时间和频率、接口响应时间。只有把“现象”量化了,后边用工具才能目标明确,不至于在日志里大海捞针。
1.2 明确调优目标才能选对工具
调优目标不同,工具选型差别很大。如果只是看堆内存使用趋势,用 jstat 就够了;要找内存泄漏的根因,必须用 jmap 配合 MAT 或者 VisualVM 分析堆转储;要排查线程卡顿、死锁,jstack 是首选;想看整体运行状态而不想敲命令,JConsole 和 VisualVM 可以帮你把大部分指标可视化出来。
拿我自己举例,有一次排查某服务频繁 Full GC,我先用 jstat 看到老年代占用率一直居高不下,但这是结果,不是原因。接着我连续打了几份堆转储,用 MAT 对比对象变化,才发现有一个业务缓存根本没用分页,数据量到了一定规模后越积越多。整个过程没有动任何生产参数,只是顺着工具暴露出来的现象一步步定位到了代码。这才是调优工具的正确用法:它们的首要价值是“诊断”,而不是“优化”。
提示:调优的第一原则是“先诊断后调参”。在没有确认瓶颈来源之前,任何参数改动都是盲猜,既浪费时间也可能引入新问题。
2. JDK 自带命令行工具:最可靠、也最容易被忽略的定位手段
很多新同学觉得命令行工具难用,上来就装各种图形化工具。但真正上了生产环境,你会发现很多情况下你只有一个终端窗口,图形化工具连不上,这时候 JDK 自带的命令行工具就是你的全部抓手。它们不需要额外安装,只要有 JDK 就能用,而且信息密度非常高。
2.1 jps:找到目标进程,调优的第一步
调优的第一步不是看堆,是找到要监控的那个 Java 进程。jps 的作用就是列出当前机器上正在运行的 Java 进程以及它们的启动参数。
jps -lvm-l输出完整类名或 jar 路径,-v显示启动时的 JVM 参数,-m显示传入 main 方法的参数。这条命令在排查“改了启动参数却没生效”这类问题时特别有用,因为它直接把进程实际加载的参数摆在面前。
实际场景里有个小坑:有时候一台机器上跑了好几个 Java 服务,或者同一个服务有多实例,你靠 pid 去 jmap、jstack 的时候容易搞错对象。我的习惯是先 jps 看清楚目标进程的 pid,再确认它对应的是哪个端口或哪个服务目录,避免对错进程操作。
2.2 jinfo:看运行期参数和默认值,别靠记忆猜参数
JVM 参数太多,没人能全记住,尤其是一些默认值在不同的 JDK 版本里还不一样。jinfo 可以实时查看指定进程当前的 JVM 参数,也能查看某些参数的默认值。
jinfo -flags <pid>这条命令能列出进程当前的 JVM 标志,比如堆大小、GC 类型、元空间配置等。我在调优前一定会先跑一次,把线上进程的真实参数和预期配置做对比。很多时候你会发现,代码里写了 -Xms2g -Xmx2g,但进程实际只拿 512M,原因往往是启动脚本被别人改过,或者环境变量覆盖了配置。不看 jinfo 的话,你会基于错误的前提去调优,全白干。
2.3 jstat:监控 GC 动态,判断频率和耗时
jstat 是我日常用得最多的命令,专门用来统计垃圾回收行为和堆内存各个分区的情况。它最大的优势是能连续观察 GC 的动态变化,特别适合在压测或者线上高峰期观察系统走势。
jstat -gcutil <pid> 1000 20这条命令每秒输出一次,连续采集 20 次,输出包括 Eden、Survivor、老年代的占用百分比,以及 Young GC 和 Full GC 的次数与累计耗时。
看这个输出的时候,我会重点关注几个点:Young GC 的频率是否过高,如果每秒都好几次,说明新生代太小或者对象分配速率太高;Full GC 次数是否在持续增加,如果短时间内多了很多次,大概率老年代空间不足;单次 Full GC 的耗时是否异常,如果一次停顿超过几百毫秒甚至一秒,对线上响应的影响会非常明显。
用 jstat 调优有个很重要的经验:你要看的是“趋势”而不是“单点值”。偶尔一次 FGC 可以忽略,但如果每隔几十秒就上来一次,而且老年代占用只增不减,这时候就可以确定老年代有问题,接着往下查。
2.4 jmap 与 jcmd:抓取堆现场,定位内存问题的关键证据
当 jstat 显示老年代占用居高不下、Full GC 频繁时,下一步就是抓堆转储(heap dump)。堆转储相当于给整个堆内存拍了一张快照,里面保存了所有对象、类、引用关系,是内存问题分析最有力的证据。
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>-dump:live会在 dump 前先做一次 Full GC,只保留可达对象,这样文件更小,也更容易分析内存泄漏问题。但要注意,这个 Full GC 本身会导致停顿,对高并发生产环境要谨慎使用,最好在低峰期操作。
jcmd 是 JDK 7 之后引入的官方诊断命令,功能更全面,也可以触发 dump:
jcmd <pid> GC.heap_dump /tmp/heap.hprof如果你只是想要堆内存的统计信息,不想抓整个快照,可以用 jmap 的直方图模式:
jmap -histo:live <pid> | head -50它会把所有类的实例数量和占用内存列出来。我一般先用这个快速看一眼,如果发现某个业务类实例数量异常庞大,基本就能锁定怀疑对象。
注意:jmap 抓 live dump 会触发 Full GC,这可能导致线上服务暂停几秒。在低峰期操作,或者改用 jcmd 结合相关策略来做,避免影响在线业务。
2.5 jstack:排查线程卡顿、死锁和 CPU 飙升
如果说堆转储解决的是“内存”问题,那 jstack 解决的就是“线程”问题。它输出指定进程所有线程的当前栈,能看到每个线程在做什么、堵在哪。
jstack <pid> > /tmp/thread.txt拿到线程栈文件后,我会先搜死锁相关的关键词,再按线程状态分组看。如果大量线程处于 BLOCKED 状态,说明有锁竞争;如果很多 RUNNABLE 线程栈都停在一个方法上,那这个方法大概率有问题。
CPU 飙升的场景里,jstack 通常配合 top 的线程模式一起用:
top -Hp <pid>先找出占 CPU 最高的线程号,转成十六进制,然后在 jstack 输出里搜相应线程号,就能定位到具体栈帧。这个方法我用了很多次,每次都直接指向问题代码,比瞎猜高效得多。
3. 图形化工具与堆转储分析:把现场数据变成可读的证据链
命令行工具负责“采集现场”,但很多问题光看原始数据是不够的,尤其是内存泄漏,堆里几十万个对象堆在一起,光靠 jmap -histo 只能看到冰山一角。这时候需要图形化工具或独立的堆分析软件来做深度分析。
3.1 JConsole 和 VisualVM:适合本机开发和较小的服务
JConsole 是 JDK 自带的图形监控工具,直接在命令行输jconsole就能打开。它可以远程连接开启了 JMX 端口的目标进程,看到堆内存曲线、线程曲线、GC 次数等指标。比较适合对服务状态有一个持续、直观的认识,也是我早期接触 JVM 时最常用的入口。
VisualVM 比 JConsole 功能更强,除了基础监控,还可以装插件看 GC 详细面板,甚至直接生成堆转储。在 JDK 6 到 8 时代,VisualVM 功能很不错,后来新版 JDK 把它从标准发行包里移除了,需要单独下载。它最大的优势是操作简单、可视化程度高,适合第一次接触调优工具的同学先用它建立整体认知。
不过说实话,生产环境我很少用这两个工具。一方面是权限和网络限制,图形界面不容易连上去;另一方面是它们展示的都是“实时状态”居多,对深度问题分析的帮助有限。真正让我觉得“值回票价”的,是下面要说的 MAT。
3.2 MAT:剖析堆转储文件,找到“谁吃了内存”
MAT 是 Eclipse Memory Analyzer,主攻堆转储文件的分析。它能把几 GB 的 hprof 文件加载进来,自动分析出可疑的内存泄漏点,还能展示对象的引用链。我分析内存问题时的标准流程是:先用 jmap 抓两个不同时间点的 dump,再用 MAT 对比不同,定位增长最快的业务对象。
我的常用步骤:
- 打开 dump 文件,等待 MAT 构建索引。文件很大时这个过程会比较慢,耐心等。
- 先看“Overview”页签,确认堆大小、类数量、实例数量这些基础信息,再跳转到“Suspects”页签,让 MAT 自动报告可疑对象。
- 对每个可疑对象,追溯“Path to GC Roots”,查看引用链,确认到底是谁持有它、为什么没被回收。
- 如果怀疑对象数量持续增长,再打开另一份 dump 做对比,锁定增长速度异常的对象,这个往往就是泄漏点。
有一次我处理某服务的 OOM 告警,dump 出来以后 MAT 自动找出了几个大对象,看起来是请求上下文里塞了完整的历史操作记录。正常情况下这个请求结束就该释放,但因为用了静态线程池并且线程本地变量没清理,这些对象一直被线程持有,最终把老年代堆满了。整个过程完全靠 MAT 的引用链分析定位。
3.3 GC 日志分析与 GCViewer 使用
有的人觉得 GC 日志没用,因为看不懂。我觉得这属于“不是工具的问题,是方法的问题”。GC 日志其实是判断调优参数是否生效的最直接依据,只要会看几条关键字段,很多性能问题都能从日志里读出答案。
生产环境可以这样启用 GC 日志:
java -Xlog:gc*,gc+cause=debug,gc+ergo=debug -Xloggc:/path/to/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof新版 JDK 已经统一用-Xlog语法,老版本还在用-XX:+PrintGCDetails -XX:+PrintGCDateStamps。GC 日志里最核心的内容是:GC 发生时的堆占用、GC 类型、停顿时长、存活对象大小。比如日志里的196K->1024K(5632K),意思是回收前这一步用了 196K,回收后占用 1024K,该区域总大小 5632K。
靠目测看大量日志很难分析出规律,我会用 GCViewer 这样的工具把日志文件导进去,自动生成各个内存分区的趋势图和 GC 停顿时间分布。它能直接告诉我 Full GC 是否频繁、单次停顿时间集中在什么范围、哪个代的空间使用率长期偏高。我听过不少团队主要的调优决策就是从 GCViewer 的那几张趋势图里来的。
4. 两个真实调优案例:从现象到参数,完整推演一遍
光讲工具用法不讲实战的话,总觉得隔了层纱。下面我拿两个自己处理过的案例,把“看到现象 -> 用工具定位 -> 确定参数 -> 验证效果”的完整链路走一遍。细节会做脱敏处理,但思路都是真实的。
4.1 案例一:老年代占用只升不降,定位到缓存设计
某业务网关在流量上涨后响应变慢,监控里 Full GC 次数每半小时就到两位数,接口平均耗时明显上升,但 CPU 占用并没有特别高,所以第一怀疑方向就是堆内存出了问题。
我先用 jps 找到进程,然后用 jstat -gcutil 观察几次,发现老年代内存使用率很快从 30% 冲到 90% 以上,Full GC 之后只能小幅回落,然后又继续涨。这个走势基本可以判断是“有长期持有的大对象集合”,单纯扩堆解决不了问题。
接着我用 jmap -histo 抓了一次直方图,看到某个缓存相关的类实例数量达到几百万,占用内存排在前列。但这还不够确认根因,因为缓存本来就是常驻内存的东西。我又通过 jcmd 连续抓了两份 dump,用 MAT 对比,发现这个缓存中的对象数量还在持续增长。最后定位到代码逻辑:缓存按用户维度累积操作日志,根本没有设置上限,也没有淘汰策略,设计容量远小于实际流量,结果数据越积越多,最终把老年代填满。
这个案例最终没有改 JVM 参数,而是改了代码:给缓存加上容量上限和过期时间,并对超大 key 做了拆分。改完后,老年代占用稳定在健康水位,Full GC 次数降到了每天个位数。这给我的体会是,很多“内存配置不合理”的表面现象,根因其实是代码层面的资源管理问题。调优工具的作用,是把这个问题从模糊的“好像内存不够”推进到明确的“这类对象在持续增长”。
4.2 案例二:GC 停顿并不高,但接口老是超时,真凶是锁等待
另一个服务的情况很有趣,GC 指标看起来一切正常,Young GC 频率不高,Full GC 几乎没有,但接口在高峰期经常超时。这时候如果把眼光只盯在 GC 上,可能排查很久都没有结果。我判断方向应该转到线程状态和锁竞争上。
我先用 top -Hp 看线程 CPU 消耗,没有发现明显单线程飙高。然后用 jstack 连续抓了三次线程栈,间隔几秒。分析文件后发现,大量业务线程都卡在一个看起来并不复杂的加锁方法上,线程状态为 BLOCKED。同样的栈帧反复出现在多个线程里,说明大家都在抢同一把锁。
顺藤摸瓜找到锁对应的代码,发现是一段对所有请求都执行同步操作的逻辑,锁粒度太大,本质上变成了串行处理。这个问题 JVM 参数当然解决不了,只能靠改代码缩小锁范围,或者换成并发度更高的数据结构。改完后,线程阻塞大幅下降,接口超时也消失了。这个案例再次说明:工具定位问题本身就是最大的价值,千万不要把调参当成排查的终点。
4.3 确定参数时要想清楚“为什么”,而不是照搬配置
真正需要调 JVM 参数的场景当然存在。比如某服务 Young GC 频率过高,jstat 显示 Eden 区总是不够用,Minor GC 每秒都要来几次。这种情况我会考虑扩大新生代或者调整 Eden 和 Survivor 的比例,但前提是对对象的存活周期有基本判断。
如果绝大多数对象朝生夕灭,适合增加新生代空间,减少 Young GC 频率;如果对象存活时间较长,新生代空间太大反而浪费,应适当加大老年代或者考虑换用 G1 这类能根据暂停时间目标动态调整的收集器。
具体的参数调整也要有依据。比如设置堆大小,我的习惯是先看实际占用和 GC 日志,再预留 30% 到 50% 的余量。设置启动堆大小和最大堆大小一致(-Xms = -Xmx),可以减少运行期的堆伸缩开销。设置 GC 日志和 OOM dump,让下次问题有据可查。任何一项改动,都要能在下一个压测或线上数据中找到对应改善。如果找不到,要么是参数没踩到点上,要么是问题根本不在 JVM。
5. 调优后的验证节奏与“最容易被忽略”的后续观测
改完参数不是结束,恰恰是新阶段的开始。我见过有人调完参数后自我感觉良好,结果第二天线上 Full GC 更频繁。所以调优一定带着验证闭环走:改前留基线,改后看变化,出了异常要好回滚。
5.1 建立调优前后的对比基线
调优前先记录当前状态,我一般会记录这几个数据:吞吐量、平均响应时间、P99 响应时间、Young GC 频率、Full GC 频率、单次 GC 最大停顿。如果没有现成的监控系统,就用 jstat 和 jstack 手动采样,并且保持采样时长和频率一致。比如调优前用 jstat 每 3 秒采一次,持续 5 分钟;调优后也用同样方式采一次。这样数据才有可比性。
改完参数后,我最少会观察一个完整的业务周期。例如日活型业务至少要跑完白天的高峰和晚上的低峰各一轮,才敢说这个改动是稳定的。睡前看一眼没问题不代表没问题,凌晨的定时任务往往会带来完全不同的 GC 特征。
5.2 留好回滚预案,别把所有参数押在一次变更上
如果线上环境不方便快速回滚,那调参本身就有风险。我的做法是:把所有参数改动写进一个独立的脚本或者配置片段,而不是直接改到原有启动文件里。这样出现问题,我可以一行命令切回旧配置,而不是手忙脚乱地回忆改过哪些地方。
另外,一次只改一个变量。如果同时改了堆大小、GC 收集器和各种日志参数,出了新问题,你根本没法判断是哪一项引起的。虽然这样验证速度慢,但胜在结论可靠。生产环境不是实验室,宁可慢一点,也要保证每一次参数变更都是可以解释、可以回退的。
5.3 长期观测指标,防止问题复发
有些问题不是一次调优就彻底解决的,比如流量继续增长,同一个参数可能过段时间又失效。我最简单的长期观测办法是,在监控系统里给 GC 次数、GC 停顿、老年代占用、堆总占用、线程数这几个指标都配上告警阈值。一旦出现异常就能及时响应,而不是等业务反馈后才去查。
日常巡检时,我也会每隔一段时间看一次 GC 日志里的趋势。这个习惯帮我在问题被用户发现前就做了好几次提前处理。某次我看到老年代占用虽然还在安全线内,但增长速度比以往快了不少,提前扩容并排查了一个定时任务里因新上线的批量处理逻辑而产生的周期性大对象。如果不看趋势,只等峰值告警,那一次就是事故了。
6. 聊几个我踩过的“工具使用”坑
工具用得再多,有一些细节还是容易忽略。以下几条是我在实际操作中踩过或者见过别人踩的坑,列出来提醒一下,都是真实经历。
第一,jmap 进程对不上。一台机器上多个 Java 进程,jmap 输错 pid 会产生不可预期的后果。我现在的习惯是命令里只写 pid,但操作前必须先 jps 核对端口和启动参数。尤其是容器化的场景,容器内的 pid 和宿主机看到的 pid 不一样,更需要小心。
第二,线上用 jstack 频繁采集,会造成一定的性能开销。虽然 jstack 本身一般不致命,但抓取大量线程栈时线程会短暂暂停,这个 pause 在要求极高一致性的场景里也可能造成影响。离线分析场景可以多抓几次,线上紧急时刻抓一次就够,后续冷静下来再分析。
第三,堆转储文件落到不合适的目录。有的服务工作目录磁盘不大,一份 dump 可能几个 G,直接就把磁盘写满。我会提前在启动参数里用-XX:HeapDumpPath指向一个专门目录,并且确认该目录有足够空间。
第四,MAT 分析用的 dump 文件最好和 JVM 版本匹配。不同 JDK 版本生成的堆转储格式有些差异,老版本 MAT 打开新版本 dump 时可能报错。遇到这种情况,换一个和线上 JDK 版本匹配的 MAT 版本就能解决,不要跟工具死磕。
这些坑都不高深,但每一个都可能导致你在真正的调优问题上多耗几个小时。工欲善其事,必先利其器,这里的“利”不是装多高级的 IDE,而是把每个工具的脾气摸透。
7. 我的长期习惯:调优是个“观测-推断-验证”的循环
最后再分享一点个人体会。JVM 调优的经验积累,不在于背了多少参数,而在于把“观测-推断-验证”这个循环持续转起来。每次排查问题,尽量留下文字记录:当时的指标数据、怀疑方向、用了哪些工具、得到什么结论、最后怎么处理。过段时间回头看,你会发现自己对 JVM 运行规律的理解是在螺旋上升的。
比如你第一次看到 G1 收集器的字符串里的停顿耗时,可能没有概念;第二次结合 GCViewer 的图,你知道了停顿分布的规律;第三次遇到跨代引用问题,你开始理解 G1 的 Remember Set 和扫描成本。这些都是工具反复用、问题反复观察之后才真正沉淀下来的。
我一直觉得,最理想的 JVM 状态,不是任何性能指标都完美,而是你能对它的运行状态做到“心里有数”。通过熟练掌握 jstat、jmap、jstack、jcmd、MAT、GCViewer 这套组合拳,线上再诡异的问题摆到你面前时,你至少知道自己该从哪里开始下手,也敢说一句:问题虽然还没定位,但现场我已经拿到了。