ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

G1垃圾回收器原理与调优实战:从Region到暂停时间

G1垃圾回收器原理与调优实战:从Region到暂停时间 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在这个范围内做复制回收。同样重要的还有RSetRemembered 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的幂。比如堆大小32GBRegion大小设为16MB堆上就正好有2048个Region。如果你不指定Region大小G1会自己计算计算公式简单说就是从1MB开始做2的幂倍增直到Region数量最接近2048。为什么要卡在2048这个数因为Region数量如果太少每个Region就太大回收粒度太粗回收集的选择就不够灵活如果Region数量太多每个Region又太小RSet管理的开销会急剧上升。我实际测试过同样的32GB堆Region设成1MB和16MBGC日志和内存占用差别很大前者光RSet的开销就可能吃掉几百MB堆外内存。Region分成四类Eden Region、Survivor Region、Old Region和Humongous Region。前三种好理解Humongous Region是给大对象准备的。当对象大小超过Region的一半比如Region是16MB对象大于8MBG1不会把它放进普通Old Region而是直接在堆上找连续的一段Region来放这些Region会被标记为Humongous。这个设计带来的麻烦是大对象的分配和回收都不走常规复制路径一旦大对象频繁出现G1的回收效率就会显著下降。2.2 三色标记与SATB并发标记阶段的防漏标方案G1的并发标记阶段靠的是三色标记法把对象分成黑色、灰色、白色三个集合。白色代表还没被访问到灰色代表自身被访问到了但引用的对象还没全部分析完黑色代表自身和引用对象都分析完了。标记结束后白色对象就是可回收的垃圾。问题来了并发标记的过程中业务线程还在跑对象图一直在变化白色对象随时可能被别的存活对象引用上。如果漏标了这个引用应用就会拿到一个被回收的存活对象直接引发严重问题。G1解决漏标的手法是SATBSnapshot-At-The-Beginning意思是在并发标记开始的那一刻记录一个对象图的快照之后所有的引用变化都通过写屏障记录下来用以维持这个快照的完整性。具体实现里每个Region还有一个TAMSTop-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:G1HeapRegionSize16m设置Region大小大堆建议明确指定别靠默认-XX:MaxGCPauseMillis100期望最大暂停时间100或200起步别一上来就设20-XX:G1NewSizePercent5年轻代最小占比默认即可别乱改-XX:G1MaxNewSizePercent60年轻代最大占比堆大的时候可适当调低-XX:G1ReservePercent10预留内存比例防止晋升失败默认10%高分配速率场景可调高到15%-XX:InitiatingHeapOccupancyPercent45堆占用率达到该比例时触发并发标记默认45%要结合实际情况调-XX:G1MixedGCLiveThresholdPercent85存活率高于该值的老Region不参与Mixed GC默认85%-XX:G1MixedGCCountTarget8单轮并发标记后分成几次Mixed GC默认8次-XX:G1HeapWastePercent5可回收空间占比低于该值时不触发Mixed GC默认5%需要提醒的是G1的参数虽然多但大部分情况保持默认即可。我见过很多性能问题不是因为参数没调好而是因为改得太激进破坏了G1内部的自我调节机制。接下来我会挑两个直接影响效果的参数细说。3.2 目标暂停时间到底怎么设置才合理-XX:MaxGCPauseMillis是G1最核心的调参入口它告诉G1你期望的GC暂停时间上限。但很多人对这个参数有误解以为设得越小越好。实际上G1只是把这个值当作软目标来努力逼近不是硬性保证。设得太小G1会不断压缩年轻代大小结果Young GC的频率急剧上升吞吐量反而崩塌。我做过一次对比实验同一个服务MaxGCPauseMillis分别设100和20总体吞吐量相差接近20%。为什么因为设成20毫秒后G1每次只敢回收很少的RegionEden空间被压得很小对象分配几分钟就把Eden填满频繁触发Young GC每次停顿确实控制在20毫秒左右但总耗时反而暴增。从实践角度我建议按接口的SLA来定。如果接口允许200毫秒延迟设100或150比较合适如果接口要求50毫秒以内那G1未必是最优解甚至要考虑堆外缓存来降低GC压力。不要迷信“暂停时间越小越好”这是新手最常犯的错误。3.3 场景化调参大内存、高并发、低延迟三种组合调参必须结合场景我给出三套我实际验证过的参数组合供参考。第一套是通用大内存场景堆内存32GB以上。参数可以这样设-Xmx32g -Xms32g -XX:UseG1GC -XX:G1HeapRegionSize16m -XX:MaxGCPauseMillis200 -XX:ParallelGCThreads8 -XX:ConcGCThreads4-Xms和-Xmx保持一致避免运行期堆扩容带来的性能抖动。Region大小设为16MB保证Region数量在2048附近。ConcGCThreads设为ParallelGCThreads的一半左右避免并发标记和业务线程抢CPU。第二套是高并发互联网后端场景特点是对象分配速率高Young GC频繁。这时可以适当调大年轻代占比上限并开启并行引用处理-XX:UseG1GC -XX:MaxGCPauseMillis150 -XX:G1MaxNewSizePercent60 -XX:ParallelRefProcEnabled -XX:G1NewSizePercent10ParallelRefProcEnabled在引用对象多时很管用比如大量使用软引用、WeakHashMap的应用这个参数能显著缩短暂停里的引用处理阶段。第三套是低延迟敏感的金融类交易系统堆内存相对中等但延迟要求苛刻。核心思路是宁可让GC频率稍高也不能出现长暂停-XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1ReservePercent15 -XX:G1HeapWastePercent3 -XX:InitiatingHeapOccupancyPercent35把IHOP从默认45%降到35%意思是堆占用没到那么多就提前开始并发标记。代价是标记频率更高但好处是Old区有充足时间整理不容易突然走到Full GC。G1ReservePercent15则是预留更多空间给晋升对象降低晋升失败的概率。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:G1MixedGCLiveThresholdPercent90让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 AllocationRegion太小或代码频繁创建大数组调大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这种事稳定比惊艳重要得多。
返回列表