1. 从一次“重启解决”说起:JVM调优到底在调什么
做后端服务的人应该都有过这种经历:某个接口白天还好好的,一到晚高峰RT就开始往上飙,CPU持续打满,偶尔还来一次Full GC把整个服务卡死几十秒。一开始大家都习惯先重启一下,重启完确实“恢复”了,但第二天同样的问题又来了。这时候你才会意识到,光靠重启解决不了问题,得真正去做JVM调优。
我见过太多人把JVM调优理解成“搜一堆参数抄上去”,比如直接加上-Xms4g -Xmx4g -XX:+UseG1GC就觉得调完了,实际上这连入门都不算。JVM调优的核心不是参数本身,而是一套定位问题、量化目标、验证效果的思路。参数只是手段,诊断才是关键。这篇文章我不会给你堆一长串用不上的配置项,而是把我自己这几年做Java服务性能排查的经验整理成一套可复用的流程:先明确调优目标,再做监控数据采集,然后针对不同现象分类排查,最后才是参数的落地与验证。适合正在处理线上服务卡顿、CPU飙高、频繁Full GC的同学参考,也适合想系统建立调优方法论的后端开发。
先说一句很重要的结论:大多数JVM性能问题,根源不在JVM本身,而在代码、依赖和资源配置。调优只是最后一公里,如果你上来就改参数,通常只会把问题掩盖掉。
2. 调优之前,先想清楚这三个目标
2.1 稳定性、吞吐量与延迟,你想要哪个
JVM调优的第一个步骤不是打开终端敲命令,而是回答一个问题:你的服务当前最痛点是什么?
不同的业务场景,调优方向截然不同。我通常把调优目标分成三类:
- 稳定性优先:服务不能OOM、不能长时间Full GC停顿、不能频繁宕机。典型场景是交易系统、网关、长连接服务。这类目标要求堆内存设置保守、GC策略选择尽量可预测。
- 延迟优先:单次请求RT要低,不能有大的毛刺。典型场景是实时推荐、在线搜索、RPC调用链中的核心节点。这类目标要求尽量减少GC停顿时间,甚至可以考虑低停顿收集器。
- 吞吐量优先:单位时间内处理的请求量要尽量大,对单次RT不敏感。典型场景是离线批处理、日志分析、大数据计算。这类目标反而可以接受较长的停顿,换取更高的整体处理效率,
Parallel系列收集器就够用。
很多人一上来就说“我要低延迟”,但实际业务是凌晨跑批任务,那方向就完全拧了。先定目标,再看工具,最后才谈参数。
2.2 把“卡顿”翻译成可量化指标
如果你去问业务方“服务怎么了”,得到的回答多半是“很卡”“变慢了”,这种描述没法指导调优。你得把这些模糊感受翻译成可监控的量化指标:
- 接口RT的P99、P95变化趋势,以及GC导致的暂停是否与RT毛刺重合。
- GC频率和耗时:Young GC多久一次、Full GC多久一次、单次STW(Stop-The-World)最长多少毫秒。
- 堆内存使用水位:老年代占用是否持续爬坡、GC后是否回不到低位。
- 线程状态与CPU消耗:是业务线程在执行计算,还是GC线程在占用CPU。
我的习惯是先把核心接口的TP99打点、GC日志、堆内存监控三个数据源齐了,再开始动手。没有这三样数据就调参,跟闭着眼睛开车没什么区别。
3. 先诊断,再动手:监控与数据采集是调优的地基
3.1 必配的GC日志参数,能开一定要开
很多生产环境没开GC日志,或者开了但没配好格式、没做日志切割,等到问题发生时什么都查不到。这属于典型的“平时嫌麻烦,出事了干瞪眼”。建议在启动参数里一定要加上:
-Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100m这是JDK 11+的写法,JDK8则用:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps加-XX:+PrintGCDateStamps特别关键,它能让你把GC时间和业务监控的报错时间对上——否则你只知道发生了GC,不知道它和线上故障是否有因果关系。日志文件的滚动策略也要配好,不然跑几天磁盘就被写满了。我在实践中吃过这个亏,日志把数据盘写爆,比GC本身还严重。
3.2 常用命令行工具:jstat、jmap、jstack的正确打开方式
jstat是看GC动态的第一选择,最常用的是:
jstat -gcutil <pid> 1000 10每秒输出一次,连续10次,能看到各区使用率和GC次数/耗时。如果老年代FGC次数持续增长、FGCT不断变大,说明Full GC在频繁发生。
jmap用来查看堆内存快照。jmap -heap <pid>可以看各代空间大小与使用率,而jmap -dump:format=b,file=heap.hprof <pid>导出堆转储文件,然后用MAT或VisualVM分析对象引用链。生产环境做dump要谨慎,大堆应用会导致服务停顿甚至卡死,建议在低峰期操作。
jstack用来抓线程栈,主要应对CPU飙高、线程死锁、线程池耗尽等问题。抓线程栈时有个细节:要连续抓几次,间隔3-5秒,对比不同时间点的线程状态,才能判断线程是真的卡死还是在正常执行某个较长任务。
这些JDK自带的工具在任何环境都能用,没有额外依赖。执行前记得用top -Hp <pid>先看一眼哪个线程吃CPU,然后再把线程ID转成十六进制去jstack里搜索,能直接定位到出问题的业务代码行。
3.3 线上监控平台,不要全靠人肉排查
人肉敲命令适合个别节点排查,但集群规模一大就不现实了。我服务里都会接一套JVM监控指标体系:堆内存使用、GC次数与耗时、老年代占用、活跃线程数、类加载数量,通过Prometheus那套标准exporter暴露指标,落到Grafana看板。这个动作建议在新服务上线时就做好,而不是等到出故障再补。
监控看板还有一个作用:沉淀基线数据。比如你知道正常情况下老年代占用在2GB到3GB之间波动,GC后能回到1.5GB。某天它突然降不下来了,你立刻就能察觉异常,而不是等Full GC把服务拖垮了才发现。
4. 分类排查:CPU、堆内存、GC与线程各自的路径
4.1 CPU飙高:先查业务线程,别急着怀疑GC
CPU占用100%常见有三种原因:业务代码死循环或计算密集、GC线程过于频繁、线程争抢自旋。我以前遇到一个案例,某服务CPU常年80%以上,运维第一个反应是“GC太频繁了”,加了一堆GC参数毫无效果。后来抓了线程栈才发现,是某个定时任务在遍历一张不断膨胀的本地缓存,每次全量扫描都消耗大量CPU。代码问题,不是JVM问题。
排查路径应该是:top看进程CPU →top -Hp <pid>看线程CPU → 找出最耗CPU的线程TID → 转十六进制 →jstack <pid> | grep -A 50 <十六进制TID>→ 看栈顶在跑什么。整个过程不到一分钟,比盲目调参高效得多。
4.2 频繁Full GC:区分是内存泄漏还是内存压力
Full GC频繁,首先要回答一个问题:GC之后老年代能降下来吗?
- 能降下来,说明对象大多是“朝生夕死”,只是流量太大导致对象分配速率过高——这时候考虑增大年轻代、调整晋升阈值,或者优化业务代码减少对象创建。
- 降不下来,占用水位越来越高,那大概率存在对象泄漏——调参是没用的,必须抓堆dump分析谁在持有这些对象。
我记得有一次排查一个Full GC每隔几分钟来一次的服务,dump完堆发现大量的对象实例被一个静态Map持有,而Map的key是请求ID,value是请求上下文,只放不删。定位到根因后一行代码就修复了,而之前团队在GC参数上折腾了一周。这个案例我每次讲调优都会提,堆外排查永远排在堆参数前面。
4.3 线程池排队与阻塞:JVM之外的隐藏瓶颈
还有一种情况非常迷惑:GC一切正常、堆内存也健康,但接口RT就是高。这时候要把思路切到线程维度。用jstack看线程状态,如果大量业务线程处于WAITING或BLOCKED状态,说明锁竞争或连接池等待是主要矛盾。常见的是数据库连接池打满、HTTP客户端连接没释放、或者某个分布式锁的竞争太激烈导致线程排队。
这类问题JVM调优完全插不上手,正确解法是扩容连接池、优化锁粒度、减少外部调用耗时。判断一个问题是JVM问题还是业务问题,是调优里最重要的一步判断。
4.4 GC日志怎么看,重点关注什么字段
刚开始接触GC日志的同学容易懵,一堆[GC (Allocation Failure) [PSYoungGen: 1234K->456K(2048K)]看起来非常劝退。其实只需要关注几个关键信息:
- 发生了什么GC:是Young GC还是Full GC,原因是什么(
Allocation Failure表示年轻代分配失败)。 - 各区域GC前后变化:看内存释放量是否正常。
- 耗时:后面的
real=0.05 secs就是STW时间。
我习惯把GC日志按天拉出来算一个均值:Young GC平均频率、Full GC次数、单次最长STW。然后和监控看板的RT毛刺对比,如果发现RT突刺的时间和Full GC时间高度重合,那基本实锤了GC停顿的影响,接下来才进入参数调优阶段。
5. 参数调整的正确姿势:从堆布局到收集器选择
5.1 堆内存大小的合理设置,不是越大越好
很多人有个误区:内存越大,性能越好。实际上堆内存过大可能适得其反:单次GC扫描的区域更大,STW时间更长,而且留给操作系统的内存变少,甚至触发系统级别的swap。更合理的思路是让堆内存“刚刚好”——能容纳业务活跃对象,又有一定余量应对流量峰值。
一个务实的经验是:-Xms和-Xmx设置成相同值,避免运行时动态扩容带来的额外开销。具体大小参考线上QPS和对象分配速率,通常从“当前峰值堆使用量的1.5-2倍”起步观察。如果服务正常运行时老年代峰值占用3GB,堆max设成4-5GB比较合理,给Full GC前留出足够空间缓冲。
堆总是不够时的第一反应不应该是调大-Xmx,而是看有没有非必要的缓存对象一直驻留在老年代。本地缓存能移到堆外就移到堆外,能不缓存就不缓存。
5.2 年轻代与老年代比例:观察对象晋升情况再定
年轻代太小,对象频繁晋升到老年代,老年代很快填满触发Full GC;年轻代太大,GC时间变长,而且晋升阈值变得不好控制。JDK8默认的-XX:NewRatio=2意味着老年代是年轻代的2倍,这个比例对很多业务不一定合适。
实操思路是:先让年轻代占整个堆的1/3左右观察一段时间,通过jstat看Minor GC后存活对象的大小。如果每次Young GC后还有大量对象晋升老年代,说明年轻代装不下这些活跃对象,可以尝试调大年轻代或提高晋升阈值-XX:MaxTenuringThreshold(范围0-15)。如果Young GC特别频繁但每次回收得都很干净,说明分配速率高但对象生命周期短,这时候想办法减少代码里的对象创建比调参更有用。
5.3 G1还是ZGC:按业务容忍度选收集器
JDK 11+的默认收集器就是G1,大多数互联网业务用G1没什么问题。G1的核心优势是把堆划分为多个Region,用-XX:MaxGCPauseMillis控制目标停顿时间。我这里强调一下:这个参数是“尽量达到”的目标,不是硬性保证。你设成50ms,它会在吞吐量和停顿之间做权衡,极端情况下依然会超时。
如果业务的RT敏感度极高,比如要求P99.99都在几十毫秒以内,那G1可能还不够,可以考虑ZGC(JDK 15+转正)或Shenandoah。ZGC的停顿时间几乎不随堆大小增长,在几十GB大堆上也能保持亚毫秒到毫秒级停顿。不过ZGC的代价是CPU占用更高,需要多核机器才划算。收集器的选型不是越新越好,而是匹配业务的优先级。
说到线上参数落地,我提供一个相对稳妥的G1基线配置(基于JDK 11+,16GB堆以内):
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -XX:MaxTenuringThreshold=6 -XX:+ParallelRefProcEnabled -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m这个配置适合多数常规微服务,跑起来后通过监控数据迭代调整MaxGCPauseMillis和堆大小。不推荐一上来就上一堆冷门参数,每一个参数都要有监控数据支撑才有意义。
5.4 其他容易被忽视但关键的参数
-XX:+HeapDumpOnOutOfMemoryError:OOM时自动导出堆dump,配-XX:HeapDumpPath指定路径。这个建议无条件加上,OOM时手里有了一份排查材料,不至于干瞪眼。-XX:+ExitOnOutOfMemoryError:OOM后直接退出进程,让容器编排系统自动重启实例,比半死不活地卡在那里好。对无状态服务尤其推荐。-Djava.security.egd=file:/dev/urandom:解决某些环境下SecureRandom初始化慢的问题,听起来冷门,但真能遇到启动卡顿数秒的情况。
参数调整有一个铁律:一次只动一个变量。改完一个参数,在监控上看两天数据,再决定下一步。全部参数一起改,出了问题你根本不知道是哪一行配置惹的祸。
6. 调优路上的常见误区与避坑指南
6.1 只看GC参数,不看代码和依赖
这个我前面反复强调过,因为太重要了。JVM调优的终点是代码,不是参数。大多数性能问题的根因在业务代码、第三方依赖、中间件配置上。参数调整只是优化手段,不是万能药。你花了大量时间去调GC参数,不如花同样的时间去分析代码里有没有创建大量短期对象、有没有无效的深拷贝、有没有大循环里的字符串拼接。
6.2 在测试环境“调好了”就直接上生产
测试环境的流量特征和生产环境差得远,测试环境GC表现正常不代表生产就没问题。我在测试环境遇到过配置跑得挺好、一上线就频繁Full GC的情况,原因就是生产的QPS是测试环境的几十倍,对象分配速率完全不同。
正确的做法是:先在压测环境模拟生产流量做调优验证,再灰度上线一小批节点,观察监控数据稳定后再全量。线上调优不可怕,可怕的是没有灰度意识,直接一波推全量。
6.3 把GC日志关掉来“提升性能”
确实有些团队觉得GC日志写入有IO开销,干脆在生产环境关闭GC日志。短看似乎“省了”一点资源,但牺牲的是整个可观测性。GC日志占用的磁盘空间和IO极小,和故障发生后两眼一抹黑的代价完全不成比例。用filecount和filesize做日志轮转,控制好总大小,那点IO损耗完全可以忽略。
6.4 用“窗口期观察”代替“一次调完”
调优是个循环过程,不是一次性工作。每次参数调整后都要回到监控平台看数据:FGC次数降了没、STW时间短了没、RT毛刺消失了没。调完一个参数,观察24小时,记录前后对比,再进入下一轮。我自己有一个踩过很多次的坑——连续调了好几个参数,结果某一项指标恶化,回头排查到底是哪个参数引起的,折腾了半天。后来严格坚持“一次只动一个变量”,效率反而高很多。
7. 一个完整的调优实例复盘
最后分享一个我前段时间处理过的线上案例,给整套思路做个串联。
某核心服务,大概4核8GB的容器规格,跑着G1收集器,偶发Full GC,每次停顿800ms到1.5秒,导致部分下游调用超时。接手后我没有急着改参数,先拉了三天的监控数据:
- 堆分配8GB,但老年代日常占用只有2.5GB左右,GC后能降到1.8GB。
- Full GC大概每小时3-4次,每次STW最长1.2秒。
- 线程栈显示业务线程大多花在JSON序列化和日志打印上。
第一步判断:这不是内存泄漏,老年代能回收,但Full GC频率还是偏高。第二步排查:为什么老年代增长这么快?通过jstat -gcutil观察,Young GC后晋升到老年代的对象量明显偏大,单次晋升约200MB-300MB。进一步分析发现,某些接口每次请求都会创建一个大尺寸的缓存对象,存活时间比较长,很容易绕过年轻代直接进老年代。
第三步调整:做了两件事。一是调整年轻代占堆比例,从默认的1/3扩大到40%,让更多短生命周期对象只停在年轻代就被回收;二是配合提升晋升阈值-XX:MaxTenuringThreshold=8,让对象在年轻代多经历几次GC再考虑晋升。两个参数分两次调整,中间各观察两天。
结果是Full GC从每小时3-4次降到每天1次以内,STW最长不超过300ms,接口P99从之前的800ms降到了200ms以内。整个过程中堆总大小一直没动,问题不在堆不够大,而在于对象的分布和晋升路径不合理。
这个案例后来我和团队复盘,最大的教训是:如果一开始直接把-Xmx从8GB调到12GB,可能也能缓解症状,但容器内存不够,反而会引入其他问题。只有顺着监控数据找到晋升异常的链路,才真正解决了根因。
JVM调优这条路,工具命令两天就能学会,难的是建立一套自己的排查思考路径。你踩过的每一个坑,都会成为下一次快速定位问题的经验。希望这篇思路整理,能让你在下次面对“服务又卡了”的时候,知道从哪里下手,而不是先重启为敬。