1. 从一个“卡死”的下午说起:我为什么开始认真做GC优化
先说个亲身经历。有一年我在负责一个订单履约系统,平时跑得好好的,结果一到午高峰就频繁报警:接口P99延迟从50ms直接飙到800ms,CPU居高不下,老年代GC每一轮都长达1.5秒。更离谱的是,局部Full GC把整个应用卡到健康检查超时,容器被反复重启,用户侧就是“转圈圈”、“按钮没反应”。我一开始怀疑是数据库慢查询,结果DBA告诉我数据库CPU才20%,锁等待也很低。折腾了两个小时,才想起来去看GC日志——好家伙,定时任务一次性扫了几十万条数据,把老年代直接塞爆了,CMS根本来不及回收,只能Full GC兜底。
那次事故之后,我才意识到一个事实:大部分应用系统的问题,表面上是“慢”,根子里是——内存管理失控,GC被拖垮了。从那以后,我养成一个习惯,凡是上线前的压测评审,必看GC曲线;凡是线上有莫名其妙的长尾延迟,第一件事就是拉GC日志。GC优化不是“调几个JVM参数再说”,而是一套从观测、分析、定位到参数调整的系统工程。
这篇文章我不打算上高大上的理论课,而是从一次典型的线上抖动排查入手,把手头积累的GC问题排查方法、参数调整思路、工具选型经验全部捋一遍。适合谁看呢?正在做Java服务性能调优的开发者、被线上Full GC折磨过的运维同学,以及那些刚接手系统、想建立一套“GC问题排查SOP”的后端工程师。
2. GC不是玄学:先把基础模型和关键术语白话一遍
很多人一看到GC(Garbage Collection,垃圾回收)就头大,因为名词太多,什么Young GC、Old GC、Full GC、Mixed GC,还有Parallel、CMS、G1、ZGC。网上资料天花乱坠,但落到自己的项目里,其实没那么复杂。先理清几个最基础的概念,后面讲的排查思路才接得上。
2.1 堆内存划分:年轻代、老年代到底在干什么
JVM的堆内存基本上可以分成两大块:年轻代(Young Generation)和老年代(Old Generation)。年轻代又细分为Eden区、两个Survivor区(S0/S1)。新创建的对象先在Eden区“孵着”,Eden满了就触发Minor GC(也叫Young GC),活下来的对象挪到Survivor区来回倒腾,达到年龄阈值之后晋升到老年代。
为什么要分这么细?因为绝大多数对象“朝生夕死”,比如一个HTTP请求里创建的中间对象,通常在方法栈帧退出后就没用了。把这种短命对象隔离在年轻代,GC时只需要扫描一小块区域,成本极低。真正要小心的是那些“漏网之鱼”——本该死掉的对象被无意当中引用住,一直在Survivor区倒腾,最终晋升到老年代。老年代一多,回收成本就上去了,还会引发更严重的连锁反应。
老年代的GC策略直接决定了服务的稳定上限。以前的老项目喜欢用CMS(Concurrent Mark Sweep),它就是“尽量不停顿地标记清除”的代名词。但CMS有个著名的毛病:浮动垃圾多、内存碎片化严重,一旦发生Concurrent Mode Failure,就会退化成本地冻结所有业务线程的Full GC。这块我后面结合故障案例细讲。
2.2 每次“卡顿”的本质:Stop The World
不管什么回收器,有一件事是绕不开的——STW(Stop The World,全局暂停)。GC本身要移动对象、压缩内存、清理引用,而这些操作会临时修改堆内存里的对象状态,如果业务线程还在同时读写这些对象,内存直接乱套。所以JVM只能在某个时刻暂停所有业务线程,让GC线程把活干完再放行。
年轻代GC因为区域小,STW时间通常只有几毫秒到几十毫秒,看起来没那么夸张。但老年代GC、Full GC动辄几百毫秒甚至秒级,这个卡顿对上线上高并发接口就是灾难。所以“GC优化”本质上是在做一件事:尽量减少STW的频次和单次时长,让GC停顿对业务的影响变得可控、可接受。
2.3 不同回收器怎么选:G1、CMS、ZGC的适用场景
这么多年用下来,我的选择逻辑很简单:
- 如果堆内存4G以内、追求最大吞吐量,用Parallel GC(JDK8默认)最简单粗暴。
- 如果堆内存4G以上、停顿敏感,以前常用CMS,但JDK9之后官方已经弃用,现在基本都迁移到G1。
- 如果堆内存几十G、上百G,对停顿有极苛刻要求(比如几十毫秒以内),ZGC和Shenandoah是方向。
G1是现在的主流。它把堆分成一个个Region,背后维护一个全局的“优先级列表”,哪边垃圾最多、回收收益最大,就先回收哪边。所以G1叫“Garbage First”,翻译过来就是“垃圾优先”。它的设计目标是让停顿时间可控,比如你设置-XX:MaxGCPauseMillis=200,G1会尽量控制在200ms以内。但注意,这只是目标,不是绝对保证,很多参数调不好反而会适得其反。
2.4 高频出现的“GC(G1 Old, All)”到底在说什么
网络热搜里有个词“gc(g1 old,all)”,很多人第一次看到会懵。其实这是G1回收器的日志分类写法:
G1 Young (Normal):正常的年轻代GC,G1会把Eden区和部分Survivor区一起回收。G1 Old (Mixed):混合回收,G1在回收年轻代的同时,把老年代里“垃圾占比最高”的那部分Region一起收掉,所以叫Mixed GC。G1 Old (All)或者G1 Full:表示老年代全面回收,通常意味着G1的增量回收跟不上对象分配速度了,只能全部扫一遍,代价非常大。
我后来排查线上问题时,看到GC日志里出现了G1 Old (All),心里大概就有数了:不是堆太小,就是有大对象竖着进老年代,或者内存泄漏在持续制造存活对象。这些场景后面会逐个拆。
提示:日常看GC日志,遇到
G1 Old (Mixed)不用太紧张,它只是G1正常的混合回收节奏;但如果你观察到G1 Old (All)出现频率很高,且每次耗时都在几百毫秒以上,就要认真查内存分配路径了。
3. 典型故障复盘:一次由“大查询”引发的Full GC风波
理论归理论,用代码层面的事故来佐证才有说服力。下面这个案例我是把多种场景浓缩到一起,但每一个环节都是我真实踩过的坑,很多公司并发稍微上来一点就会复现同样的问题。
3.1 事故现象与初步猜测
背景是一个库存服务,Deploy到两节点,每节点分配4G堆内存,用的JDK8默认Parallel GC。某天大促预热流量一上来,运维收到告警:接口超时率超过15%,老年代内存占用呈现“锯齿状”冲到顶,GC日志里Full GC出现频次高达每5分钟一次,单次耗时平均2.3秒。
我当时的第一反应是:“这不就是堆内存给小了嘛,扩容呗。”但仔细一想不对,这个服务平时压测2000QPS都能稳住,突然变了,肯定是有个别的“大头”进入内存了。于是赶紧上Arthas去看内存里的对象分布。
3.2 定位过程:Arthas + MAT 两步走
第一步,用Arthas的dashboard命令看堆内存整体画像,再用memory命令查各区域用量。当时看到的情况是:Eden区一直在疯狂增长,老年代却已经占到了3.4G,配额总量只有2.6G——因为Parallel GC的老年代默认是堆的一半。然后我执行了heapdump把堆快照拉下来,用MAT(Memory Analyzer)分析。
MAT打开快照之后,看Histogram和Leak Suspects。果然,有个ProductSkuDTO类型的对象居然有上百万个实例,占掉了2.1G内存。顺藤摸瓜一看,是某个商品详情批量接口,在查询SKU列表时一次性select *把整个商品表的全字段都捞了出来,然后批量封账成DTO,再丢进一个被Spring Singleton持有的本地缓存Map里。这个Map还设置了“永不过期”,只在收到MQ消息时才清理。
问题链条一下就通透了:大批量请求触发大批量查询,大批量DTO进入缓存且没有淘汰策略,老年代直接被打爆;老年代GC跟不上分配速度,最终退化Full GC。而且因为每个节点都在做同样的事情,重启还会“热缓存重建”,把事故进一步放大。
3.3 修复动作:不是只改参数,而是先改代码
这种问题靠调GC参数是救不回来的,纯靠堆内存硬扛只会越扛越贵。我当时做了三件事:
- 在MQ消费端增加全量商品的版本号校验,SKU查询接口只缓存“高频热点商品”的概要信息,并且给缓存加了一个定时批量清理的淘汰策略,保证缓存长度不超过2万条。
- 把批量查询从“全字段全量”改成“字段裁剪”,产品只需要名称、图片、价格,就只查这三列,SQL侧省掉大量中间对象。
- JVM参数上也做了配合:换到G1回收器,
-XX:MaxGCPauseMillis=200,同时把-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent做了限制,避免年轻代无限变大,导致老年代被挤压。
这样改完再压测,Full GC消失了,老年代稳定在1.2G左右,接口P99从800ms降到90ms。这里最大的教训是:GC优化要先从代码和架构层面找“内存黑洞”,不要一上来就改参数。参数只是兜底,代码不收敛,改什么都白搭。
3.4 一次事故带来的排查SOP
从那以后,我给自己定了一个固定动作:遇到线上GC问题,严格按以下顺序排查。
- 先看GC日志:统计Full GC频率、单次耗时、GC前后堆内存变化。
- 再看堆内存快照:用MAT或者JProfiler找大对象、排查泄漏。
- 然后看线程栈:用Arthas的
thread -n 3看CPU最高的线程在干什么,大概率能抓到请求入口。 - 最后结合业务代码确认:是缓存设计问题、批量查询问题,还是本地缓存没有淘汰策略。
- 修复完上线前,用压测流量再观测一轮GC曲线。
这套SOP看起来很简单,但真能坚持下来的人不多。很多人第一步都没走完就急着调参,结果越调越乱。
4. GC日志在说什么:手把手教你读一遍关键输出
GC日志是排查GC问题的最基础输入。以前JDK8时代靠-XX:+PrintGCDetails -XX:+PrintGCDateStamps打印到文件里,现在JDK11+用-Xlog:gc*:file=/path/gc.log。关键是日志拿到手,你要看得懂那几行英文背后代表什么。
4.1 读一行Young GC日志
我拿一段典型的Parallel GC日志举个例:
2025-03-10T12:00:01.123+0800: 1234.567: [GC (Allocation Failure) [PSYoungGen: 1048576K->102400K(1228800K)] 1048576K->512000K(4096000K), 0.0212345 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]拆开来看:
2025-03-10T12:00:01.123+0800:这是触发时间点。1234.567:JVM启动到当前时间的秒数。GC (Allocation Failure):因为年轻代没有足够空间分配新对象,才触发这次GC。PSYoungGen: 1048576K->102400K(1228800K):年轻代从1024M降到100M,总容量是1200M。1048576K->512000K(4096000K):整个堆内存从1024M降到500M,堆总量是4G。注意,这里堆总量从“满”降到一半,说明年轻代回收之后,有部分对象还“活着”被晋了老年代。0.0212345 secs:本次GC花了21ms左右,这个停顿还在正常范围。
读GC日志的关键不在于记住每个字段,而在于关注一个比值:GC前后整个堆的内存下降了多少。如果每次Young GC之后,堆内存几乎没降多少,说明Survivor区扛不住,对象频繁晋升老年代,这很可能触发后续的老年代GC。
4.2 读一行G1日志
G1的日志格式更复杂一点,但核心信息是“分Region回收”。比如:
2025-03-10T12:00:01.456+0800: 2345.678: [G1Ergonomics (Mixed GCs) continue, ...] [GC pause (G1 Evacuation Pause) (young), 0.0456789 secs] [Eden: 512.0M(512.0M)->0.0B(512.0M) Survivors: 16.0M->16.0M Heap: 2.5G(4.0G)->1.2G(4.0G)]这个信息里,G1 Evacuation Pause (young)表示这是一次年轻代“疏散暂停”;Eden从512M清到0,堆内存从2.5G降到1.2G,耗时45ms。如果看到g1 G1 Old (All)之类的字样,就意味着是整堆级别的全量回收,行动级别就要提级了。
4.3 一张速查表:GC日志高频术语对照
| 日志关键词 | 含义 | 风险等级 |
|---|---|---|
| Allocation Failure | 年轻代分配失败触发GC | 低,正常现象 |
| Promotion Failure | 晋升老年代失败,通常是老年代空间不足 | 高 |
| Concurrent Mode Failure | CMS/G1并发标记赶不上对象分配速度 | 高 |
| Full GC | 整堆回收,涉及老年代 | 高 |
| G1 Evacuation Pause (young) | G1年轻代疏散暂停 | 低 |
| G1 Evacuation Pause (mixed) | G1混合回收,包含部分老年代Region | 中 |
| G1 Old (All) | G1老年代全面回收 | 高 |
| System.gc() | 业务代码主动触发GC | 视上下文而定 |
这个速查表我平时就贴在工位旁边,看到日志关键词基本就能判断下一步该做什么。
5. 参数优化实操:从默认参数到“调好为止”的完整过程
很多人上来就问:“线上G1到底该配什么参数?”这个问题的正确答案是“看你的业务场景”。但作为经验参考,我可以给一套比较通用、比较稳的配置基线,并讲讲每个参数背后的原则。
5.1 一套比较稳的G1启动参数
假设每个实例堆内存8G,目标是接口P99 < 200ms,GC停顿尽量控制在100ms以内。我经常用的模版是:
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=2 -XX:G1HeapRegionSize=8m -XX:+AlwaysPreTouch -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m说下每个参数的设计意图:
-Xms8g -Xmx8g:初始堆和最大堆相等,避免运行时动态扩容带来的额外开销。这个几乎是线上必备。-XX:MaxGCPauseMillis=200:G1会以这个为停顿目标制定回收计划。注意别设太小,比如50ms以下,G1会为了凑目标拼命增加回收频率,反而降低吞吐量。G1NewSizePercent=5、G1MaxNewSizePercent=60:年轻代占比的上下限。默认情况下G1的年轻代大小是动态的,但是为了不让它“放飞自我”把老年代挤没了,我会给它一个明确边界。MaxTenuringThreshold=15:晋升老年代的年龄阈值。G1默认是15,但实际动态调整时经常到不了15,这个参数看情况放宽。G1HeapRegionSize=8m:Region大小。堆8G时默认Region大小是4M,可以调成8M减少Region数量,对大对象分配有好处。但一定要小于大对象阈值(1M),否则大对象分配会走另外的路径。AlwaysPreTouch:启动时把堆内存全部“压实”,避免运行时触发操作系统页分配导致抖动。这个对降低长尾延迟有帮助,但会增加启动时间。- GC日志输出:建议保留至少10份,每份100M,这样能回溯几天的GC历史。
注意:参数不是越多越好。有些参数是调试时才加的,比如
-XX:+PrintHeapAtGC、-XX:+UnlockDiagnosticVMOptions -XX:+G1SummarizeConcMark,平时线上别开,日志量爆炸,性能和存储都受不了。
5.2 参数调整的核心原则:先看对象速率,再调空间比例
我见过太多人调参失败,原因只有一个:不看对象分配速率,上来就乱改年轻代大小。
举个例子,如果你把到小年轻代调大,短期内Young GC的触发频率会降低,但每次STW的时间反而更长,因为要回收的Region更多;如果你把年轻代调小,GC频率上去了,但STW时间短,对象晋升得更快,老年代压力大了。这需要一个平衡点。
我最常用的分析方法是这样:用jstat -gcutil <pid> 1000连续观察1分钟,记录E(Eden)、O(Old)、YGC、YGCT、FGC、FGCT这些指标。算一下:
- Young GC平均频率 = YGC总数 / 观察时长。
- Young GC平均耗时 = YGCT / YGC总数。
- 老年代增长率 = (观察结束Old - 观察开始Old)/ 观察时长。
如果你的Young GC频率特别高(比如每秒好几次),且每次耗时很小,那就是对象分配过快,重点是优化业务代码减少创建对象,而不是调参数;如果Young GC频率正常,但老年代以肉眼可见的速度往上走,就要查是不是有大对象、缓存或者内存泄漏。
G1下还可以进一步看jstat -gccause,它会告诉你上次GC的原因,是Allocation Failure还是Metadata GC Threshold,帮助定位触发因素。
5.3 一个真实参数调整前后的对比
说回我在库存服务上的一次调整。最初用的是Parallel GC,默认4G堆,启动参数只有-Xms4g -Xmx4g。压测20分钟,Young GC平均耗时18ms,Full GC总共出现4次,最长一次2.1s。我把参数改为G1,并调整了新老年代比例和停顿目标,跑了同一轮压测:
| 指标 | Parallel默认 | G1 + 调整后 |
|---|---|---|
| Young GC次数 | 135 | 89 |
| Young GC平均耗时 | 18ms | 12ms |
| Full GC次数 | 4 | 0 |
| 最大STW | 2.1s | 160ms |
| 吞吐量 | 89% | 93% |
对比很明显:GC总时间变低、最大停顿从秒级降到百毫秒级、吞吐量还有小幅提升。这说明对于8G以内的应用,如果抖动敏感,G1往往比Parallel更合适;但对于一个“批处理型”任务,Parallel反而吞吐量更高。参数好坏永远脱离不了业务场景。
5.4 警惕“缓存型”系统的隐性问题:大对象直接进老年代
还有一种很常见的坑:本地缓存虽然不大,但存的都是“大对象”。比如你缓存了一个几MB的图片Base64字符串,或者缓存了一个很大的配置List。G1对超过Region大小一半的对象会直接分配到老年代的Humongous对象区域,不经过年轻代。这类大对象如果是“常驻”的,没问题;如果频繁更换,老年代里会堆一大堆废弃大对象,回收非常麻烦。G1做并发垃圾回收时,要把这些Humongous对象拷走或清理,代价比普通对象高很多。
遇到这类问题,我的建议很直接:一是尽量不要把大对象缓存到堆内,可以考虑堆外缓存或者改存对象引用;二是如果一定要在堆内,把G1HeapRegionSize适当调大,比如16M、32M,避开Humongous阈值,让大对象当普通对象走Region分配。但调大Region会增加扫描成本,也要权衡。
6. 工具链选型:从jstat到Arthas,再到MAT,谁说监控没用
很多人问我:“GC问题排查到底该用啥工具?”我的回答是:先学会免费的,再根据场景上收费的。不要一上来就堆全家桶,工具太多反而不知道看哪块。
6.1 jstat:最轻量的五分钟快检
jstat是JDK自带的,适合快速瞄一眼。常用命令:
jstat -gcutil <pid> 1000 30它会每秒打印一次GC利用率概要,30次之后停下来。重点看E、O、YGC、YGCT、FGC、FGCT几个字段。如果FGC在持续增长,且FGCT增幅巨大,说明Full GC很频繁,这是最基础的报警信号。不过jstat只给汇总数字,不给你对象级的原因,所以定位还是得靠堆转储。
6.2 Arthas:线上排查“神兵”
Arthas是我排查线上问题最常用的工具。不用改代码,Java进程直接attach。我常用的几个命令:
dashboard:看整体线程、内存、GC指标。thread -n 3:看CPU使用率最高的三个线程,通常能抓到瓶颈线程。heapdump /tmp/heap.hprof:把当前堆快照导出来,回头用MAT慢慢分析。tt:追踪方法调用,看哪个方法的入参出参产生了大量对象。
曾经有一次线上问题,我靠tt命令追踪了一个处理订单的service方法,发现每次调用都new了一个2MB的临时byte[],用于拼接日志,结果把Eden区冲爆了。这就是典型代码问题引致的GC压力,没有Arthas这种工具很难在线上精准定位。
6.3 MAT:堆转储分析的黄金标准
MAT(Eclipse Memory Analyzer)是分析堆快照最经典的桌面工具。有几个核心操作:
Histogram:按对象数量、保留内存大小排Rank,快速找大头。Dominator Tree:看哪个对象“支配”了一大块内存,通常能定位到缓存Map、全局集合这类的根持有者。Leak Suspects:自动给出一系列“可疑泄漏点”,虽然不能直接确认,但能大大缩小排查范围。
用MAT时,有一个容易踩的坑:堆快照文件别直接在线上进程里硬分析,容易把线上机器的内存也搞爆。最好是jmap或Arthas导出来,拉到本地或者专门的跳板机再分析。否则本来在排查Full GC,结果你先把机器整Full GC了。
6.4 监控大盘:GC指标纳入告警体系
到了平台期,GC监控一定要接入可视化大盘和告警。我现在的要求很简单:
- GC次数、单次耗时、GC后堆内存变化,全部进监控埋点。
- 对
FGC次数 > 每分钟1次或者单次Full GC耗时 > 500ms的动作,触发告警通知。 - 对
老年代使用率稳定在90%以上不回落,视为高风险泄漏信号。
平台不重要,Prometheus + Grafana、自研监控、云厂商监控都行,关键是“可追踪、可比较、可告警”。没有数据闭环的GC优化,基本等于盲人摸象。
7. 常见问题与排查技巧实录
写到这里,我把这些年见过的高频问题整理一下,按场景贴出来,希望大家能少走点弯路。
7.1 “为什么Full GC之后老年代还有大量对象被杀不掉?”
如果Full GC之后,老年代内存占用还是很高的,第一个怀疑方向是内存泄漏。我见过最常见的泄漏模式:
- 把对象放进静态Map,却忘了移除。
- 使用ThreadLocal保存大对象,线程池复用导致对象无法释放。
- 监听器或回调注册后没有反注册。
- 各种框架的Metaspace元数据持续膨胀(类加载器泄漏)。
排查方式就是抓两份堆快照,间隔15分钟到半小时,用MAT的Compare功能对比。如果某个类型的对象数量持续上升且被GC root引用,那基本就是泄漏了。
7.2 “GC参数改了,但线上STW还是很高怎么办?”
参数只是一部分,更大的可能性是锁竞争、IO等待或者CPU调度导致的“非GC停顿”。GC日志里显示的停顿时间,只是JVM暂停业务线程的时间;实际业务线程停顿还包括锁等待等。排查这类问题要看业务线程的调用栈,找锁、找IO,别死盯GC参数。
另外一个很容易忽略的点:机器CPU核数如果被其他容器争抢,GC线程本身也跑不快,停顿自然被拉长。如果你跑在容器化环境里,要注意-XX:ActiveProcessorCount和-XX:ParallelGCThreads的配置,别让JVM默认值高估了你实际能用的CPU。
7.3 “G1设置了MaxGCPauseMillis=100,为什么还经常超?”
G1的停顿目标本质是“启发式”的,它会基于历史数据往这个目标靠,但遇到特殊情况(大对象分配、Humongous回收、Full GC退化)照样突破。要降低突破概率,建议:
- 保持
-Xms = -Xmx,避免堆扩容抖动。 - 控制年轻代最大占比,避免Young GC一次收太多Region。
- 减少Humongous对象,大对象能拆则拆。
- 老年代占比超过65%左右时候,G1的Mixed GC会进入高负载模式,这时候要主要检查内存压力来源,而不是继续调目标停顿。
7.4 “线上GC日志里出现System.gc(),怎么回事?”
System.gc()触发Full GC在分布式系统里很危险。常见来源是JNI/NIO框架在申请堆外内存时调用System.gc()来触发堆内整理,比如老版本Netty的DirectBuffer回收路径。解决思路:
- 加
-XX:+DisableExplicitGC禁用显式GC调用(但这样做可能有堆外内存回收延迟的风险)。 - 更好的做法是升级框架版本,或者手动管理DirectByteBuffer的池化和释放。
- 排查时在GC日志里看
[Full GC (System.gc()),基本就能定位是谁调用的。
7.5 一套速查表:GC问题 → 排查重点
| 现象 | 优先排查方向 |
|---|---|
| Young GC频繁 | 代码大量创建短命对象、业务峰值流量冲击 |
| 老年代持续增长 | 缓存未淘汰、线程数过多、批量查询产生大对象 |
| Full GC频繁 | 内存泄漏、大对象扎堆进入老年代、堆太小 |
| GC停顿突增且导致接口超时 | 锁竞争、磁盘IO抖动、GC线程受限于CPU |
| 进程启动后堆内存瞬间占满 | 启动加载缓存/数据量过大、元空间不足、ClassLoader泄漏 |
8. 最后的几条实操心法
项目做得多了,我越来越觉得GC优化不是某一项硬核技术,而是一套“内存功夫”——表面上是调JVM,内核是对业务代码的理解、对并发模型的控制、对容灾手段的把握。
我个人在实际操作中有几个很坚持的习惯:
第一,上线前必须压测并保留GC基线。没压测过的服务,做GC优化就像闭着眼开车。一次压测下来,把YGC、FGC、老年代增长率记录到文档里,作为后续调优和回归的基准值。
第二,每次只改一个变量。调GC参数的时候,千万别一次性改三四个参数,否则出了效果你根本不知道是哪个参数做的贡献。我的做法是改一个参数压测一轮,数据落表,再改下一个。
第三,GC日志要“永久开启”。哪怕日志量再大,也要保留至少一周的轮转文件。很多线上问题是“偶发”的,事后没日志就只能靠猜。
第四,遇到故障先复盘代码,再动参数。GC优化的顺序一定是从上往下:代码层 → 架构层 → JVM参数层。很多公司一遇到Full GC就靠“加内存”硬扛,那是治标不治本。你得先搞清楚老年代里到底装了什么,才有资格谈参数。
最后再分享一个屡试不爽的小技巧:当你实在定位不到老年代增长原因时,就在压测环境下给老年代设置一个很低的-XX:MaxTenuringThreshold=1,强制对象快速晋升。这样那些“短命对象”会更快暴露在老年代里,更容易通过两轮堆快照的对比找到元凶。等定位完,再把阈值调回来。这个方法听上去有点“暴力”,但排查效率极高,值得一试。
GC优化这件事,没有一劳永逸的银弹,但它绝对是一门可以靠方法论、工具和复盘不断逼近“零抖动”的硬功夫。希望这篇分享能帮你少踩几个我在线踩过的坑。