G1 GC深度调优:Region分区、Mixed GC触发条件、停顿时间控制
G1 GC深度调优:Region分区、Mixed GC触发条件、停顿时间控制
适读人群:Java中高级开发工程师、性能调优工程师 | 阅读时长:约20分钟 | 适用JDK版本:JDK 8u191+ / JDK 11 / 17 / 21
开篇故事
2020年,我们把一个核心支付服务从CMS GC迁移到G1 GC,踩了一个很经典的坑。
迁移前,CMS的配置是:堆8G,年轻代2G,老年代6G,CMS的停顿时间稳定在150-300ms。迁移到G1后,按照"官方建议"只配了-XX:MaxGCPauseMillis=200,其他全部用默认值,心想G1会自动调优。
上线第一周,监控显示GC停顿时间确实好了很多,大部分时间都在100ms以内。但第二周的某个下午,系统突然出现了一次长达12秒的停顿,业务全部超时,告警铺天盖地。
查GC日志,看到了这样的输出:
[GC pause (G1 Evacuation Pause) (mixed) 8192M->7936M(8192M), 12.3456789 secs]Mixed GC!而且停顿了整整12秒!
事后分析:由于堆8G,G1默认的Region大小是4MB,一共2048个Region。因为没有配置合适的-XX:G1HeapWastePercent和-XX:G1MixedGCLiveThresholdPercent,G1在一次Mixed GC中试图回收大量的老年代Region,而这些Region里有很多存活对象需要转移,导致耗时极长。
这次事故让我系统性地研究了G1的调优机制。最终把Mixed GC停顿时间控制在了80ms以内,比CMS还好。
一、问题根因分析
G1是一个面向服务端的GC,JDK 9开始成为默认GC。它的设计目标是:在大堆(通常4G以上)场景下,以可控的停顿时间实现高吞吐量。
G1的核心思想是把堆分割成大小相等的Region,每个Region可以扮演不同的角色(Eden、Survivor、Old、Humongous),GC时选择性地对部分Region进行回收,而不是对整个堆做GC。
G1调优的三个核心问题:
问题一:停顿时间超过目标值。-XX:MaxGCPauseMillis只是一个期望值,G1会尽力实现,但不保证。如果实际停顿时间持续超标,说明G1的Pause Prediction Model预测不准,或者单次需要回收的工作量超出了停顿时间的限制。
问题二:Mixed GC触发太晚或太激进。Mixed GC是G1回收老年代Region的主要手段。触发太晚,老年代过于膨胀;太激进,单次停顿时间过长,就是我遇到的情况。
问题三:Humongous对象处理不当。大于Region大小一半的对象被称为Humongous对象,直接分配在连续的Humongous Region中,不参与普通的Young GC,处理不当会导致空间碎片和提前触发Full GC。
二、原理深度解析
2.1 G1的Region设计
Region大小由-XX:G1HeapRegionSize控制,取值范围1MB~32MB,必须是2的幂次。JVM会根据堆大小自动计算(目标是约2048个Region):
| 堆大小 | 默认Region大小 | Region数量 |
|---|---|---|
| 2GB | 1MB | 2048 |
| 4GB | 2MB | 2048 |
| 8GB | 4MB | 2048 |
| 16GB | 8MB | 2048 |
| 32GB | 16MB | 2048 |
| 64GB | 32MB | 2048 |
2.2 G1的GC类型
G1有三种GC类型,理解它们的触发条件是调优的基础:
Young GC(Evacuation Pause - young):当Eden区满了,触发Young GC。所有Eden Region和Survivor Region中的存活对象被复制到新的Survivor或Old Region。这是停顿时间最可控的GC类型。
Mixed GC(Evacuation Pause - mixed):在Young GC的基础上,额外回收一部分老年代Region。回收哪些老年代Region由垃圾收集效率(存活对象比例最低的Region先回收)决定。
Full GC:当Mixed GC来不及回收老年代空间,导致对象分配失败时触发。G1的Full GC是单线程串行的(JDK 10之后变成并行),会造成极长时间的停顿,应该尽一切手段避免。
2.3 并发标记过程(Concurrent Marking)
Mixed GC之前必须完成并发标记,以确定每个Region中存活对象的比例:
触发并发标记的条件是:老年代占堆的比例超过-XX:InitiatingHeapOccupancyPercent(IHOP),默认45%。
这个45%的阈值非常重要:
- 设得太低:并发标记频繁触发,CPU开销大
- 设得太高:老年代增长过快,来不及回收就触发Full GC
2.4 Mixed GC的触发与控制
并发标记完成后,G1会进行若干次Mixed GC来回收老年代Region。每次Mixed GC回收哪些Region,受以下参数控制:
# 老年代Region垃圾比例阈值,低于此比例的Region不参与Mixed GC回收
# 默认85%(即只回收那些垃圾比例 >= 85% 的Region)
-XX:G1MixedGCLiveThresholdPercent=85
# 允许的堆浪费比例(垃圾占总堆的比例低于此值时,停止Mixed GC)
-XX:G1HeapWastePercent=5
# 每次Mixed GC最多回收多少个老年代Region(占老年代Region总数的比例)
-XX:G1MixedGCCountTarget=8 # 默认8,即Mixed GC分8次完成,每次回收1/8
# Mixed GC最多执行多少次,超过后如果仍然有足够垃圾则继续
-XX:G1OldCSetRegionThresholdPercent=10 # 每次Mixed GC老年代Region上限2.5 停顿时间预测模型
G1内置了一个基于衰减统计的停顿时间预测模型(Pause Prediction Model)。G1根据历史GC的统计数据(每个Region的存活对象复制时间等),预测下次GC能在-XX:MaxGCPauseMillis时间内回收多少Region,然后选择合适数量的Region加入CSet(Collection Set)。
# 停顿时间目标(毫秒),默认200ms
-XX:MaxGCPauseMillis=200
# 停顿时间的统计置信度调整
# 不建议修改,默认值经过了大量调优关键点:如果Region的存活对象过多,单个Region的回收时间就很长,预测模型很容易低估停顿时间,导致超标。
2.6 Humongous对象的特殊处理
大于Region大小50%的对象被直接分配在Humongous Region。连续的Humongous Region只允许一个大对象使用。
Humongous对象的问题:
- 分配时如果找不到连续的空闲Region,会触发Full GC
- 在JDK 8u60之前,Humongous对象在Full GC之前不会被回收
- JDK 8u60之后,Humongous对象可以在Young GC时被回收(如果没有引用指向它)
三、诊断工具与命令
3.1 分析G1 GC日志
# 启用G1详细日志(JDK 8)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintAdaptiveSizePolicy # 打印自适应策略决策
-XX:+PrintTenuringDistribution
-Xloggc:/var/log/gc.log
# JDK 11+统一日志格式
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
# 关键日志解读
# [GC pause (G1 Evacuation Pause) (young), 0.0234567 secs]
# 年轻代GC,停顿23ms
# [GC pause (G1 Evacuation Pause) (mixed), 0.1234567 secs]
# Mixed GC,停顿123ms
# [GC pause (G1 Humongous Allocation)]
# Humongous对象分配触发的GC
# [Full GC (Allocation Failure)]
# Full GC,应该尽量避免3.2 查看G1 Region状态
# 在GC日志中查找Region使用情况
# 开启Region详细统计
-XX:+G1PrintRegionLivenessInfo # JDK 8(需要-XX:+UnlockDiagnosticVMOptions)
# 查看Heap Region信息
jcmd <pid> GC.heap_info
# 用jstat监控G1
jstat -gcutil <pid> 1000
# O列超过45%时,很快会触发并发标记3.3 识别Humongous对象
# 开启Humongous对象分配日志
-XX:+G1TraceHumongous # 需要 -XX:+UnlockExperimentalVMOptions
# 或者通过heap dump分析大对象
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 用MAT查看:Leak Suspects → 找出大对象四、完整调优方案
4.1 G1调优的基准配置
# 适用于8G堆的生产配置
-Xms8g
-Xmx8g
-XX:+UseG1GC
# 停顿时间目标
-XX:MaxGCPauseMillis=200 # 根据业务SLA设置,不要设太激进(<50ms难保证)
# Region大小(8G堆默认4MB,通常不需要修改)
# -XX:G1HeapRegionSize=4m
# 并发标记触发阈值(默认45%,可以根据业务适当调整)
-XX:InitiatingHeapOccupancyPercent=40 # 提前触发标记,避免来不及回收
# Mixed GC控制
-XX:G1MixedGCLiveThresholdPercent=85 # 默认85%
-XX:G1HeapWastePercent=5 # 默认5%
-XX:G1MixedGCCountTarget=8 # 默认8
# 新生代大小(让G1自动管理,不要强制指定)
# 如果不设置,G1会在5%-60%之间自动调整年轻代大小
-XX:G1NewSizePercent=5 # 年轻代最小比例,默认5%
-XX:G1MaxNewSizePercent=30 # 年轻代最大比例,默认60%
# Metaspace
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# GC日志
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m4.2 针对不同问题的调优策略
场景一:停顿时间超过目标值
# 减少每次GC的工作量
-XX:MaxGCPauseMillis=300 # 适当放宽目标,让G1有更多选择空间
-XX:G1MaxNewSizePercent=30 # 限制年轻代最大比例,减少Young GC的工作量
-XX:G1HeapRegionSize=16m # 增大Region,减少Region数量,降低GC overhead场景二:Full GC频繁
# 提前触发并发标记,给Mixed GC更多时间
-XX:InitiatingHeapOccupancyPercent=30 # 从默认45%降低到30%
# 加快Mixed GC进度
-XX:G1MixedGCCountTarget=4 # 从8次减为4次,每次回收更多Region
-XX:G1OldCSetRegionThresholdPercent=20 # 从10%提高到20%
# 降低垃圾阈值,让更多Region参与回收
-XX:G1MixedGCLiveThresholdPercent=65 # 从85%降低,更积极地回收老年代场景三:Humongous对象频繁分配
# 增大Region大小,使更多对象不被视为Humongous
-XX:G1HeapRegionSize=16m # 只有>8MB的对象才是Humongous
# 从代码层面减少大对象分配
# 常见的大对象:大数组、大ByteBuffer、大字符串
# 优化:使用对象池、分批处理、流式处理4.3 G1参数调优的迭代流程
1. 收集基线数据(GC日志 + jstat + 业务指标)
↓
2. 分析GC类型分布(Young/Mixed/Full占比)
↓
3. 针对主要问题调整1-2个参数
↓
4. 灰度验证(先10%流量)
↓
5. 对比指标(GC次数、停顿时间、吞吐量)
↓
6. 全量上线或回滚五、踩坑实录
坑一:同时设置-Xmn和G1,导致G1自适应失效
从CMS迁移到G1时,保留了-Xmn2g的配置,强制年轻代固定2G。G1之所以好用,很大程度上是因为它能根据停顿时间目标动态调整年轻代大小。强制固定年轻代后,G1的自适应能力完全丧失,停顿时间比CMS还差。
删掉-Xmn,改用-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent设置范围,让G1自己决定年轻代大小,问题解决。
坑二:MaxGCPauseMillis设置太激进
有次把-XX:MaxGCPauseMillis从200改成了50,希望把停顿时间控制在50ms以内。结果GC吞吐量下降了15%,系统整体CPU使用率上升,业务TPS反而下降了。
原因是:为了满足50ms的停顿目标,G1每次GC只能回收少量Region,GC的频率变得非常高,GC总时间反而增加了。对于大多数业务系统,200ms的停顿目标已经足够,过度追求低停顿反而适得其反。
坑三:忽略了G1并发标记的CPU开销
把-XX:InitiatingHeapOccupancyPercent从45%降到20%,希望更早触发并发标记,避免Full GC。效果确实达到了,Full GC消失了,但发现系统在高峰期CPU使用率莫名其妙地升高了5%。
排查发现:IHOP太低导致并发标记触发过于频繁,并发标记是多线程后台任务,会占用应用CPU。G1并发标记默认用-XX:ConcGCThreads个线程(通常是逻辑CPU数的1/4),频繁并发标记会持续消耗CPU资源。
最终把IHOP调整为35%,在Full GC消失和CPU开销可控之间找到了平衡点。
坑四:Humongous对象分配失败引发的Full GC
系统里有个功能会批量读取数据,生成一个大List,序列化后保存。这个List的序列化结果是一个2MB的byte数组,而当时Region大小是4MB,2MB大于4MB的50%,是Humongous对象。
业务高峰期Humongous对象分配频繁,频繁导致G1找不到连续空闲Region,触发Full GC。
解决方案:把Region大小从4MB增大到16MB(-XX:G1HeapRegionSize=16m),2MB的对象就不再是Humongous了。同时从代码上优化,把大批量操作改成分页,每页最多返回200条,序列化后不超过200KB。
六、总结
G1不是配一个-XX:MaxGCPauseMillis就完事的,真正用好G1需要理解它的几个核心机制:
第一,G1的Region设计让堆管理更灵活,但同时也带来了更多可调参数。调优时要从宏观到微观:先确定停顿时间目标,再分析GC类型分布,最后针对具体问题调整具体参数。
第二,并发标记(Concurrent Marking)的触发时机(IHOP阈值)是影响Full GC频率的关键。IHOP设置要考虑从触发标记到完成Mixed GC的时间窗口,这段时间内老年代的增长量不能超过剩余空间。
第三,Mixed GC的粒度控制参数(G1MixedGCCountTarget、G1MixedGCLiveThresholdPercent、G1HeapWastePercent)共同决定了Mixed GC的激进程度。激进的配置减少Full GC风险,但每次停顿时间增加;保守的配置每次停顿短,但可能来不及回收老年代。
第四,Humongous对象是G1的软肋,频繁分配大对象要么增大Region Size,要么从代码层面拆分大对象。
G1调优没有万能配置,要根据实际GC日志做有数据支撑的迭代调整。
