1. 先从垃圾回收器的选择聊起
1.1 为什么G1会成为主流默认选择
接触Java的人,基本都跟GC打过照面。从我自己的经历来说,早年做后端服务的时候,用的还是Parallel Scavenge配Parallel Old,后来CMS一度是低延迟场景的标配,再往后,JDK 9直接把G1设成了默认垃圾回收器。这不单单是Oracle的一纸命令,而是G1在设计目标上确实压过了几位老前辈。
先看看它解决了什么痛点。并行回收器追求的是吞吐量,GC暂停时间常常以秒计,在堆内存超过几十个GB的场景下,一次Full GC能把整个应用冻住十秒甚至更久。CMS虽然把并发标记做到了极致,但在碎片化、浮动垃圾、内存预留不足这些老问题上反复翻车,最终也扛不住大堆内存的考验。G1给出的方案很直接:把堆拆成一个个大小相等的Region,通过维护跨Region的引用关系,让垃圾回收不再需要全堆扫描,同时允许你设定一个期望的暂停时间目标,用增量回收的方式去逼近这个目标。
这里要说清楚一点:G1不是什么魔法,它依然有Full GC,只是它通过调节动作,尽量让Full GC不发生。真正理解了这一点,你就知道为什么G1能替代CMS,也明白为什么它并不是万能药。我的建议是,在JDK 11及以上的环境里,除非你有非常明确的理由,否则优先用G1,因为后续版本对它做了大量改进,很多早期版本的毛病已经改掉了。
1.2 G1和CMS的核心差异
G1和老一代回收器最本质的区别在于它引入了一个抽象概念:Region。CMS的堆划分是物理上连续的年轻代和老年代,Eden、Survivor、Old各占一段连续空间;G1则把整个堆统一划分成多个大小相等的Region,每一个Region在运行时可以动态扮演Eden、Survivor或者Old区的角色。你可以把G1的堆想象成一个棋盘,每个格子根据棋局的发展灵活改变用途,而不是像过去那样把棋盘硬切成几个矩形块。
这个设计带来的最大好处是弹性。年轻代不再需要固定预留一块空间,Old区占总堆的比例也不再固定,这就让对象晋升和回收的动态平衡变得更加平滑。回收逻辑上,G1的每一次垃圾回收都不是全堆处理,而是选出一批“最值得回收”的Region组成回收集(Collection Set,简称CSet),在这个范围内做复制回收。
同样重要的还有RSet(Remembered Set)。每个Region都会维护一个集合,记录哪些其他Region中的对象引用了本Region中的对象。这样说有点抽象,你可以把它理解成每个Region门口挂了一个记账本,专门记录“外人谁引用了我”,这样在回收这个Region的时候,就能快速找到所有外部引用,把它当作GC Roots来扫描,不必全堆翻查。
1.3 什么场景适合用G1,什么场景不适合
G1不是所有场景的全优解。如果你的服务追求的是最大吞吐量,堆内存不大,也不在乎偶尔的长时间卡顿,那么Parallel回收器很可能表现更好。G1为了可预测延迟,在并发标记、维护RSet、写屏障这些环节上消耗了额外的CPU和内存,这是它的成本。
反过来,如果你的堆内存到了几十GB以上,服务又对接口响应时间有硬性要求,比如支付、风控、实时推荐这类场景,G1就非常合适。它允许你把期望暂停时间配置到几十毫秒到一两百毫秒之间,系统会自己调整回收节奏。我见过一些团队在32GB堆的HBase节点上用G1配合调参,把GC停顿从秒级压到了两三百毫秒以内,效果非常直观。
还有一种情况是传统单体应用,代码里存在大量长生命周期的大对象,比如缓存,那就要小心。这类场景下G1的Humongous对象分配策略可能成为瓶颈,后面我会展开讲。总之,先别急着抄参数,先判断接口的延迟指标、堆内存大小、对象分配速率,再决定用哪个回收器。
2. G1的核心运行机制,搞懂原理调参才能不抓瞎
2.1 Region的划分:堆内存被切成麻将块
G1默认把堆内存划分为不超过2048个Region,每个Region的大小通过参数-XX:G1HeapRegionSize人工指定,范围是1MB到32MB,必须是2的幂。比如堆大小32GB,Region大小设为16MB,堆上就正好有2048个Region。如果你不指定Region大小,G1会自己计算,计算公式简单说就是从1MB开始做2的幂倍增,直到Region数量最接近2048。
为什么要卡在2048这个数?因为Region数量如果太少,每个Region就太大,回收粒度太粗,回收集的选择就不够灵活;如果Region数量太多,每个Region又太小,RSet管理的开销会急剧上升。我实际测试过,同样的32GB堆,Region设成1MB和16MB,GC日志和内存占用差别很大,前者光RSet的开销就可能吃掉几百MB堆外内存。
Region分成四类:Eden Region、Survivor Region、Old Region和Humongous Region。前三种好理解,Humongous Region是给大对象准备的。当对象大小超过Region的一半,比如Region是16MB,对象大于8MB,G1不会把它放进普通Old Region,而是直接在堆上找连续的一段Region来放,这些Region会被标记为Humongous。这个设计带来的麻烦是,大对象的分配和回收都不走常规复制路径,一旦大对象频繁出现,G1的回收效率就会显著下降。
2.2 三色标记与SATB,并发标记阶段的防漏标方案
G1的并发标记阶段靠的是三色标记法,把对象分成黑色、灰色、白色三个集合。白色代表还没被访问到,灰色代表自身被访问到了但引用的对象还没全部分析完,黑色代表自身和引用对象都分析完了。标记结束后,白色对象就是可回收的垃圾。
问题来了:并发标记的过程中,业务线程还在跑,对象图一直在变化,白色对象随时可能被别的存活对象引用上。如果漏标了这个引用,应用就会拿到一个被回收的存活对象,直接引发严重问题。G1解决漏标的手法是SATB(Snapshot-At-The-Beginning),意思是在并发标记开始的那一刻,记录一个对象图的快照,之后所有的引用变化都通过写屏障记录下来,用以维持这个快照的完整性。
具体实现里,每个Region还有一个TAMS(Top-At-Mark-Start)指针,指针以上的对象是标记过程中新分配的,默认存活。SATB并非完全没有缺点,它会把一些已经变成垃圾的对象保留到下一轮标记清理,形成浮动垃圾。这部分垃圾只能留到下一次循环去处理,所以G1的内存占用在某些时刻会比实际存活对象高一些,预留空间不足就会出现Full GC。
2.3 Young GC、Mixed GC、Full GC的触发时机与执行过程
G1的回收动作分为几个层次。Young GC最频繁,当Eden区被占满时触发,执行过程中回收所有Eden Region和Survivor Region,存活对象复制到新的Survivor,满足年龄阈值的晋升到Old区。整个Young GC是STW的,但G1通过控制年轻代大小,让单次暂停时间尽量落在你设定的目标范围内。
Mixed GC发生在并发标记周期结束之后,它不仅要回收年轻代Region,还会挑出一些回收价值高的Old Region一起回收。这里的“回收价值”由G1自动评估:存活对象越少、回收成本越低的Region越优先。所以你会看到Mixed GC的耗时通常比Young GC长,因为它处理的Region数量更多。
如果Old区空间被耗尽,或者回收速度赶不上对象分配速度,G1就只能退回到Full GC。G1的Full GC是单线程的,会做完整的标记-清除-压缩,暂停时间可能以十秒计。它就像一个城市平常靠精细化调度疏散交通,一旦拥堵超过极限,只能封路大扫除,代价巨大。所以G1调优的核心,就是让系统尽量维持在Young GC加Mixed GC的节奏里,而不是动不动触发Full GC。
3. 关键参数与调优实战
3.1 常用参数清单,标注好每项的作用
这里我把实际项目里最常用的G1参数整理成一张表,每一行都是经过实践检验的,你可以先照抄,再根据场景微调。
| 参数 | 作用 | 我的建议 |
|---|---|---|
-XX:+UseG1GC | 启用G1回收器 | JDK 9以上可省略 |
-XX:G1HeapRegionSize=16m | 设置Region大小 | 大堆建议明确指定,别靠默认 |
-XX:MaxGCPauseMillis=100 | 期望最大暂停时间 | 100或200起步,别一上来就设20 |
-XX:G1NewSizePercent=5 | 年轻代最小占比 | 默认即可,别乱改 |
-XX:G1MaxNewSizePercent=60 | 年轻代最大占比 | 堆大的时候可适当调低 |
-XX:G1ReservePercent=10 | 预留内存比例,防止晋升失败 | 默认10%,高分配速率场景可调高到15% |
-XX:InitiatingHeapOccupancyPercent=45 | 堆占用率达到该比例时触发并发标记 | 默认45%,要结合实际情况调 |
-XX:G1MixedGCLiveThresholdPercent=85 | 存活率高于该值的老Region不参与Mixed GC | 默认85% |
-XX:G1MixedGCCountTarget=8 | 单轮并发标记后分成几次Mixed GC | 默认8次 |
-XX:G1HeapWastePercent=5 | 可回收空间占比低于该值时不触发Mixed GC | 默认5% |
需要提醒的是,G1的参数虽然多,但大部分情况保持默认即可。我见过很多性能问题不是因为参数没调好,而是因为改得太激进,破坏了G1内部的自我调节机制。接下来我会挑两个直接影响效果的参数细说。
3.2 目标暂停时间到底怎么设置才合理
-XX:MaxGCPauseMillis是G1最核心的调参入口,它告诉G1你期望的GC暂停时间上限。但很多人对这个参数有误解,以为设得越小越好。实际上,G1只是把这个值当作软目标来努力逼近,不是硬性保证。设得太小,G1会不断压缩年轻代大小,结果Young GC的频率急剧上升,吞吐量反而崩塌。
我做过一次对比实验,同一个服务,MaxGCPauseMillis分别设100和20,总体吞吐量相差接近20%。为什么?因为设成20毫秒后,G1每次只敢回收很少的Region,Eden空间被压得很小,对象分配几分钟就把Eden填满,频繁触发Young GC,每次停顿确实控制在20毫秒左右,但总耗时反而暴增。
从实践角度,我建议按接口的SLA来定。如果接口允许200毫秒延迟,设100或150比较合适;如果接口要求50毫秒以内,那G1未必是最优解,甚至要考虑堆外缓存来降低GC压力。不要迷信“暂停时间越小越好”,这是新手最常犯的错误。
3.3 场景化调参:大内存、高并发、低延迟三种组合
调参必须结合场景,我给出三套我实际验证过的参数组合供参考。
第一套是通用大内存场景,堆内存32GB以上。参数可以这样设:
-Xmx32g -Xms32g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=4-Xms和-Xmx保持一致,避免运行期堆扩容带来的性能抖动。Region大小设为16MB,保证Region数量在2048附近。ConcGCThreads设为ParallelGCThreads的一半左右,避免并发标记和业务线程抢CPU。
第二套是高并发互联网后端场景,特点是对象分配速率高,Young GC频繁。这时可以适当调大年轻代占比上限,并开启并行引用处理:
-XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:G1MaxNewSizePercent=60 -XX:+ParallelRefProcEnabled -XX:G1NewSizePercent=10ParallelRefProcEnabled在引用对象多时很管用,比如大量使用软引用、WeakHashMap的应用,这个参数能显著缩短暂停里的引用处理阶段。
第三套是低延迟敏感的金融类交易系统,堆内存相对中等,但延迟要求苛刻。核心思路是宁可让GC频率稍高,也不能出现长暂停:
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1ReservePercent=15 -XX:G1HeapWastePercent=3 -XX:InitiatingHeapOccupancyPercent=35把IHOP从默认45%降到35%,意思是堆占用没到那么多就提前开始并发标记。代价是标记频率更高,但好处是Old区有充足时间整理,不容易突然走到Full GC。G1ReservePercent=15则是预留更多空间给晋升对象,降低晋升失败的概率。
3.4 用GC日志看清G1的一举一动
不分析GC日志就调参,等于闭着眼开车。JDK 8用的是一串经典参数:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintAdaptiveSizePolicyJDK 11开始推荐统一日志写法:
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags -Xlog:safepoint:file=/data/logs/safepoint.log拿到日志后,优先看两个数据:一是每个阶段的暂停时间分布,二是是否出现Evacuation Failure、Allocation Failure、To-space exhausted这些关键词。这两个信息最能说明G1的健康状况。我每次接手一个GC问题,第一件事就是拉最近三天的GC日志,统计Full GC次数和最长停顿时间,再决定下一步排查方向。
4. 那些年踩过G1的坑
4.1 Humongous大对象带来的分配停滞
这是G1里最容易踩的坑,而且往往上线几天后才爆发。问题出在Region大小的设置上。假设Region设成16MB,那么超过8MB的对象会被当作Humongous对象处理,分配时需要在Old区找一段连续的Region。如果堆上大对象比较多,或者分配频繁,你会发现GC日志里出现大量Humongous Allocation,而且Young GC的停顿时间明显变长。
为什么?因为Humongous对象的分配和回收都不走普通的Eden复制路径,G1在处理大对象时需要扫描和标记的Region数量可能比普通对象多得多。更麻烦的是,大对象回收后留下的Region碎片可能一直无法被有效利用。我曾在一次日志分析时发现,堆中Humongous Region占用达到总堆内存的20%,但大部分都是短生命周期的一次性大数组,这就非常浪费。
解决思路有两个方向。一个是调整Region大小,让大对象能够落进普通Region,比如Region从16MB调到32MB,那么16MB以下的对象就不再是Humongous了。另一个是排查代码本身,看看那些大数组是否可以通过分片、池化来避免频繁创建。从根源上减少大对象,效果远好于硬调参数。
4.2 RSet占用内存过高的隐患
每个Region的RSet需要占用堆外内存。Region数量越多,RSet管理开销越大。堆内存32GB、Region设1MB时,Region数量高达32768个,RSet的开销会到不可忽略的地步。我见过一个极端案例,应用堆外内存一直涨,排查半天发现是RSet占了几百MB。
JDK 8下G1的RSet设计比较原始,GC日志中可以看到Remembered Set相关的时间占比。JDK 11做了不少优化,比如RSet的细粒度索引、懒惰清理等。如果你的线上环境还在JDK 8,又遇到RSet占用过高,建议在升级JDK之外,把Region大小适当调大。Region大一点,Region数量就少一点,RSet管理的压力也会小很多。
4.3 疏散失败与Full GC的连锁反应
疏散失败(Evacuation Failure)是G1最典型的Full GC前兆。场景通常是这样的:并发标记还没跑完,或者Mixed GC还没来得及回收足够的Old区,业务线程就疯狂分配对象,把预留空间用光了。结果是对象晋升到Old区时没有空间可放,G1只能放弃复制,退化成Full GC。
GC日志里如果出现To-space exhausted或者Evacuation Failure,伴随而来的往往就是classic的Full GC (Allocation Failure)。Full GC一旦出现,停顿时间秒级起步,如果堆内存大,几十秒都可能。而且Full GC之后RSet需要重建、Region需要整理,后续一段时间内GC表现都非常差。
应对疏散失败,最靠谱的手段是预防。把InitiatingHeapOccupancyPercent适当调低,让并发标记启动得更早,给Old区腾出更多整理时间;同时确保G1ReservePercent不低于默认的10%,特殊场景可以调到15%。如果是临时救急,还可以使用-XX:G1MixedGCLiveThresholdPercent=90,让Mixed GC更积极处理高存活率Region。但这些都是缓解措施,根因还得从对象分配速率上找。
4.4 G1在JDK不同版本的行为差异
如果不提JDK版本聊G1调优,是不严谨的。JDK 8里的G1和JDK 11、JDK 17里的G1,表现差别非常大。JDK 8的G1在某些场景下甚至不如CMS,所以很多团队在JDK 8上依然坚持用CMS是有道理的。但JDK 9开始G1转正之后,持续优化了很多点,包括字符串去重改进、RSet处理优化、并发标记效率提升等等。
我最明显的感受是,JDK 8上升级参数带来的效果有限,同样的应用在JDK 11上哪怕用默认参数也能获得更平稳的GC表现。JDK 17又进一步改进了并发类卸载、堆内存紧缩这些场景下的卡顿。所以如果你有条件,尽量把线上环境升级到较新的LTS版本再谈G1调优,否则你在旧版本上摸索出的经验,换一个版本就可能失效。
5. 排查与定位G1问题的思路
5.1 从GC日志中读取关键信号
拿到GC日志先看结构。Young GC的行尾通常会带着本次暂停的耗时和各个阶段的时间明细,像Pre Evacuate Collection Set、Evacuate Collection Set、Post Evacuate Collection Set这些都是内部阶段。重点关注Evacuate Collection Set的时间,它越高说明复制对象的成本越大,通常意味着存活对象多,或者对象引用关系复杂。
并发标记周期的日志里会有Concurrent Mark相关的标记,整个周期分为初始标记、根区扫描、并发标记、重新标记、清理几个阶段。逐一记录各阶段耗时,如果并发标记反复触发但无法在下一轮Mixed GC中消化完Old区垃圾,就要考虑IHOP设置是否合理。
还有一个信号是年轻代大小的变化。G1会根据暂停时间目标动态调整年轻代,如果日志里经常出现Eden容量的剧烈波动,说明G1非常吃力。此时我会手动把MaxGCPauseMillis调大10到20毫秒,观察波动是否缓解。这个方法效果非常直接。
5.2 常见问题速查表
我把高频问题整理成了表格,每一条都是实战撞过的墙。
| 问题现象 | 可能原因 | 处置建议 |
|---|---|---|
| Young GC极其频繁 | MaxGCPauseMillis设太小,Eden被过度压缩 | 调大目标暂停时间,观察实际停顿 |
| Full GC后内存仍紧张 | 存活对象过多,晋升速率远超预期 | 分析业务代码,减少长生命周期对象 |
| 日志频繁出现Humongous Allocation | Region太小,或代码频繁创建大数组 | 调大Region,或优化大对象分配 |
| 堆外内存占用异常高 | RSet过大,Region数量太多 | 调大Region大小,升级JDK版本 |
| Mixed GC迟迟不触发 | G1HeapWastePercent设太高 | 调低到3%或5%,让Mixed GC更积极 |
| 并发标记反复执行但没效果 | Old区垃圾率太高,标记跟不上分配 | 调低IHOP,提前介入回收 |
这张表不能直接当结论用,但它能帮你缩小排查范围。遇到GC问题,我的排查顺序永远是:先拉GC日志统计,再查大对象分配,再调参数观察;千万别跳过日志直接拍脑袋改参数。
5.3 实战复盘:一次HBase GC延迟过高的排查记录
这里分享一个HBase RegionServer上G1调优的实战过程。现象很典型:RegionServer的GC暂停频繁超过500毫秒,业务侧读写延迟随之飙升,社区里也常有人抱怨HBase配G1后延迟太高。
第一步看GC日志,发现Young GC停顿本身并不长,出问题的是伴随并发标记周期结束后的Mixed GC,以及偶尔冒头的Full GC。继续翻日志,注意到堆中有大量包含Humongous Allocation的记录,进一步定位是HBase在执行某些大scan操作时,客户端一次性读取大量KeyValue数据,在堆中生成了大数组。
第二步调整参数,把Region大小从16MB调到32MB,同时将IHOP从45%调到35%,并将MaxGCPauseMillis设置到200毫秒。第三步优化客户端Scan逻辑,限制每次RPC读取的条数,避免大数组频繁分配。三步做完,Full GC彻底消失,Mixed GC停顿稳定在250毫秒左右,整个集群的P999延迟下降了将近一半。
这个案例说明,GC调优很多时候不只是一个参数问题。参数、代码、使用姿势三者都要兼顾,单纯靠调参去背锅,往往事倍功半。
5.4 调优的底线思维:什么时候该停止折腾
最后这条经验可能最值钱。G1调优是有边际收益递减的,不必追求极致的暂停时间。我见过有人花两周时间,把平均GC停顿从80毫秒压到60毫秒,但付出的代价是团队大量精力和线上多次变更风险。
判断调优是否到位,我觉得看两个指标就够了:一是Full GC出现的频率,如果一周以内一次都没有,说明系统在G1的舒适区里;二是应用自身的SLA达标情况,如果接口P999满足要求,吞吐量也正常,GC停顿多一点少一点其实没那么重要。
当这两个指标都满足时,我建议停止调优,不要因为看到GC日志里某些数字不顺眼就继续折腾。G1的设计初衷本来就是自动化回收,人工介入越少越稳定。理解和敬畏它的自我调节机制,比掌握多少调参技巧更重要。
我在实际项目中见过太多次Online事故,起因就是盲目套用网上的G1配置。尤其是Region大小和IHOP这两个参数,在不同堆大小、不同对象分配速率下,最优值差异巨大。所以我一般会建议团队把G1调参纳入压测环节,每一次参数变更都必须放到生产流量模型下验证,而不是拍脑袋上线。GC这种事,稳定比惊艳重要得多。