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大小(约) |
|---|---|
| 小于4GB | 1MB |
| 4GB到8GB | 2MB |
| 8GB到16GB | 4MB |
| 16GB到32GB | 8MB |
| 32GB到64GB | 16MB |
| 64GB以上 | 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:MaxGCPauseMillis | 200ms | 期望的最大停顿时间 | 软目标,不保证绝对达标 |
| -XX:G1HeapRegionSize | 自动推算 | Region大小 | 很少需要手动修改 |
| -XX:InitiatingHeapOccupancyPercent | 45% | 触发并发标记的老年代占用阈值 | 设太小标记频繁,设太大回收滞后 |
| -XX:G1MixedGCCountTarget | 8 | 一轮混合回收期望的混合GC次数 | G1会自适应调整,不必强求 |
| -XX:G1HeapWastePercent | 5% | 堆中允许浪费空间的比例,垃圾量低于它不回收 | 容易被忽略但很关键 |
| -XX:G1NewSizePercent | 5% | 年轻代最小占比 | 动态调整的下限 |
| -XX:G1MaxNewSizePercent | 60% | 年轻代最大占比 | 过大会挤压老年代空间 |
| -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的各项机制是互相影响的,一次改三五个参数,出了问题你根本说不清是谁的锅。这个习惯虽然朴素,但关键时刻能救命。