JVM GC调优三板斧——先诊断、再调参、后验证
背景
政务系统上线后,运维反馈系统偶尔会"卡一下",持续时间不长,但频率不固定。数据库慢SQL排查过了,网络也没问题,服务器资源充足。
这种"说不清道不明"的性能问题,最后往往落在JVM的GC上。
2023年处理过一个类似问题,总结了一套三板斧流程:先用jstat诊断GC原因,再针对性调参,最后验证效果。
第一步:诊断——jstat -gccause 看清GC的"病历"
很多人知道用jstat -gcutil看GC统计,但-gcutil只告诉你频率和耗时,不告诉你为什么触发GC。
真正好用的命令是:
jstat-gccause<pid>1000每秒输出一次GC统计,最后两列是关键:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC 0.00 0.00 68.23 45.67 92.13 88.45 234 2.345 5 6.789 9.134 Allocation Failure No GC 0.00 0.00 12.34 46.12 92.13 88.45 235 2.378 5 6.789 9.167 Allocation Failure No GC 0.00 0.00 78.90 46.89 92.13 88.45 236 2.412 6 8.234 10.646 Allocation Failure Full GC (Metadata GC Threshold)重点看最后两列:
| 列 | 含义 | 怎么读 |
|---|---|---|
| LGCC | 上一次GC的原因 | 最近一次GC为什么触发 |
| GCC | 当前GC的原因 | 现在正在进行的GC为什么触发(No GC表示空闲) |
常见GC原因对照:
| LGCC/GCC的值 | 含义 | 怎么办 |
|---|---|---|
| Allocation Failure | 新生代分配失败,正常YGC | 频率太高就调大新生代 |
| Full GC (Metadata GC Threshold) | Metaspace满了 | 调大Metaspace |
| Full GC (System.gc()) | 代码显式调了System.gc() | 加-XX:+DisableExplicitGC |
| Full GC (Ergonomics) | JDK自适应触发 | 看老年代使用率,可能堆太小 |
| Full GC (Heap Dump Initiated GC) | 做了heap dump | 排查dump的来源 |
| Full GC (Last ditch collection) | CMS/G1老年代回收失败兜底 | 严重了,堆内存根本不够用 |
盯了几分钟,如果发现Full GC频繁且原因明确(比如Metaspace不够),调参就有了方向。
第二步:调参——对症下药
诊断出原因后,针对性调整JVM参数。不要上来就抄网上的"JVM调优最佳实践"——不同系统的GC特征不一样,参数也不一样。
场景一:Allocation Failure过于频繁
说明新生代太小,对象还没用多久就被YGC回收,或者晋升到老年代太快。
# 调大新生代(默认新生代占整个堆的1/3) -Xmn512m # 或者调整新生代与老年代比例 -XX:NewRatio=2 # 新生代:老年代 = 1:2 # 如果大对象直接进老年代,调大这个阈值 -XX:PretenureSizeThreshold=1m场景二:Metaspace触发Full GC
政务系统经常加载大量动态生成的JSP、反射类,Metaspace压力大。
# 调大Metaspace(默认256m,政务系统建议512m~1g) -XX:MaxMetaspaceSize=512m # 同时调大压缩类空间 -XX:CompressedClassSpaceSize=256m场景三:Full GC (Ergonomics) 频繁
说明老年代增长太快,对象存活时间太长或者内存泄漏。
# 调大整个堆 -Xmx4g # 如果用的CMS,可以调整触发老年代回收的阈值 -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly # 如果用的G1,调整暂停时间目标 -XX:MaxGCPauseMillis=200场景四:System.gc()作祟
参考另一篇博客《System.gc()的隐蔽陷阱》,直接加-XX:+DisableExplicitGC。
第三步:验证——调了之后到底有没有用
参数改完不是万事大吉,必须验证。重启后继续盯着jstat -gccause,对比调参前后的数据:
# 调参前记录5分钟数据(导出到文件)jstat-gccause<pid>1000>gc_before.log&# 调参后记录5分钟数据jstat-gccause<pid>1000>gc_after.log&关注这几个指标:
| 指标 | 调参前 | 调参后 | 评判 |
|---|---|---|---|
| FGC(Full GC次数/分钟) | 3~5次 | 0次 | 消除Full GC |
| FGCT(Full GC总耗时) | 累计增长快 | 不再增长 | Full GC消除 |
| YGC频率 | 过高(>10次/秒) | 合理(1~5次/秒) | 新生代分配正常 |
| O(老年代使用率) | 持续增长 | 稳定 | 没有内存泄漏 |
调参成功的标志:Full GC没有了。
一个健康的JVM,Full GC应该极少甚至没有。如果YGC频率高但每次都很快回收(LGCC一直是Allocation Failure,E区没有飙升到90%+),那是正常的,不用管。
实战案例:一体化平台GC调优
2023年给一个政务一体化平台做GC调优,过程如下:
诊断阶段:
用jstat -gccause盯了10分钟,发现:
- YGC每秒3~4次——频率偏高
- 每小时触发2~3次Full GC——LGCC显示
Ergonomics - 老年代使用率每次YGC后都会涨一点,说明对象晋升太快
调参阶段:
原始参数(.tomcat的setenv.sh):
JAVA_OPTS="-Xms2g -Xmx2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m"调优后参数:
JAVA_OPTS="-Xms4g -Xmx4g -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:NewRatio=1 -XX:+DisableExplicitGC"关键改动:
- 堆从2G扩到4G——应用服务器内存够用,何必省
- Metaspace从256M扩到512M——政务系统JSP和动态类多
- 新生代比例从1:2调到1:1——大量短生命周期对象在新生代就回收掉
- 禁止System.gc()——防止第三方库触发无谓的Full GC
验证阶段:
调参后运行一周,jstat -gccause显示:
- YGC降到每秒1~2次
- FGC次数:0——Full GC消失了
- 老年代使用率稳定在35%左右,不再持续攀升
系统再也没有"卡一下"的现象。
常见误区
误区一:一上来就调G1
有些文章说"G1比CMS好,生产环境应该用G1"。不一定。如果你的堆不大(<8G),CMS或者甚至Parallel GC就够了。G1的目标是低延迟大堆,小堆用G1反而开销更大。
# 4G以下的堆,Parallel GC可能就够了(JDK 8默认) # 4G~16G,CMS或G1都可以 # 16G以上,推荐G1误区二:把Xms和Xmx设成不一样的
# 不好:Xms2g -Xmx4g # 好:Xms4g -Xmx4gXms和Xmx不一样,JVM运行过程中会动态扩缩堆,扩容本身要Full GC来整理内存。既然服务器内存够用,就让它一开始就分配满,省去扩容的开销。
误区三:调了参数不管
见过有人在测试环境调了参数,发现"好像好一点"就上线了。没有量化数据支撑的调优等于白调。一定要用jstat -gccause前后对比,拿数据说话。
命令速查
# 诊断GC原因(最有用)jstat-gccause<pid>1000# 查看GC统计概览jstat-gcutil<pid>1000# 查看详细GC统计(含各区域大小)jstat-gc<pid>1000# 查看JVM当前参数jinfo-flags<pid># 查看堆内存分布jmap-heap<pid># 导出heap dump分析内存泄漏jmap-dump:format=b,file=heap.hprof<pid>总结
JVM GC调优不神秘,就三步:
- 先诊断:用
jstat -gccause看清GC原因,不要猜 - 再调参:对症下药,不要抄网上的"万能参数"
- 后验证:用数据对比调参前后效果,不要凭感觉
政务系统的JVM问题,80%是这三个原因:堆太小、Metaspace不够、System.gc()作祟。把这三个排查完,基本就解决了。
感谢豆包、智谱、OpenCode在写作过程中的辅助。