
G1垃圾收集器Garbage-First垃圾优先从JDK 7u4开始以实验特性登场到JDK 9转正成为默认收集器这十几年里它几乎是每个写Java服务端的人都要面对的技术选型。而G1最核心的两块基石恰恰就是标题里这两个词Region分区和混合回收机制。前者决定了G1怎么管理堆内存后者决定了G1怎么把停顿时间控制在可预测的范围里。这篇文章我不打算堆概念而是从设计动机讲起配合参数和排障经验把G1这套机制拆开揉碎了讲明白。适合已经在用G1、想搞懂它内部原理、或者正在排查GC问题的读者。1. 为什么是G1传统收集器的死穴与G1的破局思路1.1 传统分代模型的三块硬伤想看明白G1先得知道它替掉的老家伙们到底哪里不行。以Parallel和CMS为代表的收集器堆内存被物理上切成年轻代和老年代两块年轻代里再分Eden和Survivor。对象先在Eden出生Minor GC时把存活对象往Survivor拷贝熬过几轮之后再晋升到老年代。这套面向“小堆、低并发”时代设计的模型放到如今动辄几十GB堆的后端服务上至少有三个硬伤。第一是碎片化问题。CMS用标记-清除算法处理老年代标记完垃圾活对象原地不动被回收的空间东一块西一块地空出来就像拆了一堵墙砖头散落一地想砌一堵新墙却找不到足够大的连续空间。碎片积累到一定程度老年代明明还有大量空闲却分配不出一个大数组只能被迫触发Serial Old兜底停顿时间直接飙到几秒甚至十几秒。我见过不少业务方在切G1之前就是被这种不定期出现的Full GC折磨到崩溃。第二是停停顿时间不可控。Parallel很实在追求吞吐优先GC停下来就把活干完停顿长短全看堆有多大之前跑批任务可以接受放在在线接口上就出事。CMS虽然把大部分标记工作放到并发阶段但并发模式失败、晋升失败这些意外仍然可能触发一次串行Full GC停顿同样拉满。第三是伸缩性差。CMS基于物理分代年轻代和老年代边界固定跨代引用用卡表记录这套结构在GB级堆上还能应付。堆一旦到几十GB老年代扫描和碎片整理的成本会指数上升再怎么调参数也只是打补丁。补充一个背景G1的雏形来自2004年前后的一篇论文核心思路不是“把GC做得更快”而是“让GC优先回收那些垃圾最多的区域”。这个设计取向跟传统收集器完全不同理解了这一点后面所有机制都能从名字里推出来。1.2 “垃圾优先”这个名字本身就是设计文档G1把堆切成很多大小相等的Region不再有物理上的年轻代老年代之分年轻代和老年代转而变成一组Region构成的逻辑概念。这套设计带来的直接好处是G1不必每次都回收整个堆而是可以在一个停顿周期里只挑一部分Region来处理。挑选依据就是两个指标回收成本指把这个Region里的存活对象拷贝出去要花多少时间回收收益指清理完能腾出多少空间。G1维护了一张按性价比排序的候选Region列表每次在停顿时间预算内挑收益最高的先进回收集合这就是Garbage-First的本意。打个比方保洁团队打扫一栋办公楼时间只够10分钟不会傻到从一楼开头一层层扫肯定是先去垃圾最多、打扫最快的房间能清掉多少是多少剩下的下一轮再来。所以G1在设计上追求的是一种折中不要求某一次GC把堆彻底清干净而是用多轮小的、可控的GC把整个堆慢慢过一遍。年轻代GC解决Eden和Survivor里的对象混合回收再顺带处理老年代里那些“垃圾率高”的Region两套动作配合起来把STW时间压进一个可以接受的区间。这就是它的破局思路。2. Region分区把堆切成棋盘格之后的世界2.1 Region大小是怎么定的G1把堆划分成一组大小相等、内存连续的Region每个Region既可以扮演Eden也可以扮演Survivor还可以作为老年代Region存放晋升对象另有一类Humongous Region专门处理超大对象。Region大小默认由JVM根据堆大小自动推算目标是把Region数量控制在2048到4096个之间。实际推算时会按2的幂取整我用常见堆规格整理了一张参考表堆大小范围Region大小约小于4GB1MB4GB到8GB2MB8GB到16GB4MB16GB到32GB8MB32GB到64GB16MB64GB以上32MB这个数量区间是内存管理精度和元数据开销之间的平衡。Region太小数量就多每个Region都要维护RSet等辅助结构光这些元数据就可能吃掉不少堆空间。Region太大回收粒度太粗一个Region里可能既有大量垃圾又有不少活对象拷贝成本上去了停顿时间就更难压。实际调优中我强烈建议先用默认值除非你对超大对象有明确的痛感否则手动设置-XX:G1HeapRegionSize很容易干扰G1内部的启发式算法得不偿失。2.2 Region状态流转与年轻代动态调整Region在工作时处于不同的状态空闲、年轻代、老年代、Humongous还有回收过程中出现的临时状态。分配对象时线程本地分配缓冲TLAB依然照常工作只不过Eden不再是一整块连续大内存而是由一组Eden状态Region拼出来的逻辑Eden。GC结束后被清空的Region变回空闲状态等待下一次被重新赋予角色。这里值得多说一句年轻代大小是动态的。G1通过-XX:G1NewSizePercent默认5%和-XX:G1MaxNewSizePercent默认60%限定年轻代占比的区间再根据分配速率和停顿预测模型不停调整。分配速率高年轻代就扩容降低晋升压力老年代占用涨得快年轻代就稍微缩一点给老年代留空间。整个过程的目标是让预测出的停顿时间不超过-XX:MaxGCPauseMillis默认200ms的期望值。这个动态机制我后来跟新人解释时用过一个类比快递站不会把分拣区固定成某个面积而是看每天包裹量动态调整包裹多就临时扩点包裹少就缩回来给存储区腾地方。G1的年轻代调整也是一样的思路固定比例反而是亲手废掉了它的自适应能力。2.3 Humongous Region大对象的专属通道当对象大小超过Region大小的一半G1不会让它走普通分配路径而是直接放进连续的一个或多个Humongous Region里并且不参与正常的年轻代晋升。这个设计经常被忽略但它对性能的影响相当直接。Humongous对象分配很贵因为要寻找一段连续的Region序列分配失败时还可能引发一次较长的停顿。更麻烦的是如果Humongous对象长时间存活它占用的Region基本不会被混合回收盯上老年代可用空间看着很大实际被这些“巨无霸”占着空间利用率上不去。大量超大对象还可能造成Humongous区的碎片表现就是老年代占用率不高却反复触发回收。我在项目里处理过大数组、大缓存这类场景常用的办法两三个第一把大Buffer拆成小对象池或分段数组降低单对象体积第二改成堆外内存管理让GC完全不感知第三实在拆不了就根据对象典型大小反推Region配置。但无论如何优先还是从业务侧减少超大对象数量而不是指望GC兜底。2.4 RSet与卡表跨Region引用的记账本Region分区最核心的代价是跨Region引用变成了常态。原来CMS只需要处理年轻代和老年代之间的引用G1却要对任意Region之间的引用负责。回收一个Region前必须知道哪些Region里有对象指向它否则里面的对象被清掉别的Region还在引用内存就坏了。G1的解法是给每个Region维护一张Remembered Set以下简称RSet用来记录“谁引用了当前Region里的对象”。RSet底层由卡表支撑写屏障在每次引用赋值时把对应卡页标记为Dirty后台的Concurrent Refinement线程再把这些Dirty卡里的引用信息同步到目标Region的RSet里。这套机制等于给每个Region建了个记录本随时查得出它被谁依赖。有得必有失RSet的代价是写操作的额外开销和它自身的内存占用。经验数据显示RSet可能吃掉堆内存的1%到2%堆越大、引用越分散越严重。这也是部分应用从CMS切到G1后发现整体内存占用反而涨了的原因之一。RSet异常膨胀时GC日志里能看到引用更新相关的耗时明显变长排查方向一般是密集的随机对象引用、频繁修改的缓存字段、或者短期存活对象太多导致更新量激增。多数情况下G1的RSet会自动收紧给它一点时间别急着动参数。3. 混合回收机制G1的GC周期是怎样运转的3.1 一个完整GC周期的四个阶段G1的一个完整GC周期可以分为四个阶段年轻代GC、并发标记、混合回收以及作为兜底存在的Full GC但Full GC并非每个周期都出现。年轻代GC是最高频的只回收Eden和Survivor把存活对象拷贝到新的Survivor或者晋升到老年代。这个过程是STW但因为只处理年轻代停顿通常很短几十毫秒以内很常见。年轻代GC的逻辑跟传统收集器有些像但区域不连续、进度可视所以G1可以精确地告诉运营人员这次GC回收了多少Region、耗时多少。当老年代占用率达到-XX:InitiatingHeapOccupancyPercent默认45%时G1启动并发标记。注意G1不是等老年代满了才动手而是在占用率到这个阈值之后就开始“边用边打扫”。标记阶段又细分为初始标记、并发标记、最终标记和清理几步最终产出是给每个老年代Region标注“存活对象占比”供后续混合回收做决策。真正的主角是混合回收。标记完成后G1会把老年代里垃圾比例高的Region挑出来跟年轻代Region一起放进回收集合Collection Set简称CSet然后用类似年轻代GC的拷贝方式回收。混合回收通常执行多轮直到把标记出来的Region处理完然后进入下一个GC周期。这套节奏保证了单次停顿可控整体又能持续释放空间。3.2 CSet挑选收益优先的停顿预算分配混合回收的核心是CSet的构建算法。G1给所有老年代Region按“回收收益/回收成本”排序收益主要看能释放多少垃圾空间成本看存活对象拷贝量和回收耗时。然后在一次停顿的预算内从性价比最高的Region开始逐个加入CSet直到预算被填满。这个决策过程非常像人整理房间的直觉时间有限先收那些垃圾多、又好整理的地方而不是按房间顺序依次来。CSet挑选有几个值得注意的细节年轻代Region是必进的这部分属于低延迟刚需老年代Region按性价比顺序进入累计耗时逼近预算上限就停-XX:G1MixedGCCountTarget默认8控制一轮混合回收周期内期望的混合GC次数G1会通过调整每次CSet大小来自动满足这个目标。实际观察中一次混合回收通常能清理几十个Region而老年代可能有上千个Region所以要好几轮混合GC才能把标记出来的Region都过完。这也是为什么混合回收会持续一段时间别看到连续几次Mixed就觉得异常这是正常节奏。3.3 并发标记混合回收的“情报收集”混合回收的质量完全取决于并发标记能不能摸清老年代的底细。标记阶段结束后每个老年代Region都会得到一个存活对象占比。G1把存活占比低于G1MixedGCLiveThresholdPercent默认85%的Region视为可回收候选存活比例太高说明里面大部分对象都在用回收代价太大干脆先不动。还有一个容易被忽略的参数-XX:G1HeapWastePercent默认5%它表示允许堆中浪费的空间比例。只有当可回收的垃圾量超过这个比例时G1才会真正启动混合回收。换句话说如果老年代虽然标记完了但可回收空间太少G1会判断“这次停顿花得不值”宁可先不回收。这个设计意图很明确停顿预算要花在刀刃上。标记阶段包含几个STW小步骤初始标记、最终标记、清理以及一次并发清卡表操作。对延迟敏感的系统这些STW虽然单次很短但叠加起来还是需要关注。JDK 8时代通常要手动开启类卸载走并发标记的参数到了JDK 11以后默认开启这一步能明显减轻最终标记阶段的负担。3.4 复制收集为什么G1不会产生碎片混合回收落地执行时会把CSet里每个Region的存活对象复制到其他空闲Region完成后原Region清空变回Free状态。这个过程不是传统标记-清除那种“原地标垃圾”而是“把活的东西搬走”搬完整个Region直接空白。复制收集有两个好处。一是天然无碎片因为对象搬过去之后是紧凑排列的新对象分配时分配器效率高不会出现“有空间却分配不出来”的尴尬。二是清空后的Region可以直接复用分配新对象时不存在找连续空间的问题。代价也摆在明面上对象拷贝需要额外空间而且拷贝期间内存压力短暂上升。所以G1在CSet挑选时极度警惕存活率过高的Region因为搬一批活对象不仅慢清完还没腾出多少空间纯粹是赔本买卖。我遇到过混合回收执行了七八轮老年代占用率纹丝不动的场景查日志发现CSet里选进来的Region存活率普遍在80%以上等于把一堆活对象搬了个家垃圾却没清掉多少。根因是一个缓存类对象长期驻留在老年代一直有引用G1拿它没办法。最后靠调整缓存失效策略才把占用率压下去。这个案例说明混合回收只能锦上添花真正解决内存问题还得靠业务侧的内存治理。4. G1关键参数解析与调优思路4.1 核心参数速查表G1的参数体系看起来繁杂但真正值得记住的就那么几个。我把日常排查和调优中用得最多的整理成了一张表参数默认值作用注意事项-XX:MaxGCPauseMillis200ms期望的最大停顿时间软目标不保证绝对达标-XX:G1HeapRegionSize自动推算Region大小很少需要手动修改-XX:InitiatingHeapOccupancyPercent45%触发并发标记的老年代占用阈值设太小标记频繁设太大回收滞后-XX:G1MixedGCCountTarget8一轮混合回收期望的混合GC次数G1会自适应调整不必强求-XX:G1HeapWastePercent5%堆中允许浪费空间的比例垃圾量低于它不回收容易被忽略但很关键-XX:G1NewSizePercent5%年轻代最小占比动态调整的下限-XX:G1MaxNewSizePercent60%年轻代最大占比过大会挤压老年代空间-XX:ParallelGCThreads自动并行GC线程数调太大容易导致CPU飙高-XX:ConcGCThreads自动并发标记线程数一般保持默认即可这张表我不想写成文档复读只想强调一点默认值是G1团队基于大量场景调出来的基线调整任何参数之前先问自己一个问题——我观察到的现象是哪个参数能解决的答不上来就先别动。4.2 调优路径先看日志再动参数我见过很多团队的调优方式是“照着网上文章抄一遍配置”这是最不推荐的。G1本身就是自适应设计大部分场景默认配置已经足够好。正确的调优路径应该分三步。第一步是确认问题。先看GC日志里STW时间是否超过业务容忍阈值。JDK 8用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.log记录JDK 9之后用-Xlog:gc*:file/path/gc.log。拿到日志后重点关注两类信息每次GC的实际停顿时间、以及停顿时间里各个阶段的耗时分布。第二步是定位瓶颈。停顿变长通常有三类原因年轻代过大导致Young GC拷贝量大并发标记过于频繁拖累吞吐混合回收阶段CSet过大。日志里阶段名写得很清楚比如Pause Young (G1 Evacuation Pause)的耗时集中在Evacuation那就是对象拷贝环节出了问题再去看Region拷贝量和Survivor晋升情况。第三步是最小干预。如果Young GC频繁但停顿只有20ms完全没必要调参。如果停顿超过预期先试着把MaxGCPauseMillis从200ms改成100ms甚至50ms观察G1能不能真的压下来。记住这是软目标堆内存需求极大或者吞吐优先的场景下G1会容忍超时。一次只改一个参数改完保留前后两份日志做对比这是我一直坚持的习惯。4.3 容易踩的调优误区调优路上我踩过不少坑挑三个有代表性的说说。一个是把MaxGCPauseMillis设成1ms妄想压出极致低延迟。结果G1的停顿预测模型一看目标这么苛刻把年轻代压缩到极小Young GC变得非常频繁吞吐率直接崩盘。目标值设在合理区间一般50ms到200ms别跟数学模型较劲。另一个是盲目调大Region大小为了让Humongous对象不“超标”。结果是回收粒度变粗RSet内存开销上升停顿反而变长。建议先用日志确认Humongous分配的实际情况再决定动不动Region配置而不是拍脑袋。还有一个是关闭自适应。有人为了“配置更可控”把年轻代占比固定死或者把并发线程数手动拉满相当于亲手废掉了G1最核心的启发式能力。G1的价值就在于自适应你要控制的是业务行为和堆大小而不是越俎代庖替它做决策。5. 常见问题与排查经验实录5.1 高频问题速查表日常答疑中有几个问题出现频率特别高我汇总成一张速查表方便大家按图索骥问题现象可能原因排查思路Young GC停顿突然变长Eden区过大、存活对象多、RSet更新量大看日志中Evacuation耗时检查分配速率和Survivor晋升量老年代占用持续不降存活率高、G1HeapWastePercent设置过大看标记阶段存活率检查是否有长期驻留的缓存对象Full GC频繁出现Humongous分配失败、堆空间不足、并发回收失败查Full GC触发原因分析Humongous对象必要时加大堆混合回收迟迟不执行标记未完成或可回收垃圾量低于阈值检查IHOP触发状态看日志中并发标记周期是否正常结束元数据区不断增长类加载过多或类卸载未生效确认JDK版本下的类卸载参数配置RSet内存占用过高引用更新频繁、对象引用分散优化代码引用结构谨慎调整相关参数这张表只能帮你定位方向真正的答案永远在GC日志里。我见过太多人拿着问题来问第一句话都是“帮我看下参数”结果我要求先看日志问题基本自己就浮出水面了。5.2 一次混合回收长停顿的排查全过程拿一个真实项目里的case说事。某服务堆内存24GB跑在G1上现象是每天固定时间段出现300ms左右的STW业务方明确表示不能接受。排查过程大致分四步。第一步是打开GC日志先确认是哪个阶段在拖时间。日志里最大的停顿不是Young GC而是Pause Mixed耗时到了250ms到350ms量级。点开明细看到Evacuation阶段拷贝了超过8GB的存活对象这个数字立刻抓住了我的注意力。第二步是反推CSet构成。混合回收的CSet由年轻代Region加上老年代候选Region组成拷贝8GB说明CSet非常大。结合时间点刚好是并发标记完成后的第一批混合回收G1把一堆老年代Region选进了CSet而老年代里存活对象又多拷贝量自然失控。第三步是找根因。老年代为什么有大量可回收对象同时存活率还高用堆转储方式采了几个样本发现是一个全局缓存Map在傍晚业务高峰期被高频写入大量中间态对象进了老年代还没来得及被清理混合回收就把它们整个搬了一遍。第四步是调优和验证。业务侧优化了缓存Map的写入方式避免短生命周期对象堆积JVM侧把IHOP从45%调到30%让并发标记更早触发再把G1MixedGCCountTarget从默认8调到16让一轮混合回收分更多次执行单次停顿更小。调完之后混合回收单次停顿降到80ms以内业务恢复正常。这个案例最值得记住的一点是问题表面是GC参数根子却在代码的分配行为上。5.3 几条过来人才知道的避坑心得最后分享几条从多次排障里总结出来的体会。GC日志默认不会记录RSet更新耗时但它在某些场景下才是停顿的大头。排查时可以临时开启引用细粒度日志不过日志量很大记得用完就关。并发标记有一个“周期”的概念标记周期完成前混合回收不会启动。如果并发标记因为老年代占用率在IHOP附近反复抖动而失败混合回收会被一再推迟日志里能看到多次Concurrent Mark却没有后续Mixed这种时候要优先解决抖动来源。堆大小的设置对G1的影响比任何参数都大。G1适合的堆规模大致在4GB到32GB太小选Serial或Parallel反而简单直接太大要考虑新一代收集器。千万别让G1硬扛不适合它的场景那是双重折磨。另外如果听到有人问“G1怎么也会Full GC”答案通常是G1的Full GC是兜底机制出现在并发回收失败或者Humongous分配找不到连续Region的时候它不代表G1有bug只说明前面的回收机制已经搞不定了。想减少Full GC回到堆和代码本身找问题比调参有用得多。6. 一些长期观察下来的体会折腾G1这几年我最大的体会是它不是一个配置完就能撒手不管的收集器但也远没有到需要天天调参的“娇气”程度。大多数时候把堆大小设置对、把代码里的对象分配管住G1自己就能跑得很稳。真正需要你动手调参的场景往往意味着业务层面已经出了问题——要么对象分配失控要么缓存设计不合理要么堆大小压根没给够。所以我的建议一直是先学会看GC日志再学调参数最后有余力再去研究源码实现。把这三步走完G1对你来说就不是黑盒了。最后一个实用小技巧每次改动参数后严格控制变量一次只改一个并且保留前后两份GC日志做对比。因为G1的各项机制是互相影响的一次改三五个参数出了问题你根本说不清是谁的锅。这个习惯虽然朴素但关键时刻能救命。