
做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频繁。还有一个判别技巧看EEden区使用占比和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:MaxMetaspaceSize512m但不确定有没有被覆盖一条jinfo立刻见分晓。2.3 高分屏专区jcmd和jhsdbJDK7开始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、JMCJava Mission Control这类工具的看家本领。3.1 Java Flight RecorderJDK自带的“黑匣子”JMC配搭JFRJDK Flight Recorder是JDK里被低估的能力。JFR可以持续记录JVM运行期间的事件包括GC、线程阻塞、锁竞争、IO、方法调用热点等不需要重启应用就能开启。对于偶尔才出现的性能问题开着JFR抓一圈分析起来非常顺手。启动方式并不复杂# 录制1小时保存到jfr文件 jcmd 12345 JFR.start duration1h 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:formatb,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高的线程IDTID-H是显示线程级别 top -Hp pidtop输出的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:SurvivorRatio8保持Eden区和Survivor区8:1:1的默认比例不必频繁改动。这组参数是我在中等规格实例上验证过比较稳的基线-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -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:MaxGCPauseMillis200是软目标G1会尝试通过调整各Region回收量来满足但如果你把堆内存压得太小谁来了也救不了停顿。另一个参数是-XX:G1HeapRegionSize默认根据堆大小自动计算不建议手动改除非你能准确预判大对象的大小分布。有一个普遍误区以为把MaxGCPauseMillis设成50ms就能让停顿更低结果GC频率反而明显上升因为G1为了压缩单次停顿会加速并发标记、增加后台线程活动反而抢占了CPU资源。建议起步200ms观察一段时间的Full GC和Promotion Failure情况再逐步往下压。GC优化本质是“延迟与吞吐、CPU与内存”之间的拔河没有百利而无一害的参数。6. 工具选择与避坑清单少走弯路的经验之谈6.1 不同场景该选哪个工具工具没有最好的只有最合适的。我整理了一个自己的选择标准不一定适合所有人但可以当参照系。场景首选工具备选工具备注快速看进程状态jps / jcmdps -efjps在部分容器环境不准持续观察GC频率jstat -gcutilVisualVM远程监控适合脚本采集后再绘图生成堆转储jmap -dumpJFR jcmd大堆慎用提前评估影响分析堆dumpMATJProfilerMAT免费、支持脚本重算定位线程问题jstackjdb配合top -Hp使用实时在线诊断ArthasJMC JFRArthas支持动态反编译看代码分析GC日志GCeasygceasy.io上传日志注意脱敏排查锁与并发JFRFastThreadJFR统计锁竞争FastThread分析线程dumpArthas值得多说一句。它是阿里开源的在线诊断利器生产环境里临时想看某个方法耗时、动态改日志级别、反编译看代码都能在不停机的情况下完成。dashboard命令一屏展示堆内存、GC次数、线程状态trace命令跟踪方法的每层调用耗时watch命令观察入参和返回值。很多你已经上线的老服务没打印关键日志Arthas是救场神器。6.2 实操中的高频坑与补救方案这一节整理下我自己踩过的坑提前讲清楚能帮你省不少时间。第一个坑生产环境堆几十个GB直接用jmap -dump把整个堆导出来结果服务在dump期间停顿明显还被监控告警轰炸。补救方式是提前在启动参数里加上-XX:HeapDumpOnOutOfMemoryErrorOOM时让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调优工具解决的是“看清楚”的问题但调优的终点始终是让业务稳定高效地跑起来。很多性能问题根子在代码——无意义的对象创建、锁竞争、外部接口超时重试——参数调得再好也填不了代码挖的坑。工具是脊梁方法论是骨架对业务的敏感才是调优真正的内核。