尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

G1垃圾回收器实战:从Region、RSet到Mixed GC的调优与排障

G1垃圾回收器实战:从Region、RSet到Mixed GC的调优与排障 凌晨2点的值班群突然沸腾了。某核心服务一整排报警Full GC耗时6秒GC Cause异常接口P99延迟直接翻了三倍。第一反应照旧——jmap dump堆、调整CMS参数、加年轻代但所有人都清楚这些只是治标。那次事故后我把G1从JDK 8老版本一路升级到较新的JDK把Region、Remembered Set、Mixed GC这三个概念彻底啃了一遍才真正敢说看懂了G1的运作逻辑。这篇文章就是那之后沉淀下来的复盘既讲原理也讲排障适合正在用CMS、准备迁到G1或者被G1参数搞到头疼的同行参考。1. 从一次Full GC告警说起CMS在低延迟场景下的困局1.1 CMS的运作逻辑CMSConcurrent Mark Sweep是JDK 5时代就存在的老牌垃圾回收器它的设计目标非常纯粹用并发标记和并发清除来减少老年代回收时的停顿时间。CMS在老年代内存达到一定阈值时启动不暂停应用线程完成大部分标记和清除工作只有初始标记和最终标记需要STW。这个设计在堆内存不大、对象存活率稳定的年代确实很管用。但要注意一个关键词不压缩。CMS的清除阶段只是把死亡对象所占用的空间挂到空闲链表上不会移动存活对象。这意味着老年代内存会逐渐碎片化新对象分配时只能从空闲链表中寻找合适的洞而不是像复制算法那样整体搬移。1.2 CMS的三个老毛病用一句大白话说CMS的困境就是停顿不可预测。具体拆解成三个问题内存碎片长时间运行后老年代被切成无数小碎片。大对象需要连续空间时找不到合适的hole于是触发Full GC。Concurrent Mode Failure并发清除期间应用线程还在持续产生垃圾如果老年代空间增长速度超过了回收线程的处理速度CMS会直接放弃并发阶段降级为Serial Old全量STW回收。这个停顿通常以秒为单位。浮动垃圾并发标记之后产生的垃圾对象没法在本轮回收只能留到下一轮进一步压缩了可用空间。在一个20GB堆、峰值流量打满的服务上这三个问题会交替出现。尤其是Concurrent Mode Failure几乎每次大促期间都会精准爆发。CMS也不是完全没法调可以增大老年代比例、提前触发并发周期但本质上是拆东墙补西墙碎片问题依然无解。G1就是在这种背景下被设计出来的。它换了一个完全不同的思路不再维护一整块连续的老年代而是把堆拆成大小相等的Region以Region为回收单位通过跟踪每个Region的回收价值来动态组成回收集合把停顿时间控制在可预测的范围。这套设计直接带来了三个关键概念Region、Remembered Set、Mixed GC。2. Region布局把大堆切成小块的收益与代价2.1 堆被切成了2048块G1把整个堆划分成若干大小相等且连续的内存区域这些区域被称为Region。JVM在设计上希望Region数量大约为2048个Region大小必须是2的幂次范围从1MB到32MB。默认情况下JVM会根据堆大小自动计算Region大小。我在实际配置中总结过一个快速估算方式堆大小除以2048再向上取整到2的幂次。举个例子堆16GB16GB / 2048 8MBRegion大小正好8MB。堆32GB32GB / 2048 16MBRegion大小16MB。堆2GB2GB / 2048 1MBRegion大小1MB。堆太小或太大时这个比例会兜底。可以通过-XX:G1HeapRegionSize参数手动指定Region大小但多数情况下没必要改让默认值自己算就好。Region这个设计带来一个直接好处回收的基本单位从整个老年代降级为若干个Region。G1可以只回收那些死亡对象比例最高的Region把有限的STW时间花在最值得回收的内存上。这与CMS每次必须处理整个老年代的思路完全不同。2.2 逻辑分代与物理分区脱钩在CMS和Parallel等传统收集器里Eden、Survivor、Old是物理上连续的内存段年轻代和老年代是两段独立的地址空间。G1打破了这种物理边界Eden、Survivor、Old、Humongous都只是Region的集合。每个Region在某一时刻扮演一个角色比如Eden、Survivor或Old角色可以在不同回收周期中被改变。年轻代不再是一个固定的大块连续区域而是位于堆上的多个Region的虚拟集合。这个设计是G1实现停顿预测的前提回收只需要处理CSetCollection Set中的RegionCSet里的Region数量可以动态调整目标是让本次停顿时间不超过设定的期望值。这里有一个容易忽略的细节老年代在G1中可能不连续。CMS的老年代必须是一个连续地址空间而G1的老年代是由分散的Region拼出来的地址上不连续也没关系。这给晋升和分配提供了更多弹性也在根本上规避了CMS那种找不到连续空间的碎片问题。2.3 Humongous Region大对象的特殊通道当对象大小超过Region容量的50%时G1会把这个对象直接分配到一个或多个连续的Humongous Region中而不是走标准的Eden分配路径。这个设计是为了避免大对象在年轻代内跨多个Region复制导致回收效率低下。Humongous Region在G1中会被直接视为老年代区域在并发标记周期中被回收但在Young GC阶段不会被移动。值得注意的是Humongous Region的回收代价并不低。由于一个巨型对象占据多个Region如果这个对象是垃圾只有等到并发标记周期完成这些Region才能被释放。如果大对象生命周期很长这些Region会被长期占用相当于老年代空间被无谓消耗。我在生产环境踩过一个相关坑某服务频繁创建几十MB的byte数组导致大量Humongous Region残留老年代使用率涨到70%以上还迟迟不触发Mixed GC最终直接Full GC。后来用jmap看过对象分布后才定位到是缓存加载逻辑的问题把缓存数组拆分后Humongous分配明显下降。3. Remembered SetG1的跨区引用记账本3.1 没有RSet时的窘境G1的年轻代回收有一个天然难题Eden和Survivor中的对象可能被老年代对象引用。如果每次Young GC都去扫描整个老年代来找这些跨代引用停顿时间就会随老年代规模增长这跟CMS一样不可控。G1的解法是给每个Region维护一张Remembered SetRSet记录其他Region中的哪些卡页引用了当前Region的对象。有了RSet之后回收某个Region时只需要检查它的RSet就能得知哪些外部Region引用了它而不用扫描全堆。用生活化的方式理解RSet就像每个Region的访客登记簿谁来过、从哪个门进来的都记得清清楚楚。垃圾回收时只需要翻自己的登记簿就能把所有根找到。3.2 Card Table与写屏障的配合RSet不是凭空生成的它依赖两个基础机制Card Table卡表和写屏障Write Barrier。JVM将堆内存划分为一个个固定大小的卡页通常每个卡页512字节。每次发生引用写入时JIT编译器会在指令序列中插入一段写屏障代码把目标对象所在卡页标记为dirty。CMS也使用卡表但它只需要判断这张卡是否脏然后在重新标记阶段扫描这些脏卡找出跨代引用。G1的写屏障比CMS复杂得多它不仅要把卡标记为脏还需要进一步判断这个引用是否跨Region并在跨Region时把这张卡信息加入到对应目标Region的RSet中。G1的这一步实际上分为两个阶段写屏障先把卡标记为脏后续专门的线程G1 ConcRefineThread再异步扫描脏卡更新RSet。这就是为什么G1对CPU有额外开销每次引用赋值都多了一两条指令。测试下来在对象引用密集的写操作场景下G1的写屏障开销确实比CMS略高好在多数业务系统并不属于这种模式。3.3 RSet的粒度、膨胀与内存代价RSet内部有三种粒度按层级递进稀疏表Sparse记录单个卡页存储量小时使用。细粒度位图Fine Grain当某个Region引用当前Region的卡页较多时升级为位图。粗粒度位图Coarse Grain当前Region被大量其他Region引用时直接用一份位图表示该Region的所有卡页均被引用。这种分级设计是为了控制内存占用。但RSet仍然是不可忽视的内存开销极端情况下RSet总占用可能达到堆内存的1%到5%。堆越大、跨Region引用越频繁RSet消耗的内存越多。此外RSet依赖后台线程不停处理脏卡如果应用线程写引用速度太快ConcRefineThread处理不过来写屏障可能会触发同步的RSet更新导致应用线程停顿。这个问题排查起来很隐蔽我在某个高并发写入场景中就遇到过Young GC本身只耗50ms但ConcRefineThread CPU长时间打满整体吞吐持续下降。后续通过对写密集代码做函数级采样把频繁赋值对象引用的热点代码优化掉问题才缓解。4. 并发标记与SATB为什么G1能保证不漏活对象4.1 三色标记算法的基本逻辑要理解G1的并发标记先要搞懂三色标记。GC把对象分为三种颜色白色未被访问的对象回收结束时仍然是白色的对象视为垃圾。灰色对象被访问过但它引用的子对象还没被全部扫描。黑色对象和它的所有引用都被扫描完成。并发标记的问题在于应用线程和标记线程同时在跑对象的引用关系时刻在变化。如果某个已经标记为黑色的对象在扫描之后又新增了一个指向白色对象的引用原来的白色对象就可能被漏标最终被错误回收。这个场景称为漏标。4.2 CMS的增量更新与G1的SATBCMS解决漏标的方法是增量更新Incremental Update当黑色对象新增引用指向白色对象时把这个黑色对象重新标记为灰色等待再次扫描。这样白色对象不会漏掉。G1采用的是另一种思路SATB全称Snapshot At The Beginning初始快照。G1在并发标记开始的那一刻给对象图打一个逻辑快照。并发标记执行期间如果某个引用的旧值被覆盖写屏障会把旧引用所指向的对象记录到一个SATB队列中后续标记时把队列中的对象当作存活对象处理。换句话说CMS关注的是新增了哪些引用G1关注的是哪些引用被移除了。SATB的优点是标记过程不要求重新扫描那些在并发期变活跃的对象标记效率更高代价是如果应用线程把某个对象的所有引用都切走了但这个对象本来存在于初始快照中它仍然会被标记为存活成为浮动垃圾。这一点在实际使用中很关键G1在每个并发周期后会残留一批浮动垃圾它们要等下一轮并发标记或完全空闲阶段才能被回收。不要因此认为G1有缺陷浮动垃圾是所有并发回收器共有的问题只是SATB让这个数量可能比增量更新更多一些。4.3 并发标记周期的触发与执行G1的并发标记周期包含五个阶段初始标记Initial MarkSTW停顿借助Young GC顺带完成标记GC Root直接可达的对象。并发标记Concurrent Mark与应用并发运行遍历对象图记录存活对象信息。最终标记Final MarkSTW停顿处理SATB队列中的剩余记录。清理CleanupSTW停顿统计每个Region的存活对象比例并清理空Region。并发清理Concurrent Cleanup并发重置空Region。整个周期什么时候启动默认情况下G1使用自适应IHOP但传统上由-XX:InitiatingHeapOccupancyPercent控制默认值为45%。当整个堆的使用率达到这个阈值时G1会在后续的Young GC前插入初始标记开始并发周期。这个45%的默认值不是随便设的它代表G1希望老年代堆占用达到45%时提前回收避免堆耗尽。如果业务对象分配速率极快并发标记赶不上分配速度堆使用率可能继续上涨最终触发Full GC。所以IHOP这个值既不能调太高也不能调太低需要根据实际对象分配速率来权衡。5. Young GC、Mixed GC与Full GCG1的完整回收节奏5.1 Young GCEden区回收G1的Young GC与传统收集器类似但触发条件不同。Eden区被填满时G1启动一次Young GC回收Eden和Survivor中的所有Region。存活对象会被复制到新的Survivor Region年龄达到晋升阈值默认15次的对象则晋升到老年代Region。这一阶段有几个G1特有的细节CSet的组成Young GC的CSet包括所有Eden Region和Survivor Region。G1会根据MaxGCPauseMillis参数的目标停顿时间动态调整年轻代Region的数量让本次停顿尽量不超过预期。晋升依赖老年代Region空间如果老年代没有足够Region接受晋升对象就会触发晋升失败Promotion Failure严重时直接降级为Full GC。Young GC同样依赖RSet回收Eden Region时需要从RSet中找到老年代Region对当前Eden对象的引用。没有RSet的话这一步就要全堆扫描。G1的Young GC在JDK 11之后开始支持并发标记与Young GC并行执行效率进一步提升但整体节奏没有变化。5.2 Mixed GC挑收益最高的老年代Region全名为Mixed GC的回收过程是G1最鲜明也最容易误解的地方。很多人以为G1只有在老年代满了才回收老年代实际上G1在并发标记完成以后会在后续连续多次Young GC中额外把一部分老年代Region加入CSet与年轻代一起回收。这种混合了年轻代老年代Region的GC就叫Mixed GC。老年代Region不是随便挑选的。G1会用启发式算法评估每个Region的回收价值存活对象比例越低回收释放的空闲空间越多带来的收益越大。同时G1会根据历史停顿数据预测如果加入这个Region本次停顿会增加多少时间。在预设的停顿目标内它会尽量多选择高收益Region加入CSet。下面两个参数直接影响Mixed GC的挑选策略-XX:G1MixedGCLiveThresholdPercent默认85%存活对象占比超过这个值的Region不会加入CSet因为回收它没有意义。-XX:G1MixedGCCountTarget默认8次表示并发标记完成后的老年代Region预计分8次Mixed GC回收完。还有-XX:G1HeapWastePercent默认5%。如果可回收的老年代Region总空间不足堆空间的5%G1认为回收性价比过低会直接跳过剩余的Mixed GC周期。实际调优中-XX:G1MixedGCCountTarget太小会让单次Mixed GC回收太多Region停顿时间超标太大则回收周期拉长老年代堆积。最稳妥的方法是通过GC日志观察每次Mixed GC停顿时间的趋势再调整这个目标值。5.3 Full GCG1的兜底方案G1的Full GC通常由以下场景触发并发标记还未完成但Eden区被填满且老年代没有足够Region容纳晋升对象。对象分配速度太快Mixed GC回收速度赶不上分配。Evacuation Failure复制存活对象时找不到空闲Region来容纳。Humongous Region分配失败。G1的Full GC采用单线程Serial Old的标记-整理-压缩算法STW时间可能长达数秒甚至十几秒。这是G1最难受的时刻也是生产环境最需要预防的事件。预防手段不是靠调整Full GC本身而是确保并发周期能在堆耗尽之前完成或者给堆留足缓冲空间。好在G1在多数版本中引入了并行的Full GC处理JDK 10之后G1的Full GC被改为并行执行情况缓解不少。用一段典型的GC日志来说明各个阶段的长相[GC pause (G1 Evacuation Pause) (young) 502M-186M(1024M), 0.0456048 secs] [GC pause (G1 Humongous Allocation) (young) (initial-mark) 186M-212M(1024M), 0.0730051 secs] [GC concurrent-root-region-scan-start] [GC concurrent-mark-start] [GC concurrent-mark-end, 0.4926935 secs] [GC remark, 0.0925033 secs] [GC cleanup, 0.0060682 secs] [GC pause (G1 Evacuation Pause) (mixed) 342M-118M(1024M), 0.0661040 secs]日志里能看到几次关键的转换initial-mark由一次Humongous Allocation的Young GC触发之后进入并发标记最终标记和清理完成后出现mixed回收。看到(mixed)字样就说明G1进入了混合回收阶段。6. G1与CMS核心对比一张决策表对比项CMSG1堆结构Eden、Survivor、Old物理连续全堆划分为大小相等的Region回收算法标记-清除不压缩基于Region的复制/标记-整理碎片问题严重长时间运行必然碎片化复制算法天然避免碎片停顿预测不可预测Full GC随时可能来可以按MaxGCPauseMillis调跨代引用Card Table扫描RSet精准定位并发标记增量更新SATB初始快照老年代回收CMS并发清除整个老年代Mixed GC按收益挑选Region并发失败Concurrent Mode Failure常见Evacuation Failure偶发内存额外开销较小RSet 并发表格额外占用较多JDK状态JDK 9废弃、JDK 14移除JDK 9起默认收集器CPU消耗并发标记和清除占用一定CPU写屏障和RSet处理占用更高适合场景堆小、追求低停顿的老系统大堆、要求可预测停顿的对延迟敏感系统CMS的核心优势在于业务线程并发标记阶段的CPU开销相对温和且停顿时间在并发阶段远远小于全量回收。但它的致命伤是碎片和停顿不可控就像一台刹车系统存在隐患的汽车平时开起来没问题关键时刻可能刹不住。G1的定位非常清楚面向大堆4GB以上和需要可控延迟的服务。如果你的堆只有1-2GBG1的优势体现不出来反而可能因为RSet内存开销和写屏障指令让吞吐量下降。从我个人的经验看以下情况建议优先考虑G1堆大小超过4GB甚至十几个GB。服务对RT敏感不能接受偶尔出现的几秒级Full GC。有充足的CPU资源可以容忍额外开销。业务活动频繁希望让GC停顿稳定在一个区间内。以下情况可能更适合保留CMS或转向其他收集器堆很小2GB以内对象分配和释放非常规律。吞吐量优先级高于延迟且CPU资源紧张。JDK版本还在8并且对G1了解不足短时间内无法完成稳定调优。JDK 11之后的ZGC和JDK 13之后的Shenandoah在超低延迟场景下比G1更激进它们把STW时间压缩到极短但吞吐开销和内存额外消耗也更大。如果你的业务能接受亚毫秒到毫秒级的停顿ZGC值得关注。7. 生产环境G1调优参数与三个实战问题7.1 常用参数和缺省值生产环境建议优先只设置必备参数避免过早优化。下面这组参数适合大多数G1服务-XX:UseG1GC -Xms16g -Xmx16g -XX:MaxGCPauseMillis200 -XX:ParallelGCThreads8 -XX:ConcGCThreads2 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent60一些参数背后的权衡逻辑值得展开说明MaxGCPauseMillis默认200ms。这不是硬性指标G1会尽力逼近。如果设置成50msG1会过度控制年轻代大小导致频繁Young GC反而影响吞吐。ParallelGCThreads默认值跟CPU核数相关。核心少时并行线程太多容易抢夺业务线程的CPU。ConcGCThreads约为ParallelGCThreads的1/4这个比例通常合适。G1HeapRegionSize手动设置成8m后16GB堆约2048个Region。Region太小会导致RSet管理和分区Region数量膨胀Region太大则大对象容易触发Humongous分配。在JDK 9以上版本中-XX:InitiatingHeapOccupancyPercent默认值被改成了45%但结合-XX:G1AdaptiveIHOP以外的场景时可以手动覆盖。不要在并发周期触发频繁时盲目调高IHOP先确认对象分配速率和Region存活情况。7.2 三个典型的实战问题第一个问题Humongous Region导致老年代快速膨胀。某服务的缓存预热逻辑每次加载可用20MB以上的二维数组大量对象被分配到Humongous Region。因为大对象只有并发标记后才能回收老年代占用率居高不下IHOP频繁触发每轮并发周期结束不久又触发下一次。最终方案是把20MB的大数组拆成多个1MB以内的数组块同时调整IHOP为50%老年代占用大幅回落。第二个问题Mixed GC停顿远超预期。观察日志发现每次mixed阶段停顿都超过300ms而纯young阶段只有60ms。查看G1 GC日志后发现-G1MixedGCCountTarget默认8次单次需要复制的老年代Region数量太多。把G1MixedGCCountTarget调到12并同时把G1MixedGCLiveThresholdPercent从85%提高到90%单次Mixed GC要复制的对象变少停顿降到100ms以内。代价是回收周期变长但整体可控。第三个问题RSet内存过大导致堆占用虚高。一台8GB堆的服务RSet竟然占了400MB以上。排查后发现大量短生命周期对象被长时间存活的Map持有引用每个Region的RSet都记录了大量交叉引用。通过让Map在不再使用时显示清理、减少跨Region长引用RSet下降显著。如果代码无法优化也可以用-XX:G1HeapRegionSize扩大Region减少Region数量从而降低RSet总量但会带来大对象判定阈值的变化需要一并验证。7.3 我个人的调优习惯最后分享几招我自己的实战习惯不一定适用于所有场景但值得参考。第一每次调整只动一个变量。G1的停顿预测依赖大量历史数据一次改多个参数会让后续日志分析失去基准出了问题也分不清是哪个参数引发的。第二多关注分配速率而不是只看GC日志。用jstat -gcutil观察Eden和Old区域的变化趋势对比对象分配速率和回收速率。只要分配速率持续高于回收速率不管怎么调G1参数都扛不住只能扩容或优化业务代码。第三一定要盯日志。线上环境开启以下两个参数-Xlog:gc*:file/var/log/gc.log:time,uptime,level,tags:filecount20,filesize100m -XX:HeapDumpOnOutOfMemoryErrorJDK 8使用的-XX:PrintGCDetails和-XX:PrintGCDateStamps在JDK 9以后已经被统一日志框架替代如果你还在用JDK 8继续用PrintGCDetails也没问题。拿到日志后先看FULL/GC pause关键字再锁定时间点排查思路会清晰很多。我在实际调优中最大的体会是G1不是一个换了就完事的收集器它的调优空间比CMS大但需要的观测能力也比CMS强。能把Region、RSet、Mixed GC这三件事从概念层面吃透再落到日志和参数上生产环境的GC问题就不再玄学了。
返回列表