做Java后端开发这些年,线上系统出问题,最怕的不是报错本身,而是登录服务器后一脸懵——CPU飙到99%不知道哪个线程在跑,内存持续上涨不知道该看哪里,Full GC频繁到服务假死却无从下手。靠着重启和加内存糊弄过去,下次又炸,纯属硬扛。JVM调优工具存在的意义,就是把这种“玄学”变成一门有章可循的手艺。这套东西核心就三件事:先会用工具看清楚JVM在忙什么,再把参数调到与代码行为匹配,最后通过调优实战验证效果。这篇文章我把平时排查问题真正会用到的工具和套路整理出来,从命令行到可视化,从堆转储到GC日志,配合几个典型的线上案例场景,希望能帮到正在被JVM问题折磨的朋友。
1. 调优第一步:先把JVM内存模型和GC机制捋清楚
不夸张地说,我见过太多人拿着jmap和jstat乱敲一通,输出看不懂,最后把堆内存调大一倍就完事。调优工具只是放大镜和手术刀,你得先知道身体结构,才知道该往哪儿看。所以哪怕你认为自己已经懂了,我还是建议花两分钟把这张地图在脑子里过一遍。
1.1 堆内存的分区结构与对象一生
JVM的堆内存(Heap)是最常打交道的区域,绝大多数调优参数都是围绕它转的。堆内部通常分成年轻代(Young Generation)和老年代(Old Generation),其中年轻代又拆成Eden区和两个Survivor区(S0、S1)。
对象分配的过程基本是这样的:新对象出生在Eden区,Eden满了触发Minor GC,存活对象被移到S0;下次Minor GC时,把S0和Eden里还活着的对象一起移到S1,然后清空S0;如此反复交换,熬过一定次数(默认15次,可以用-XX:MaxTenuringThreshold调整)依然存活的对象,才晋升到老年代。这是个“淘汰赛”机制,绝大多数对象活不过第一轮Minor GC。
理解这个模型,你才能看懂为什么有的系统新生代太小会频繁Minor GC,为什么大对象直接进老年代会导致老年代提前打满。举个例子:接口里一次性查出上万条记录塞进List,这个大对象如果是直接分配到老年代,很快就会把老年代堆满,触发Full GC,而Full GC是停住所有业务线程的,表现就是接口突然集体超时。
1.2 垃圾收集器选型与适用场景
垃圾收集器不是越新越好,而是匹配业务特征。目前主流JDK8默认是Parallel Scavenge + Parallel Old,注重吞吐量,适合后台计算任务;JDK11及以上默认是G1,把堆划分成多个Region,可预测停顿时间;JDK17里ZGC已经在很多低延迟场景大放异彩。
选型的底层逻辑很简单——你的系统更在意“同一时间能干多少活”(吞吐量),还是更在意“单次响应不能卡顿超过多少毫秒”(延迟)。批处理系统用Parallel没问题,网关、交易系统这类要求接口延迟稳定的场景,G1或ZGC更合适。线上见过一个极端案例:某个服务用Parallel收集器,高峰期Full GC单次停顿接近10秒,客户端全部超时;换成G1并把-XX:MaxGCPauseMillis设置为200后,停顿被拆散成多次小额停顿,业务表现立刻好起来。这些工具看得懂GC日志之后,你才会明白为什么换个收集器效果差这么多。
2. 命令行工具实战:线上排查的“急诊三件套”
JVM自带的那几个命令行工具,绝对是线上排查的第一梯队。没有图形界面、没有额外依赖,只要是JDK环境,进去就能用。但很多人只会jps看看进程号,这远远不够。这一节我把每个工具的核心用法和判别思路过一遍。
2.1 jps和jstat:快速定位进程与GC状态
jps本身没啥技术含量,就是列出Java进程ID,但它是所有操作的前置条件。jps -l可以显示完整包名,方便识别哪个进程是你的应用。不过很多容器环境里宿主机看到的进程名是Java进程,jps -l展示的就是主类名,一眼就能认出来。
日常监控GC最该用jstat,它能直接告诉你GC频率和内存使用情况,不用装任何插件。最常用的命令是:
# 查看进程12345的gc情况,每1秒输出一次,连续输出10次 jstat -gcutil 12345 1000 10输出里YGC和FGC是Young GC和Full GC的次数,YGCT和FGCT是各自累计耗时,GCT是总耗时。如果你看到FGC每隔几秒就+1,而且FGCT飞快增长,这就是典型的Full GC频繁。还有一个判别技巧:看E(Eden区使用占比)和O(老年代占比),如果老年代长期维持在90%以上,说明堆内存明显偏小或者有对象无法被回收。
jstat -gc则输出各个分区的绝对容量和使用量,适合需要精确数值时查看。注意,jstat对刚启动的进程数据可能不太平滑,建议持续观察几分钟再下结论,别拿一次输出就拍板。
2.2 jmap、jstack和jinfo:深入进程内部
如果说jstat是看整体趋势,那jmap和jstack就是看内部细节。先记住一条铁律:在生产环境,尤其是大堆(几十GB以上)的机器上,jmap -dump或jmap -histo都可能触发一次完整的Stop-The-World,会造成业务短暂不可用。所以用之前先掂量一下,能避开高峰期就用,或者直接加上-XX:+HeapDumpOnOutOfMemoryError让JVM在OOM时自动留档。
jmap -heap <pid>可以打印堆的配置参数和当前分区使用情况,适合快速确认JVM实际生效的堆大小。jmap -histo:live <pid>会触发一次Full GC,然后打印类实例数量和占用内存的Top列表,这是找内存泄漏元凶最快的方式之一。你经常能看到byte[]、String、HashMap$Node这类大对象扎堆出现,再结合代码排查基本就能锁定问题。
jstack专门打印线程快照,是分析死锁、线程阻塞、CPU飙高场景的核心命令。通常配合top一起用,后面实战部分我会详细演示整套流程。基础用法是:
jstack 12345 > thread_dump.txt拿到后用文本编辑器打开,搜索deadlock可以快速定位死锁,搜索业务线程名(比如http-nio-8080-exec-*可以看Tomcat工作线程状态)可以分析卡顿点。
jinfo -flags <pid>能列出JVM启动的全部参数和当前生效值,适合在线核对配置是否真的生效。比如你在配置文件里写了-XX:MaxMetaspaceSize=512m,但不确定有没有被覆盖,一条jinfo立刻见分晓。
2.3 高分屏专区:jcmd和jhsdb
JDK7开始jcmd是一个“瑞士军刀”型工具,能替代一部分jmap和jstack的功能。命令格式是jcmd <pid> <command>,比如:
# 打印JVM编译统计 jcmd 12345 Compiler.stats # 打印堆占用概览 jcmd 12345 GC.heap_infoJDK9之后,原来的jhat被移除了,很多人还不知道。如果你想在JDK9以上版本分析堆转储,可以用jhsdb jmap --heap --pid <pid>查看堆概览,或者把dump文件扔给MAT、VisualVM这些桌面工具来分析。这里顺带说一句:不要迷恋命令行一步到位,工具是死的,思路是活的,看到什么信息对应什么问题,这才是排查能力的关键。
3. 可视化与堆转储分析:把问题“看”得明明白白
命令行工具能定位症状,但真要定位到是哪一行代码泄漏了内存、哪个对象占用了70%的堆,光看数字是不够的,需要把堆“拍下来”做离线分析。这就是JProfiler、MAT、VisualVM、JMC(Java Mission Control)这类工具的看家本领。
3.1 Java Flight Recorder:JDK自带的“黑匣子”
JMC配搭JFR(JDK Flight Recorder)是JDK里被低估的能力。JFR可以持续记录JVM运行期间的事件,包括GC、线程阻塞、锁竞争、IO、方法调用热点等,不需要重启应用就能开启。对于偶尔才出现的性能问题,开着JFR抓一圈,分析起来非常顺手。
启动方式并不复杂:
# 录制1小时,保存到jfr文件 jcmd 12345 JFR.start duration=1h filename=/data/logs/app.jfr # 随时查看录制状态 jcmd 12345 JFR.check录制完成后,用jmc打开文件,重点看GC标签页里的停顿时间分布,以及Thread标签页里的锁竞争情况。有一次排查线上“偶发2秒超时”,就是打开JFR后发现某个锁的竞争频率异常高,顺着调用链找到了一个synchronized方法里干了太多耗时的DB查询。
3.2 堆转储文件生成与MAT分析套路
堆转储(Heap Dump)相当于给堆拍了张CT照片。生成方式主要有两种:一是开头提到的让JVM在OOM时自动dump,二是手动用jmap触发:
## 手动生成堆转储文件 jmap -dump:format=b,file=/data/logs/heap_12345.hprof 12345拿到.hprof文件后,Eclipse MAT是这个场景里最常用的分析工具。打开文件后,第一件事不要去看“Leak Suspects”,那个报告有时候太着急下结论。我一般先看Overview右边的Histogram,直接按Shallow Heap或Retained Heap排序,就能看到哪个类的对象最多、谁占内存最大。
定位内存泄漏有个很实用的套路:多次dump对比。比如先dump一次,隔半小时再dump一次,如果某个类的实例数量只增不减,而且Retained Heap越来越大,那基本可以断定就是它泄漏。双击该对象类,点Merge Shortest Paths to GC Roots,钩上exclude all phantom/weak/soft etc. references,看它被什么强引用链挂着,沿着引用链走就能找到泄漏的源头。常见的情景:ThreadLocal存了请求上下文忘了remove、静态集合一直添加缓存数据、使用execute线程池时任务里“巨石对象”循环嵌套引用。
4. 线上实战:四个最经典的调优场景复盘
光说不练假把式。这一节把线上最常遇到的四类问题,从现象到排查再到解决方案,完整走一遍流程。每个案例都是真实场景的脱敏缩影,照着这套路子走,大部分问题都能收敛到根因。
4.1 CPU飙到100%:从线程栈里把“凶手”揪出来
现象:应用响应缓慢,登录服务器用top一看,某个Java进程CPU占用率接近100%。
排查套路是这样的:
# 1. 找到CPU高的线程ID(TID),-H是显示线程级别 top -Hp <pid>top输出的PID其实是操作系统线程ID,是十进制数,但jstack里的线程ID是十六进制,需要转换后再去jstack输出里搜索:
# 2. 把线程ID转成十六进制 printf "%x\n" 12345# 3. 生成线程快照,搜索对应十六进制线程ID jstack <pid> > /tmp/thread_dump.txt比如转换出来是3039,在thread_dump.txt里搜索0x3039,你会看到这个线程当前执行的JDK方法栈。大多数情况下,栈顶能直接看到业务代码——比如某个死循环的while、一次超大集合的遍历排序、正则回溯,或者是GC task thread标记告诉你其实是GC线程在狂跑。
我在实际排查中发现一个高频坑:CPU飙高是Full GC引起的,而Full GC的根因又是堆内存被某些业务对象打满了。此时线程栈里看到的只是VM Thread在跑GC。所以top -Hp看到线程名如果是VM Thread,别急着搜业务代码,先看jstat -gcutil确认GC次数和耗时,多数时候要回到堆dump分析才能闭环。
4.2 内存持续上涨:用两轮dump锁定泄漏源头
现象:监控面板显示JVM堆内存曲线呈阶梯式上涨,重启后恢复正常,但过几天又顶满。
这种问题千万不要在生产环境直接用jmap -dump去摸一个几十GB的堆,大概率会把服务打得停顿几十秒。正确做法是给JVM配置OOM自动dump,或者选择低峰期手动做一次:
java -Xms4g -Xmx4g \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/heap_auto.hprof \ -jar app.jarOOM一发生,dump文件自动落盘。然后隔段时间再看一次存活对象的增长趋势:可以用jmap -histo <pid>定期采样,观察某些类的实例数量曲线。如果看到某个自定义类、字节数组或者某个缓存的实例数量只增不减,再用MAT的Dominator Tree查支配树,找出“大头”对象是否都汇聚到某个容器上。
线上见过一个典型的泄漏案例:某服务用ConcurrentHashMap做本地缓存,代码里只写put,没写过期策略和淘汰机制,压测一上来缓存就一直膨胀,直到堆爆。这种问题工具只能帮你定位,真正的修复还是要靠代码层面加弱引用、加过期清理或换成熟的缓存框架。
4.3 GC频繁导致服务卡顿:从GC日志看全局
现象:服务响应时不时出现长尾超时,线上监控图里能看到“GC暂停”的尖刺。
第一步先确认GC日志有没有开。老项目很多都没开,需要加参数重启,或者在JDK8上临时用jstat -gcutil观察。日志参数按JDK版本区分:
# JDK8 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log# JDK11及以后(注意写法完全变了,别照抄老参数) -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tagsGC日志拿到后,可以用脚本统计或直接丢给在线GC分析平台(比如GCeasy),重点看两个指标:Full GC的频率和单次时长、Minor GC后晋升到老年代的大小。如果每轮Minor GC之后都有大量对象晋升,说明要么Survivor区太小容纳不下,要么有对象本来就是大对象直接进了老年代,要么MaxTenuringThreshold设得太大导致对象熬过太多次回收。
我曾经调过这样一个服务:堆总大小8G,新生代只分了2G,接口产生的大量临时对象还没活过一轮Minor GC,就被频繁“倒腾”到老年代,老年代每周都要Full GC好几次。调整为-Xmn4g之后,新生代能容纳更多短期对象,Full GC频率肉眼可见地降下来了。记住,堆参数不是越大越好,给年轻代配多大,取决于你的业务里“朝生夕灭”的对象占比有多高。
4.4 线程死锁与线程池耗尽:jstack一查便知
线程池耗尽这个问题非常典型,表现为Tomcat的http-nio-8080-exec-50等线程全部处于WAITING状态,新的请求排不上队。Jstack输出里搜索http-nio-8080-exec能看到成片的waiting for monitor entry或parked。
死锁则会出现Found one Java-level deadlock字样,下面列出了两个线程互相等待对方的锁。这种问题代码层面往往是因为两个模块加锁顺序相反,或者CompletableFuture异步任务相互等待导致线程池资源耗尽。
排查经验是:jstack建议连续抓三次,每次间隔5秒,对比线程状态变化。单次快照只能说明某一瞬间的状态,连续抓三次才能区分“偶发阻塞”和“持续卡死”。另外,线程dump文件会很大,建议用grep或脚本筛选关键状态,比如统计各线程状态的分布:
grep "java.lang.Thread.State" thread_dump.txt | sort | uniq -c | sort -nr5. 从参数层面优化:手把手配置一份实用调优方案
很多人拿到参数清单就照着往上贴,结果业务更卡了。参数调整必须基于前面工具的输出,对症下药。这一节给出常用的参数项和选值逻辑,并提供一个可直接落地的样板。
5.1 核心参数选型逻辑:堆大小与代际比例
堆内存的总大小-Xms和-Xmx,我强烈建议生产环境设成相同值,避免启动后动态扩容导致的不确定性。大小怎么定?不是你机器有32G内存就敢给JVM分30G,还得算上系统、文件缓存、中间件的开销。常规公式我给个参考:堆容量 = 机器内存的60%~70%,其余的留给系统页缓存和其他进程。
代际比例上,短期对象为主、典型Web应用,新生代可以占到堆的1/3到1/2,通过-Xmn指定;对象普遍存活较久的后台服务,新生代比例可以小一点。用-XX:SurvivorRatio=8保持Eden区和Survivor区8:1:1的默认比例,不必频繁改动。
这组参数是我在中等规格实例上验证过比较稳的基线:
-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.hprof -Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStampsMetaspaceSize和MaxMetaspaceSize值得单独拎出来说。JDK8之后方法区放到了本地内存,默认情况下MaxMetaspaceSize是无限的,一旦大量动态生成代理类或反射类,容易把机器内存耗尽。设置一个512m的上限并监控使用率,能避免“物理内存被吃光”这种更隐蔽的问题。
5.2 G1调优与常见参数陷阱
G1上线也有几年了,很多人还在用默认参数,遇到问题不知道怎么调。-XX:MaxGCPauseMillis=200是软目标,G1会尝试通过调整各Region回收量来满足,但如果你把堆内存压得太小,谁来了也救不了停顿。另一个参数是-XX:G1HeapRegionSize,默认根据堆大小自动计算,不建议手动改,除非你能准确预判大对象的大小分布。
有一个普遍误区:以为把MaxGCPauseMillis设成50ms就能让停顿更低,结果GC频率反而明显上升,因为G1为了压缩单次停顿,会加速并发标记、增加后台线程活动,反而抢占了CPU资源。建议起步200ms,观察一段时间的Full GC和Promotion Failure情况,再逐步往下压。GC优化本质是“延迟与吞吐、CPU与内存”之间的拔河,没有百利而无一害的参数。
6. 工具选择与避坑清单:少走弯路的经验之谈
6.1 不同场景该选哪个工具
工具没有最好的,只有最合适的。我整理了一个自己的选择标准,不一定适合所有人,但可以当参照系。
| 场景 | 首选工具 | 备选工具 | 备注 |
|---|---|---|---|
| 快速看进程状态 | jps / jcmd | ps -ef | jps在部分容器环境不准 |
| 持续观察GC频率 | jstat -gcutil | VisualVM远程监控 | 适合脚本采集后再绘图 |
| 生成堆转储 | jmap -dump | JFR + jcmd | 大堆慎用,提前评估影响 |
| 分析堆dump | MAT | JProfiler | MAT免费、支持脚本重算 |
| 定位线程问题 | jstack | jdb | 配合top -Hp使用 |
| 实时在线诊断 | Arthas | JMC + JFR | Arthas支持动态反编译看代码 |
| 分析GC日志 | GCeasy | gceasy.io | 上传日志注意脱敏 |
| 排查锁与并发 | JFR | FastThread | JFR统计锁竞争,FastThread分析线程dump |
Arthas值得多说一句。它是阿里开源的在线诊断利器,生产环境里临时想看某个方法耗时、动态改日志级别、反编译看代码,都能在不停机的情况下完成。dashboard命令一屏展示堆内存、GC次数、线程状态;trace命令跟踪方法的每层调用耗时;watch命令观察入参和返回值。很多你已经上线的老服务,没打印关键日志,Arthas是救场神器。
6.2 实操中的高频坑与补救方案
这一节整理下我自己踩过的坑,提前讲清楚,能帮你省不少时间。
第一个坑:生产环境堆几十个GB,直接用jmap -dump把整个堆导出来,结果服务在dump期间停顿明显,还被监控告警轰炸。补救方式是提前在启动参数里加上-XX:+HeapDumpOnOutOfMemoryError,OOM时让JVM自动转储,不要人肉在生产刺激这个动作。如果jmap不可用,可以考虑用jhsdb jmap --dump --pid试试。
第二个坑:jstack在Full GC期间去执行,很可能卡住半天没反应。因为线程dump要访问线程安全点,而GC本身就持有锁。建议先jstat确认GC状态,GC平稳时再去抓线程快照。
第三个坑:JDK版本升级后老参数不生效,打印GC日志得换新语法。JDK9+的-XX:+PrintGCDetails会打印警告,JDK11+干脆不支持了,统一用-Xlog。升级版本后,最好先看一下jinfo -flags确认参数都加载了,别按网上的老博客抄完发现毫无用处。
第四个坑:向GC日志分析平台上传日志时忘了脱敏。日志中的类名、SQL、业务编号可能包含敏感信息,公开分析工具虽方便,但要注意信息合规,建议先手动过滤一遍再上传。
再补充一点:调整参数不是一劳永逸,业务流量增长、接口逻辑改动,都可能导致原有参数不再匹配。我习惯每次版本迭代后都顺手看一眼jstat和GC日志,建立一份日常健康基线,基线有异常波动时再去查,比出了故障再救火高效得多。
最后,回到最开始说的那句话:JVM调优工具解决的是“看清楚”的问题,但调优的终点始终是让业务稳定高效地跑起来。很多性能问题根子在代码——无意义的对象创建、锁竞争、外部接口超时重试——参数调得再好也填不了代码挖的坑。工具是脊梁,方法论是骨架,对业务的敏感才是调优真正的内核。