1. 闲聊开始:网上搜“G1”你到底想干嘛
坦白讲,收到“闲聊GC-G1”这个题目时,我第一反应是去翻了翻所谓的全网热词,看看现在搜“G1”大家都想看什么。果然不出意外,搜出来一头雾水:git拉取代码一直fetch的时候提示“see help gc for manual housekeeping”,这是让我去清垃圾对象;G1主观赋权法——当我看完这词条,第一反应是,哦,原来是搞数据的统计指标权重的;再看看,还有宇树G1机器人拆解和遥操作。
在座各位后端工程师、Java开发,应该和我一样,看到这些“G1”第一眼能愣住,但紧接着脑子里瞬间映射出的是一个无比熟悉的概念:JVM Garbage First垃圾收集器。没错,在Java技术栈里,但凡你说“聊GC,聊G1”,不管热搜榜上挂着的是机器人还是数学公式,咱们的话题只有一个:为什么我的服务时不时卡顿?为什么并发一高JVM的GC日志全屏都是Pause?为什么G1明明号称“可控停顿”却还是把我们的接口给憋成了慢响应?
这篇文章就当成是工作间隙的一段闲聊,不讲教科书级别的源码阅读,就聊我自己在线上环境里调G1、看G1日志、排查G1导致的各种幺蛾子时攒下来的经验。做后端时间长了,你会发现JVM的垃圾回收这件事,你逃不掉,也躲不开。既然躲不开,那就把它掰开揉碎聊清楚。适合刚接触JVM调优的新人,也适合那些已经用了G1几年但总是在“背参数”却不太知道每个参数背后到底在干嘛的老兵。
一句话先说透:G1不是银弹,它只是把原本一次性的“大清扫”拆成了很多次“小块巡逻”,让“随机长停顿”变成“可控的短停顿”。但代价是什么呢?代价就是它引入了一堆极其绕的概念和配置项,比如Region、SATB、RSet、Mixed GC。你要是对这几个名词心里没底,那G1这辆车你开起来早晚会翻。
2. 为什么会有G1:从CMS的“死亡”开始聊
2.1 CMS的老大难:碎片化与Concurrent Mode Failure
咱们这批人,谁没被老一代CMS收拾过。CMS(Concurrent Mark Sweep)的设计思路和它的名字一样,是“并发标记清理”。它的核心卖点就是那些需要和用户线程并发的阶段(标记、清理)不用从头到尾停止整个世界(STW)。听起来很美,但用过的都知道,CMS有几个从娘胎里带出来的毛病。
第一个毛病是内存碎片化。CMS用的是“标记-清除”算法,不整理堆内存。所以它跑得越久,老年代越像年久失修的出租屋,这一块空着、那一块空着,零星散落着好几块巴掌大的可用区域。这时候如果新进来的大对象又要塞进老年代,找半天找不到一块连续空间容纳它,CMS就会触发它最恐怖的一个机制:Concurrent Mode Failure。一看到这个单词,基本就等于告诉你:兄弟,没辙了,接下来要请出Serial Old进来做一次全停顿的标记-整理-Full GC。那感觉就像是你买了个号称“无损并发”的扫地机器人,结果它扫到一半自己卡住了,还得你亲自上手把全屋家具搬开手动擦地。
第二个毛病是CPU资源吃紧。CMS并发阶段虽然不会STW,但它是实实在在要抢CPU的。如果服务机器核数不够,CMS并发标记阶段一跑,应用线程的QPS就会往下掉,延迟蹭蹭往上冒。那时候在4核8G的小机器上部署服务,最怕的就是半夜CMS开始“勤奋工作”。
2.2 G1的颠覆性思路:大块头切小块,分而治之
G1在JDK 7中首次亮相,JDK 9直接把它设置为默认垃圾收集器,JDK 17时代更是妥妥的生产主力。G1的设计哲学完全推翻了CMS的“整堆扫描、整堆清理”思路。官方名字叫Garbage-First,直译过来是“垃圾优先”。什么意思?就是我哪块区域的垃圾最多、回收起来性价比最高,我就先清哪块。
要做到这一点,G1把整个堆内存划分成了一个个大小相等的小格子,官方术语叫Region。每个Region既可以充当年轻代的Eden,也可以充当Survivor,还可以是Old区,甚至还有专门的Humongous区来存放超大对象。这么一搞,G1就可以把这些Region当作一张巨大的拼图,每次GC决策时,它不是非把整个堆翻一遍不可,而是挑选一批“最值得回收”的Region加入本次收集集合(Collection Set,简称CSet)。
这就是G1最核心的目标:暂停时间可控。通过参数-XX:MaxGCPauseMillis,你告诉JVM,每次GC的STW时间尽量别超过这个值。G1内部维护了一套“暂停时间预测模型”,它会根据历史回收记录来估算,如果这次挑这100个Region大约花费150ms,那行,就按这个量来;如果昨天数据波动大,它就会调整策略,减少或增加Region数量,朝着你设定的目标尽量逼近。
打个不严谨但好懂的比方。以前CMS是做大扫除,一年大扫除一次,平时脏乱差忍忍就行,但大扫除那天从早到晚干12小时,干净是干净了,全天家里没法住人。G1呢,是每天下班后花半小时整理一个房间,今天收拾客厅,明天收拾厨房,长期坚持下去,屋子里一直是能住人的状态。代价就是你每天都要花点时间去收拾,没法像CMS那样装作看不见。
3. G1核心机制拆解:Region、RSet和SATB到底是个啥
3.1 Region:G1的世界观里没有“老年代”这堵墙
咱们先记住一个结论:G1里其实没有物理意义上的“年轻代”和“老年代”。从内存块的大小和形态来看,所有Region长得一模一样。只是在逻辑上,G1会动态地给不同Region分配角色。
-XX:G1HeapRegionSize这个参数就是用来指定每个Region大小的。如果不指定,G1会根据你的堆大小自动算一个合适的值出来。有个经验公式大家可以参考:G1的目标是让堆被划分为大约2048个Region。比如我们的服务-Xmx设置成了4GB,那默认Region大小就是 4GB / 2048 ≈ 2MB。所以我一般习惯显式地指定一下,比如用-XX:G1HeapRegionSize=4m或-XX:G1HeapRegionSize=8m,避免自动计算带来一些边界瑕疵。
Region太小会有什么问题?一个对象如果超过Region大小的50%,官方就把它归为大对象(Humongous),比如你有一个80MB的大对象需要分配,但Region是2MB,那这个大对象就要独占连续40个Region。Humongous对象的分配和回收都比较“霸道”。分配的时候要找到一段连续的Region来放它,触发一次特殊的停顿叫G1 Humongous Allocation;回收的时候更麻烦,因为没人能移动它,它只有在并发标记后确认是垃圾才能被回收。如果大对象在服务里频繁创建,你会看到GC日志里频繁出现 “Humongous Allocation” 相关的停顿,接口响应时间直接往上飙。
3.2 RSet:跨Region引用的“备忘录”
年轻代和老年代之间互相引用的追踪,在G1里也换了一套玩法。以前CMS要找到老年代里哪些对象被年轻代引用,最笨的办法就是把整个老年代扫一遍,找出哪些Card是dirty的,这效率极低。
G1引入了Remembered Set(RSet)的概念。每个Region都有一个RSet,记录了“有哪些Region中的对象引用了我这个Region中的对象”。通俗地讲,这就是一张备忘清单。每当有一个对象A引用了另一个Region里的对象B时,JVM内部会通过一个写屏障(Write Barrier)在B所在Region的RSet里记上一笔:“喂,A在那边那个Region里,你要找我的话去那边找。”
有了这个备忘录,GC在回收某个Region时,就不必遍历整个堆去找谁在引用我,直接查我自己的RSet名单就行。这样一来,回收的粒度就可以精确控制。这也是为什么G1能实现“挑选部分Region进行回收”,而CMS做不到的原因。RSet是G1的核心优势,也是G1内存开销的一个大头。RSet本身也会占用堆外或者堆内空间,如果你的应用里对象之间的引用特别“错综复杂”,RSet的体积会相当可观,这部分内存开销是很多人容易忽略的。
3.3 SATB:并发标记时的“快照”保底
G1的并发标记阶段用的是SATB(Snapshot-At-The-Beginning,起始快照)算法。这个东西是为了解决一个在“GC线程和应用线程并发运行”时很头疼的问题:你这边正在判断一个对象是否活着,那边应用线程已经把它的引用干掉了,或者新造出了一个引用。你到底该不该把它标记为存活?如果漏标了,就可能导致“明明是活对象却被回收掉”的严重事故,直接引发JVM崩溃。
CMS时代为了解决这个,使用了“增量更新”(Incremental Update)策略,代价就是“写屏障”逻辑极其复杂,还容易扫描漏掉。G1换了个思路:干脆不追求标记时刻的绝对精确,我以并发标记开始那个瞬间的堆状态为基准,做一个“快照”。在这个快照时刻,只要某个对象是可达的,那么在整个并发标记期间,我就默认它一直是可达的,绝对不会把它当垃圾清掉。换而言之,G1允许“过度标记”(宁可错杀一千,不放过一个存活对象),但绝不允许“漏标”。
那如何保证呢?G1在应用代码每次进行对象引用变更的时候,插入一个“预写屏障”(Pre-Write Barrier),把变更前的那份引用记录下来,放入一个叫SATB的队列里。GC线程会去处理这份记录,确保快照时刻不可达、但在标记过程中变成可达的对象也能被识别为存活。这么做确实增加了并发阶段的开销,但换来的是极低的风险和相对稳定的停顿时间。实际经验来看,绝大部分线上服务这种SATB队列压力都不大,除非你的那个服务真的是眨眼之间对象图疯狂变化。
3.4 Mixed GC:为什么G1回收老年代叫“混合”
刚才说G1把堆分成Region,那么怎么回收老年代对象?G1没有CMS那样“全堆一次并发清理”的流程。它把整个回收过程拆成了两个大类:Young GC和Mixed GC。
Young GC就是只回收年轻代Region。这个过程是STW的,把Eden区和Survivor区里存活下来的对象,复制到新的Survivor或者直接晋升到老年代Region。这个过程完全复制/移动,所以不会产生碎片,整理得干干净净。
Mixed GC就不一样了,它除了回收年轻代Region,还会顺带挑选一部分老年代Region加进CSet里一起回收。这才是G1英文名里“Garbage-First”的精髓:每次Mixed GC都会重点挑选那些“垃圾占用比例最高”的老年代Region先回收,这样能以最小的工作量腾出最多的空间。
但要注意,Mixed GC不是你想触发就能触发的。它需要在“占用率足够高”时触发并发标记周期(Concurrent Cycle),经历初始标记、并发标记、重新标记、清理这几个阶段,标记出老年代里哪些Region垃圾多。然后G1会根据目标停顿时间、垃圾占比等参数,分多轮去执行Mixed GC。所以G1的老年代回收是“细水长流”式,不像CMS那样突然来一次大的。
4. G1参数和配置实例:别乱调,但也不能完全不调
4.1 基础参数配置速查
聊了这么多概念,不落地点配置参数总感觉少了点什么。下面这张表是我自己一直放在收藏夹里的,线上服务启动参数基本脱不开这张表。
| 参数名 | 默认值 | 我的建议 |
|---|---|---|
-XX:+UseG1GC | 开启(JDK9+) | 明确加上,避免摸不清默认收集器 |
-XX:Xms -Xmx | 视场景 | 生产环境务必设成相同值,避免堆抖动 |
-XX:MaxGCPauseMillis | 200ms | 一般设定为100-300ms,别设太小 |
-XX:G1HeapRegionSize | 自动计算 | 建议显式指定,堆3-8G用2M或4M |
-XX:G1NewSizePercent | 5% | 年轻代最小比例,别乱动 |
-XX:G1MaxNewSizePercent | 60% | 年轻代最大比例 |
-XX:InitiatingHeapOccupancyPercent | 45% | 常被改成50-60%,触发并发标记阈值 |
-XX:ConcGCThreads | ParallelGCThreads的1/4 | 并发标记线程数 |
-XX:ParallelGCThreads | CPU核数 | 并行GC线程数 |
-XX:G1HeapWastePercent | 5% | 回收候选区垃圾占比低于此值,垃圾回收会提前结束 |
-XX:G1MixedGCLiveThresholdPercent | 85% | Mixed GC对象存活率高于此值的Region不回收 |
-XX:G1MixedGCCountTarget | 8 | 控制Mixed GC轮数 |
-XX:+ExitOnOutOfMemoryError | 关闭 | 推荐开启,OOM时直接退出让容器重启 |
4.2-XX:MaxGCPauseMillis千万别设置成1ms
很多初学者一看到“可控停顿时间”,上来就把-XX:MaxGCPauseMillis=1,心想既然美妙的停顿时间可控,那就让它控制在1毫秒内呗。结果一上线,JVM确实疯狂响应目标,把GC周期切得非常碎,每隔几百毫秒就来一次Young GC,每次Young GC只收集那么一丢丢Region。这个行为你从GC日志里看,暂停时间确实没超过1ms,但GC频率暴增,CPU大部分都在跑GC,业务吞吐量直接腰斩。
我踩过这个坑。有一次给一个活动页面的接口调优,我把停顿时间设成了20ms。结果线上观察到,老年代的对象还没来得及晋升就被反复扫描,触发了一连串的并发标记周期。整个服务的TPS从3000降到了1200,GC消耗的CPU占比直接冲到30%以上。后来改回150ms,世界立刻安静下来。
经验是:G1的暂停时间预测模型,本质上是拿“回收频率”和“GC线程CPU开销”来换“单次停顿时间”。你强行把目标压得太低,它就只能用极高的频率去打扫,整体开销反而更大。一般来说,线上业务系统设置为100ms到300ms是一个比较健康的区间,再低就属于自找麻烦。
4.3-XX:InitiatingHeapOccupancyPercent是并发周期的发令枪
IHOP默认是45%,意思是整个堆的使用率达到了45%,G1就开始准备执行并发标记周期。这个参数直接影响老年代的回收节奏。
如果堆的使用率长期在60%上下波动,但IHOP还是45%,那么G1就会频繁地启动并发标记,标记完发现老年代其实也没多少垃圾,或者Mixed GC没挑到几个值得回收的Region,空跑了一轮。空跑的代价是CPU持续消耗在并发标记上。反过来,如果你把IHOP设置的过高,比如80%,那老年代垃圾堆积得很严重时才触发标记,很有可能标记完还没开始Mixed GC回收,老年代就彻底放不下新晋升的对象了,于是G1被迫退化为Full GC(暂停整个世界,做Serial Old式整理)。这比CMS晚节不保时的Concurrent Mode Failure好点,但一样让你脑溢血。
所以我建议的做法是:先看监控里老年代的占用曲线。如果长期在60-70%徘徊,就把IHOP调到55%或者60%左右,留出安全余量,让G1在你有压力之前提前开始“囤积情报”。同时配合-XX:G1HeapWastePercent,控制一下在清理阶段,如果dirty region太少就及时终止,避免浪费资源。
4.4 G1调优的禁忌清单
- 不要无脑调
-XX:G1NewSizePercent。这玩意儿改小了会导致年轻代过早晋升,老年代压力陡增;改大了虽然Young GC频率降低,但一次性晋升的大对象可能撑爆Survivor。新手不建议动它。 - 不要在同一台机器上塞太多JVM实例。每个G1实例都有并发标记线程,即便设置了ConcGCThreads,你想想一台宿主机上跑10个Java进程,每个进程5个并发GC线程,再加上业务线程,CPU核数不够时GC就变成了群体活动,互相拖累。
- 不要以为G1没有Full GC。G1只有在并发标记和回收进展顺利的情况下才能避免Full GC。一旦你制造了大量大对象,或者程序里有严重的内存泄漏,堆被塞满,G1照样会触发Full GC,而且这位老兄的Full GC是串行Single Thread的,速度巨慢。说白了Full GC在G1里就是“终极保底手段”,真到了那一步,稳定性和性能早就无处可寻了。
5. 实操现场:从一次线上报警看 G1 日志到底怎么读
5.1 一次让人头秃的“接口波动”事件
前阵子我们有个核心交易服务,某个大促预热期间,业务方反映说接口响应时间从正常的30ms突然波动到500ms以上,而且这种波动不是偶发,是每隔十几分钟就来一波,每次持续一两分钟。我们一查监控,发现Full GC指标的小时次数从0涨到了4,同时GC耗时曲线呈周期性冒尖。
我第一反应是:该不会是用了CMS的老服务没迁移?了一看Java版本和启动参数,发现这个服务是标准的JDK 17 + G1。那就怪了,G1在正常情况下是不太容易周期性大停顿的。我们通过监控平台拉取了实时的GC日志片段,开始逐行分析。
5.2 G1日志逐行解读
生产上可以通过-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags这个参数打开详细GC日志记录。等日志拉下来,你会看到类似下面这种,我来带你走一遍:
[GC pause (G1 Evacuation Pause) (young) (initial-mark) [Parallel Time: 92.5 ms] [GC Worker Start (ms): 12345.6 12345.7 ...] [Ext Root Scanning (ms): 15.2 16.8 ...] [Update RS (ms): 8.4 9.1 ...] [Scan RS (ms): 6.2 7.4 ...] [Object Copy (ms): 50.3 51.7 ...] [Termination (ms): 2.1 1.8 ...] [GC Worker Other (ms): 0.8 0.9 ...] [GC Workers: 8] [Code Root Fixup: 0.2 ms] [Code Root Purge: 0.1 ms] [Clear CT: 0.6 ms] [Other: 10.0 ms] [Eden: 1024M(1024M)->0.0B(1024M) Survivors: 128M->128M Heap: 3072M(4096M)->2048M(4096M)] [Times: user=0.82 sys=0.12, real=0.11 secs]这段日志告诉我们几个信息:这是一次年轻代Evacuation暂停,同时附带initial-mark(说明要开始并发标记周期了)。Parallel Time约92ms,这期间Ext Root Scanning花了15ms,说明GC Root扫描耗时有点高,通常和应用的线程数、类加载器数量、JNI引用数量有关。Update RS和Scan RS分别是8.4和6.2,这说明RSet更新和扫描的代价还行,没到炸裂的程度。Object Copy用了50ms,这个时间段主要是把存活对象复制到新Region,如果内存里大对象多,这个值会非常难看。
结尾的Eden统计里,Eden区从1024M直接被清空到0,说明刚经历了一次比较完整的年轻代回收。而Heap区从3072M降到2048M,算一下大概释放了1G内存,回收量理论上并不低。
5.3 报警背后的元凶:Humongous 对象在作祟
光有上面这段日志,还没法定位到那个周期性的“Full GC”。我们接着看后续日志,发现GC日志里频繁出现这样的片段:
[GC pause (G1 Humongous Allocation) (young) (initial-mark) [Parallel Time: 210.3 ms] ... [GC concurrent-mark-start] ... [GC concurrent-mark-end] ... [Full GC (Allocation Failure) ...看到G1 Humongous Allocation和Full GC (Allocation Failure)联系在一起,基本就能确定问题方向了:这个服务里一定有大量的大对象在频繁创建。大对象(Humongous Object)因为要占据连续Region,在分配时本身就比小对象更容易触发停顿。更致命的是,G1的并发标记在处理Humongous对象时,要扫它的RSet,效率比常规Region低不少。
我们查了代码,发现这个服务在预热期间增加了一个定时任务,每隔5分钟会把一批大报文一次性加载进内存做业务计算。这批报文每条都有几十MB,存到一个缓存Map里,等下一个周期再替换掉。这就导致了每隔十几分钟,就会触发一轮“大对象分配失败”带来的Full GC。
解决方案不复杂,但很能说明问题:
- 把大报文拆小,或者改成异步流式处理,避免一次性全部加载到堆中;
- 临时调整
-XX:G1HeapRegionSize从2M改成4M,让部分大对象不至于占满几十个Region,稍微缓解一下分配压力; - 同时把
-XX:InitiatingHeapOccupancyPercent从默认的45%调到55%,让G1没必要那么早进入并发标记,省掉一部分重复的并发周期; - 最后给那个定时任务默认的线程池设置了一个合理的等待队列上限,防止任务堆积时把内存瞬间吃干。
这一套组合拳下来,Full GC的次数直接从每小时4次降到了0次,接口的P99响应时间从500ms降回了45ms附近。
5.4 读GC日志要盯的核心数据
有人问我,GC日志那么多行,到底该看哪些指标?我一般按优先级从高到低看这几个:
- 先看线路里有没有
Full GC字样,有就意味着大事不妙,说明G1的并发回收已经跟不上了,系统在走下策,STW时间会非常长。 - 如果没有Full GC,那看
Parallel Time和Ext Root Scanning。Parallel Time是整个暂停的主体,如果它一直过高,说明应用线程数可能太多,GC Root扫描开销太大。 - 再看
[Eden ...]释放量和Survivors大小。如果Survivor频繁溢出,大量对象直接晋升老年代,那么老年代压力会很大,GC频率也会跟着提高。 - 如果 GC 日志中出现
concurrent-mark-start到concurrent-mark-end时间间隔特别长(比如超过几百毫秒甚至秒级),说明并发标记阶段应用线程的争抢非常激烈,往往是CPU被大量占用导致的。
记好这几点,你去看GC日志就不会像看天书一样,抓重点的能力直接上升一个档次。
6. 选型经验谈:G1、Parallel和ZGC到底该怎么挑
6.1 G1并不是所有场景的默认解
虽然G1现在是JDK默认收集器,也确实是绝大多数后端服务的安心之选,但它绝对不适用于所有场景。有些服务内存分配压力极大,追求的是“吞吐量优先”,比如离线批处理、数据仓库ETL任务。这类任务的特点是对单个请求的延迟不敏感,但对整个任务的完成时间敏感。此时Parallel Scavenger + Serial Old(PS + Serial Old)这种组合往往是更好的选择。Parallel收集器可以尽可能把GC线程开满,用更高的并发回收率换取更高的吞吐量,一两秒钟的STW在离线任务里根本不是事。
所以我通常的建议是:
- 如果你跑的是实时在线接口、RPC调用、网关、微服务实例,选G1,默认参数微调即可,稳妥。
- 如果你跑的是离线任务、一次性批量跑数、或者单机内存特别大(比如超过64G)且不想折腾,那么Parallel依然是好同志。
- 如果你对延迟极其敏感,比如量化交易、在线游戏服务器、高频交易风控,要求GC停顿必须小于10ms,那你应该去拥抱ZGC或Shenandoah。JDK 17之后的ZGC已经实用了,吞吐量和稳定性都经得起考验。它的思路更进一步,利用染色指针、读屏障等手段,把绝大部分GC阶段变成了并发,STW时间几乎被压到了极致。
6.2 一个简单的决策表格
| 应用类型 | 极致吞吐量(离线批处理) | 在线后台服务(标准微服务) | 低延迟交易系统 |
|---|---|---|---|
| 推荐收集器 | Parallel | G1 | ZGC |
| 核心优势 | 高吞吐 | 吞吐与延迟的平衡 | 极低停顿 |
| 劣势 | STW不可控 | 大对象仍可能引发Full GC | 内存开销大,CPU开销高 |
| 堆大小经验区间 | 任意(适合大堆) | 8G-32G都行 | 32G以上优势明显 |
6.3 我对选型的个人体会
最初用G1的时候,我也迷信“调一调参数就能包治百病”。后来线上踩的坑多了,逐渐明白一个道理:垃圾收集器管的是“清理垃圾”这件事,它本身没法让你的代码不去制造垃圾。无论G1怎么进化,如果你的业务逻辑里疯狂创建大对象、缓存不设上限、批量循环里频繁字符串拼接,再牛的收集器也救不了你。
所以我现在做性能优化时的顺序是:先看代码,再看堆转储,最后才调GC参数。GC调优是兜底策略,不是第一手段。
最后分享一个很实用的小习惯:生产环境JVM启动参数里,我都建议加上一个-XX:+ExitOnOutOfMemoryError。这样一旦真发生堆溢出,JVM会直接退出进程,让容器调度器重新拉起一个干净实例,而不是让服务继续在半死不活的状态下带病运行。配合监控告警,你会发现好多“莫名其妙的长时间Full GC”问题,实际都是内存泄漏的预警信号。与其耗在那里让人工介入,不如快速fail-fast,先恢复业务,再慢慢通过dump文件定位问题。这也是这些年我和G1“相爱相杀”过程中,最想提醒大家的一条实践经验。