拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JVM调优工具链实战:从监控到诊断的完整排查指南

JVM调优工具链实战:从监控到诊断的完整排查指南

先说个我自己的真实经历。去年接了一个压测告警的夜班,服务CPU直接冲上90%,接口RT从50ms飙到1.8s,数据库连接池被打满。团队第一反应是“重启大法”,我当时按住了所有人,先jps -lvm找到业务进程,再用jstat -gcutil确认FGC在不停上涨,接着jmap -histo:live看到某个byte[]占了堆的一半,最后用Arthas的trace命令直接抓到日志框架里做字符串拼接的调用点。整个定位过程不到40分钟,没重启,没丢现场。后来复盘,问题本质是循环日志里把整个报文打了出去。

这件事给我的教训很直接:JVM调优根本没有银弹,但有一套稳定的分析工具链,从监控到诊断再到调优,每个阶段用什么武器,按什么顺序出招,是完全可以提前准备好的。这篇文章就把这套“武器库”掰开揉碎讲清楚,适合自己扛线上问题的开发、运维,准备JVM面试的同学,以及刚接手Java服务、不知道从哪开始看监控的新手。

1. 先把武器库摆上桌:JVM工具到底分几层

很多人学JVM工具是零散的,今天搜到jstack就背一下,明天看到jmap又记一下,真上了线还是一团乱麻。我的建议是,先不要记命令,先记分层。所有JVM工具按解决问题的时间线,只有三层:监控层、诊断层、调优层。想清楚你在哪个阶段,再决定用哪个工具,否则工具越多越乱。

1.1 一张表看清监控、诊断、调优的边界

我平时带人的时候很喜欢先给这张表,相当于先把地图铺开,再带人进战场。

阶段要回答的问题代表工具核心输出物
监控层现在系统状态如何,是否恶化jstat、jcmd、VisualVM、JFR、JMX ExporterGC频率、堆占用、CPU、线程数时序数据
诊断层为什么变成这样,异常点在哪jstack、jmap、Arthas、MAT线程栈、堆直方图、dump文件、方法调用链
调优层改什么参数,改完是否有效jinfo/jcmd改flag、GC日志、JFR对比JVM启动参数、对比基线后的性能验证记录

这三个层次不是割裂的,而是递进关系。监控负责发现“病征”,诊断负责定位“病灶”,调优负责开出“药方”并验证疗效。很多人一上来就盯着调优参数看,却没有跑过监控和诊断,最后只能靠猜,这是最典型的弯路。

1.2 按JDK版本划分:内置工具才是基础盘

工具的边界和JDK版本强相关,我按实际使用场景给你拆一遍:

  • JDK 8及以下:jps、jstat、jinfo、jmap、jstack、jhat,外加独立的VisualVM。这个版本里JFR还是Oracle JDK的商业特性,OpenJDK里没有,能用的分析手段偏命令行。
  • JDK 9到11:JFR开始开源并集成进OpenJDK,jcmd的能力逐渐加强,VisualVM不再随JDK分发但可以单独下载。
  • JDK 11及以后:JFR开箱即用,配合JDK Mission Control(JMC)看事件流,基本就是“官方全家桶”。另外还有jhsdb这样偏底层的工具,用的时候要小心。

我的核心建议是:先吃透JDK自带的命令行工具,再去折腾Arthas、MAT、Prometheus这类外部工具。原因是自带工具只要机器上有JDK就能用,不受依赖管理、网络环境、Agent注入权限的限制。尤其是线上环境出问题时,可能根本没有条件给你装Agent或找可视化服务,这时候能救命的往往就是一条最简单的jcmd命令。

1.3 工具选型的核心逻辑:先想清楚你在解决什么问题

选型不是选最火的,是选最匹配当前场景的。我自己判断场景就三个问题:

  1. “我只是想看趋势,判断有没有恶化”——选jstat周期性采样,或者上Prometheus做持续采集。
  2. “我已经觉得它异常了,但看不出为什么”——选线程栈、堆直方图、JFR事件记录,目的是抓现场。
  3. “我已经知道根因了,要改参数并验证”——改JVM flag,然后回到监控层做同口径对比。

这里要泼一盆冷水:不要一上来就部署一套重量级APM全家桶。APM能给你很多漂亮面板,但如果你看不懂指标之间的因果,面板越多反而越容易在错误的方向上使劲。JVM调优的核心从来不是工具多,而是能不能把工具输出的数据,翻译成对“内存、线程、GC、代码执行”这四件事的判断。

2. 监控层:先让JVM告诉你它现在的状态

监控层是所有工作的起点。没有监控数据,诊断就像没有线索的推理。这一节我按工具复杂度从低到高讲,你先学会最简单的,再考虑规模化方案。

2.1 命令行三件套:jps、jstat、jcmd能做什么

这三条命令就是JVM监控的“体温计、血压计和听诊器”,任何一个JDK环境都能跑。

第一步,用jps找到JVM进程,别再用ps -ef | grep java一把梭了:

jps -lvm

-l输出完整类名,-v显示启动参数,-m显示main方法传入的参数。这条命令能让你一眼看出进程对应的业务服务,顺便确认启动参数里有没有-Xmx、-XX:+HeapDumpOnOutOfMemoryError这类关键配置。

第二步,用jstat观察GC和内存池的趋势:

jstat -gcutil <pid> 5000 10

每隔5秒输出一次,一共采样10次,结果是这个样子的:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 82.34 45.67 96.21 93.40 1289 45.23 12 3.891 49.127

S0、S1是幸存区使用率,E是Eden区,O是老年代,M是Metaspace,YGC是Young GC次数,YGC T是耗时,FGC是Full GC次数,FGCT是Full GC总耗时。如果看到FGC在持续增长,而FGCT又大,基本可以判定老年代有问题了,这时候就该进入诊断阶段。

第三步,用jcmd做通用体检。jcmd是JDK 7之后官方主推的“万能入口”,很多旧工具的功能都被收编进来了:

# 查看启动参数和系统属性 jcmd <pid> VM.flags jcmd <pid> VM.system_properties # 查看当前堆概览 jcmd <pid> GC.heap_info # 打印线程栈,等价于jstack jcmd <pid> Thread.print # 触发一次GC再查看,谨慎使用 jcmd <pid> GC.run

我实际操作中很少单独记jstack和jmap的用法,因为jcmd基本都覆盖了。但在JDK 8及更早版本里,老命令还是更通用,具体情况看你线上用的JDK版本。

2.2 可视化工具:VisualVM和JConsole的使用套路

命令行适合快速采样,但要看曲线变化,还是VisualVM顺手。VisualVM的打开方式很简单:本地启动后,左侧会自动看到本机的Java进程,双击直接看概览、线程、CPU和内存曲线。

远程连接是重点,也是最容易踩坑的地方。直连生产环境不推荐,常用的是通过JMX方式,在启动参数里加上:

-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false

加了之后VisualVM里右键“添加JMX主机”填IP和端口就行。这里必须提醒:authenticate=false意味着任何能访问这个端口的人都能拿到监控数据,所以生产环境至少要开认证,或者通过跳板机、防火墙做访问控制。我见过不止一次有人图方便裸奔JMX端口,最后被扫到造成信息泄露事故。

另外一个小坑:有些环境会设置-XX:+DisableAttachMechanism,这会导致VisualVM的本地attach和Arthas都连不上。如果你们的生产进程开了这个参数,先排查基础配置再怀疑工具坏了。

2.3 JFR:JDK 11之后最被低估的监控利器

我经常跟同事说,JFR(Java Flight Recorder)是“黑匣子”,它做的事和飞机上的飞行记录仪一样:以事件形式详细记录JVM内部发生了什么。和jstat这种“每隔几秒采样一次”的方式不同,JFR能记录到GC暂停、锁竞争、IO等待、方法执行热点等细粒度事件,记录的深度完全不是一个量级。

从JDK 11开始,OpenJDK自带JFR,用法很简单:

# 方式一:启动时直接开启,记录60秒后写入文件 java -XX:StartFlightRecording=duration=60s,filename=rec.jfr MyApp # 方式二:对运行中的进程动态开启 jcmd <pid> JFR.start name=test duration=60s filename=rec.jfr # 方式三:开启后立刻转储 jcmd <pid> JFR.dump filename=rec.jfr

生成的.jfr文件用JDK Mission Control(JMC)打开。JMC可以单独下载,也可以在很多包管理器里安装。它能看的东西非常全:GC暂停时间、分配压力、锁池、线程阻塞、热点方法、异常抛出次数、IO统计等。排查启动阶段、压测分析、偶发性卡顿,JFR基本都是我第一个启用的工具。

有一个使用频率很高的组合:先开启JFR跑一段时间,等线上问题复现后立刻转储,再用JMC分析问题区间。因为JFR保留了完整事件流,你甚至能回放问题发生前10秒JVM内部在干什么。这是jstack做不到的——后者只能看当前快照。

2.4 规模化监控:Prometheus加JMX Exporter怎么搭

单机可以用命令行和可视化工具,到了多节点、多服务规模,得靠数据采集体系兜底。目前社区里最务实的组合就是Prometheus加JMX Exporter加Grafana,轻量、可控、不需要额外买商业APM。

用法是在启动命令里挂一个JavaAgent:

java -javaagent:/opt/jmx_prometheus_javaagent.jar=9100:/opt/config.yaml -jar app.jar

config.yaml里配置要采集哪些指标。一个最小配置长这样:

rules: - pattern: 'java.lang<type=GarbageCollector><name=(.*)>:(CollectionCount|CollectionTime)' name: jvm_gc_$1_$2 labels: gc_name: "$1" metric: "$2" - pattern: 'java.lang<type=MemoryPool><name=(.*)>:( committed|used|max)' name: jvm_memory_pool_$1_$2 labels: pool: "$1"

Prometheus配置里加一个scrape_configs指向各节点9100端口,Grafana导入采集数据做面板。这一步建议的重点是:先理解指标的语义,再追求好看的面板。把jvm_gc_young_collectioncount和jvm_gc_old_collectiontime这类指标对应的GC行为搞清楚,比多挂几个华丽图表有用得多。

3. 诊断层:线上出问题时,用什么武器按什么顺序排查

监控发现问题之后,最关键的是诊断。诊断层的核心目标只有一个:抓到“现场证据”。我按三个经典症状展开,每类症状对应一条完整排查链路。

3.1 拿到现场第一步:jps定位进程,jinfo确认参数

现场处理第一步不是分析问题,而是确认“敌人是谁、武器是否带齐”。说白了就是确认进程身份和当前启动参数。

jps -lvm jinfo <pid>

jinfo能输出当前生效的JVM参数和系统属性,包括显式没写但被默认值补上的flag。这非常重要,因为很多线上问题源于“某个参数被某平台默认改了”,比如容器环境内存限制、CI平台额外加的GC日志参数,你不看实际生效值,光看启动脚本会完全丧失判断力。

我见过一个典型场景:服务明显频繁FGC,启动脚本里写的-Xmx4g,但jinfo查出来Xmx只有2g,原因是部署平台给容器配了默认JVM参数。这个例子说明,不要相信脚本,要相信JVM自己告诉你的值。

另外,这一步顺手要确认-XX:+HeapDumpOnOutOfMemoryError有没有开、堆转储文件路径在哪、GC日志输出了没。如果这些“保险丝”没接好,后面拿不到dump只能干瞪眼。

3.2 线程问题:jstack和Arthas的thread怎么抓线程栈

CPU飙高、线程卡死、RT抖动,基本都绕不开线程栈分析。线程栈相当于行车记录仪——它记录的是那一刻每个线程在哪个位置执行,必须在事发现场抓,事后复盘价值大打折扣。

传统排查链路是这样的:

# 1. 按CPU占用找出进程内最忙的线程 top -Hp <pid> # 2. 把本地线程ID转成16进制 printf '%x\n' <tid> # 3. 在线程栈里搜这个nid jstack -l <pid> | grep -A 30 'nid=0x<hex>'

流程本身没问题,就是略繁琐,而且jstack在高负载下可能因为抓取时全停顿而加剧抖动。更推荐的方式是用Arthas直接干活:

# 显示当前CPU占用最高的3个线程,附带最近的原因为什么线程状态是RUNNABLE/BLOCKED thread -n 3 # 看某个线程的完整调用栈 thread <tid> # 查阻塞关系 thread -b

thread -n 3一步到位,输出里能看到堆栈调用点,最常见的坏味道是“Dubbo/Tomcat线程池打满,全部卡在锁等待或数据库调用上”。看到这种栈,线程问题本身往往只是表象,真正要查的是那个被竞争的资源或慢I/O。

我当时那个压测事件也是这样,Arthas一跑trace,直接暴露了日志框架里StringBuilder.append超长报文拼接,线程栈里全是logback的Appender调用,问题原因浮出水面。

3.3 内存问题:jmap、MAT、Arthas、OOM文件完整排查链路

内存问题比线程问题更陰险,因为堆是动态的。我给一条亲测有效的路径:

第一步,快速确认对象分布。先不忙dump(dump成本高),用直方图摸底:

jmap -histo:live <pid> | head -60

输出会显示各类对象数量、占用字节数。看到某类对象数量异常大,通常就是嫌疑点。比如String、char[]、byte[]数量猛增,第一反应就是“是不是有大字符串被反复复制”。byte[]增大还可能来自网络IO缓冲区或图片类业务,需要结合代码判断。

第二步,准备自动转储和手工dump。启动参数得常年带上:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/$(date +%Y%m%d)_heap.hprof

实在需要手工dump,用:

jmap -dump:format=b,file=/data/dump.hprof <pid>

这里有个大坑要讲清楚:jmap -dump在JDK 8及较早版本里可能触发一次Full GC,对高峰期服务是雪上加霜。在JDK 9+里jmap的行为有调整,但线上最好还是用jcmd <pid> GC.heap_dump或者先做好降级预案再用。

第三步,用MAT做深度分析。把hprof文件拖进Eclipse MAT,核心看三个视图:

  • Leak Suspects:MAT自动分析的可能泄漏点。
  • Histogram:按类统计对象数量和shallow heap、retained heap。
  • Dominator Tree:支配树,能看出“谁持有谁”,也就是哪个大对象把一堆小对象“拽”在内存里。

我调过的典型泄漏里,有一类是缓存框架设置了永不过期,key又在动态增长;另一类是HTTP连接池响应体没有释放,底层byte[]全部堆积被某个Map引用。这两种靠猜很难猜出来,MAT的Dominator Tree一眼就能看到根路径:从GC Roots到嫌疑对象的那条引用链,基本就是问题代码所在。

3.4 排查顺序的讲究:先看现场、再堆栈、再看GC日志、最后dump

诊断最容易犯的错是“一上来就dump”,好像dump完就万事大吉。实际上dump文件几百MB甚至几个GB,下载、分析都要时间,而且静态堆快照看不到线程状态。我推荐下面的排查SOP,按优先级排:

症状第一步第二步第三步第四步
CPU飙高top -Hp找线程jstack/Arthasthread -n看栈对照业务代码确认逻辑如有必要再JFR抓热点方法
内存飙升/疑似泄漏jstat -gcutil看GC趋势jmap -histo:live看对象分布检查GC日志和OOM日志MAT分析dump确认引用链
GC频繁/停顿长查GC日志jcmd GC.heap_info看分区占比看对象晋升情况用JFR分析分配压力

核心思路是:从代价最低、获取最快的工具开始,逐步升级到代价更高、信息更全的工具。dump放最后不是因为不重要,而是因为它的代价最重。如果前面的步骤已经能定位问题,dump就不是必须的。

4. 调优层:诊断出结论之后,参数怎么改才安全

调优是最容易“上瘾”也最容易“翻车”的阶段。很多人喜欢到处搜“JVM优化参数大全”,复制一堆进启动脚本,结果问题没解决,反而因为变动太多不知道哪个参数生效、哪个参数起了反作用。正确的调优姿势是:诊断有结论以后,一次只改一个变量,并且用监控数据验证。

4.1 堆、GC、日志三组参数的正确打开方式

启动参数里最核心的三组,我给你整理成一个常用模版,按注释分别改:

JAVA_OPTS="-Xms4g -Xmx4g \ -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -Xlog:gc*:/data/logs/gc.log:time,level \ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.hprof"

有几个设计逻辑说下:

  1. -Xms是初始堆,-Xmx是最大堆。我非特殊情况都会建议两者相等,避免运行期堆伸缩带来的停顿和不可预测性。堆伸缩本身也是一个GC触发点,频繁伸缩对性能没有好处。
  2. Metaspace上限一定要设,不然类加载过多时Metaspace会无限膨胀,直接拖垮整个容器。
  3. GC日志路径要固定好,出问题时能立刻找到日志文件。没有GC日志的JVM调优等于闭着眼开车。
  4. 以Tomcat为例,修改catalina.sh里的JAVA_OPTS时要追加而不覆盖,很多同事踩过坑:自己写的新参数把Platform预置参数整个export掉了,导致服务起不来。

4.2 G1和ZGC参数背后要理解的原理

JDK 8默认的Parallel GC在堆大、暂停时间敏感的场景里越来越吃力,所以G1成了老牌默认,JDK 11及以后更是直接默认G1。G1的核心思路是“把堆分成多个Region,优先回收垃圾最多的Region”,所以它尽力控制暂停时间:

-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60

MaxGCPauseMillis是目标暂停时间,不是保证值,只是G1内部调整垃圾回收策略的参考。Region大小默认会根据堆大小自动推算,一般不用手动指定,除非你有特别大的对象,会让大对象Region单独分配。

ZGC则是把“暂停时间”压到极致的另一条路线:

-XX:+UseZGC

它通过染色指针和读屏障做到单次GC停顿毫秒级,但代价是CPU占用更高、对大堆(几十G以上)效果更明显,且配置和排查复杂度也更高。如果你们服务堆只有2-4G,ZGC带来的收益通常不如G1来得实在。

这里必须破除一个迷信:“把Xmx调大”不等于解决Full GC。FGC的根因往往是年轻代对象晋升过快、分配率太高、某个缓存把老年代占满。堆再大,只要业务代码持续制造“朝生夕灭的大对象”,老年代还是会被打爆。换个比喻:仓库再大,如果货运通道天天堵,问题也不是仓库面积能解决的,而是必须处理货物流通方式。

4.3 用JFR和GC日志验证调优效果

调优后怎么判断有效?不是看服务“感觉顺畅了”,而是看数据对比。

我的做法是至少等30分钟到2小时,取同等业务量时段的数据对比:

  • 从GC日志里对比:YGC频率、FGC次数、单次FGC耗时。
  • 从JFR里对比:GC暂停时间分布、分配压力、热点方法的CPU消耗。
  • 从Prometheus/Grafana对比:请求RT、线程池活跃线程数、内存池曲线。

如果改完参数后FGC减少、RT稳住,那说明方向对了。如果FGC依旧频繁,那就回到诊断层重新找原因,而不是继续瞎调参数。调优本质是个“实验迭代”过程,一次只能动一个变量,记录每一次调整的动机和结论,不然最后改成一锅粥,线上出问题都没法回滚。

5. 实战现场:常见坑和我自己的工具使用习惯

很多问题不是工具不强,而是用法不对。这一节把我在生产环境里踩过、见过、教过别人绕开的坑集中捋一遍,也分享一下现在固定下来的工具组合习惯。

5.1 生产环境千万别做的几个操作

有些命令在本地随便跑,到生产环境就是事故级操作。我列一下最典型的雷区:

  1. 高峰期直接jmap -dump。在老版本JDK上可能触发Full GC,高并发下抖动会产生灾难级的RT波动。如果非要dump,优先用jcmd <pid> GC.heap_dump,并且错峰操作,或者借助JFR先记录事件流。
  2. 没有确认GC日志就重启进程。很多线上问题要靠GC日志才能确诊,重启后日志文件被覆盖,现场直接没了。我自己的习惯是:无论什么情况,先备份gc.log、hs_err_pid*.log、OOM dump文件,再谈重启。
  3. 把VisualVM裸连生产JMX端。远程JMX端口一旦开放,没做认证和网络隔离就等于是把JVM内部数据暴露到公网。只能用跳板机加认证,或者干脆用JFR录制后拉回文件再看。
  4. 在容器里用旧版JDK跑Arthas。Arthas需要attach,在容器里极易受限,表现为“找不到进程”或者“attach失败”。先确认容器有没有给/tmp/hsperfdata权限,Java Agent在容器化场景下比Arthas更稳定。

5.2 监控、诊断、调优的组合拳怎么打

工具再多,如果你没有一个稳定的出招流程,还是白搭。我现在的组合拳是:

  1. 日常值班:开Prometheus加Grafana看全局,重点关注jvm_gc_*和堆内存池曲线,不做无目的的观察,而是先记住“哪些是正常基线”。
  2. 收到告警:先jps -lvm和jinfo确认进程和参数,再用jcmd GC.heap_info和jstat -gcutil做快速健康检查,判断是CPU、线程、还是GC问题。
  3. 定位问题:根据症状选Arthas还是JFR。CPU类问题用thread -n 3,内存类用jmap -histo:live和GC日志,再不行开JFR录制几十秒拿热点方法。
  4. 调整验证:每次只改一个参数,改完跑一段稳定时间,用监控面板同趋势对比,记录结果,再决定下一个动作。

这一套流程走下来,绝大多数问题都能在半小时内定位,根本不需要一堆重型APM。

5.3 工具之外的思考:JVM调优的边界

有一点我必须反复提醒:JVM工具能看到的是“结果”,但根因常常藏在代码和外部依赖里。数据库连接没释放、锁竞争严重、线程池参数不合理、第三方接口超时拖垮业务线程,这些从JVM视角看都是内存或线程状态异常,但它们本身不是JVM问题。越是资深的同学,越要先排除业务代码、中间件、基础设施的问题,再安心去调JVM参数。

我自己经历了这么多轮线上排查,最大的转变是从“遇到问题就翻参数调优”变成“先看数据、再找引用链、再对症下药”。JVM参数能解决的只是JVM运行配置和GC策略层面的问题,把Xmx调大、把垃圾回收器换掉,解决不了“该释放的资源没释放”和“调用了不该调用的慢接口”这类业务代码问题。

最后再分享一个实用小技巧:我平时会把gc.log、OOM转储路径、Arthas安装包、JMX Exporter配置散件的完整路径写在一个固定的文档里,每次部署新服务就顺手确认一遍。线上出问题时,人已经很紧张了,再满世界找工具只会手忙脚乱。把这些准备工作做在前头,排查的时候才能把精力集中在问题本身,而不是卡在“工具在哪”这种破事上。

这套武器库看起来东西不少,但真正常用的其实没几个。把监控、诊断、调优三层都跑过一轮,你对JVM运行时状态的数据敏感度自然会建立起来。工具是死的,判断力是活的——先摸清楚每个工具能拿回什么数据,再教它们怎么帮你形成判断,这才是从“会用工具”到“会调优”的分水岭。

返回列表