G1垃圾收集器发展史与工作原理
G1(Garbage First,垃圾优先)收集器是JVM垃圾收集技术发展史上的里程碑之作,它开创了面向局部收集的设计思路和基于Region的内存布局形式,定位为CMS收集器的替代者和继承人。
一、发展史
1.1 早期设计阶段(2004-2007)
- 2004年:Sun公司发布G1回收器的核心论文,奠定了理论基础。
- 2005年:提出Region化内存布局的设计理念。
- 2006年:完成Remember Sets(记忆集)的初步设计。
- 2007年:实现首个原型版本。
1.2 实验特性阶段(2008-2011)
- 2009年:JDK 6u14版本中,G1作为实验性功能首次亮相;同年增加了自适应堆调整算法。
- 2010年:改进大对象(Humongous Objects)的处理。
- 2011年:在OpenJDK 7中发布第一个正式版本。
1.3 成熟发展阶段(2012-2016)
- 2012年:JDK 7 Update 4中,G1进入生产环境推荐使用阶段,正式获得官方支持。
- 2014年:增强Mixed GC的性能。
- 2015年:优化字符串去重功能。
- 2016年:改进SATB(Snapshot-At-The-Beginning)算法。
- JDK 8 Update 40:G1提供了并发的类卸载支持,被Oracle称为"全功能的垃圾收集器"。
1.4 默认收集器阶段(2017至今)
- 2017年:JDK 9中,G1取代Parallel Scavenge + Parallel Old组合,正式成为服务端模式下的默认垃圾收集器,同时官方废弃(Deprecate)了CMS收集器。
- 2018年:JDK 10引入了并行Full GC。
- 2019年:JDK 12优化了Abort Out Of Memory机制。
- 2021年:JDK 16引入分代式G1收集器。
从2004年第一篇论文发表到2012年达到商用程度,G1走过了将近10年的演进之路。
二、⚙️ G1 核心概念
这里有两个核心概念需要先了解:
- Region(区域):G1不再将堆内存划分为固定大小的年轻代和老年代,而是切分成许多大小相等的小块(1MB到32MB之间),这就是Region。
- CSet (Collection Set,回收集合):G1每次回收的Region集合。它会从垃圾最多的Region中挑选出一些,组成一个“回收集合”,然后集中清理,这也是“Garbage First”名字的由来。
三、📣 大白话版:G1就像个精明的“垃圾管理员”
以前的回收方式:麻烦又费时
早期的垃圾回收器会把整个仓库翻个底朝天——停下来全部扫一遍,有用的搬走,没用的扔了。
问题是,仓库越大,扫一次的时间就越长,程序就得卡住等它做完,用户体验很差。
1. G1核心思路:把大仓库切成小块,每次只清理最脏的几个格子
假设你有一个堆满杂物的巨大仓库(内存),仓库里放着很多箱子(对象)。有些箱子还在用(活对象),有些箱子早已废弃(垃圾)。
你的任务是定期清理垃圾,腾出空间放新箱子。
G1 的做法不是一次清空整个仓库,而是:
- 先把仓库平均划分成几百上千个小格子(每个格子就是一块内存区域,叫 Region)。
- 每个格子里可能有些垃圾,有些有用箱子。
- G1 会优先清理垃圾最多的那几个格子(因为同样的精力,垃圾越多,腾出的空间越大,性价比最高)。
- 并且每次清理只做一小段时间(比如 0.2 秒),避免让仓库管理员(程序)等太久。
2. 怎么知道哪个格子垃圾最多?—— 偷偷做标记
G1 会在仓库正常运转的同时,派一个小队在后台悄悄巡逻,给每个格子里的箱子做标记:
- 活箱子画个 ✓
- 死箱子(垃圾)画个 ✗
巡逻队不会影响仓库正常存取货(并发标记)。巡逻完毕后,G1 就清楚知道每个格子的“垃圾密度”,然后排出一个清理顺序表:垃圾最多的格子排最前面。
3. 清理时怎么做?—— 把活箱子搬到其他空格子,然后直接把原格子清空
当 G1 决定清理几个格子(比如垃圾最多的三个格子)时:
- 它会把这个格子里还在用的活箱子挨个搬到另一个全新的空格子里。
- 搬完后,整个旧格子直接推平(里面所有垃圾和箱子全部清除)。
- 原来那个格子又变成全新的空格子,可以重复使用。
这样做的效果:清理后没有内存碎片(像搬家后整理得整整齐齐),而且只动少数几个格子,速度很快。
4. 关键难题:格子之间互相指着怎么办?
仓库里的箱子可能会互相“指着”(引用)。比如格子 A 里的箱子指向格子 B 里的箱子。
如果你要清理格子 B,但格子 A 指向了 B 里面的一个箱子,那你不能把 B 里的那个箱子当成垃圾。
G1 的解决办法:给每个格子准备一个小本本(Remembered Set,简称 RSet)。
小本本上记录:“有哪些其他格子的箱子指着我这个格子的箱子”。
比如格子 B 的小本本上写着:“格子 A 的第 3 号箱子指向我”。
这样当 G1 要清理格子 B 时,只需翻阅 B 自己的小本本,就知道谁还在用我,而不需要把整个仓库翻一遍。这是典型的空间换时间策略。
5. 不固定划分年轻/老年代 —— 更灵活
传统仓库会把区域固定分成两块:
- 新生区:新放进来的箱子,容易变成垃圾。
- 老年代:放很久、一直有用的箱子。
G1 不固定分区——每个小格子可以临时扮演新生区或老年代的角色,根据需要动态变化。
这就避免了“新生区满了但老年代还有很多空位,却没法用”的尴尬。
6. 万一清理速度跟不上垃圾产生速度怎么办?
如果程序疯狂制造垃圾,G1 的小规模清理来不及,仓库慢慢被填满,就会触发“破罐破摔模式”——整个仓库锁死,一个人(单线程)把所有格子翻一遍,暴力清理所有垃圾。
这个过程会卡很久(叫 Full GC),是最后的保险。
一句话大白话总结
G1 把内存切成很多小格子,平时后台偷偷统计每个格子里的垃圾量,然后每次只清理垃圾最多的一些格子,并控制在很短时间内完成,这样程序几乎感觉不到卡顿。
四、工作流程
我们深入G1的内部,把它的工作流程掰开揉碎了讲清楚。G1的回收并不是单一模式,而是两种主要活动交替进行:一种是专门清理年轻代的Minor GC;另一种是并发的全局并发标记,之后再配合混合回收来逐步清理老年代。G1之所以能做到可预测的停顿,全靠这个精密的流程。
整个流程可以用下面这张图来建立全局印象:
下面我们按照时间顺序,把每一步都展开,包括细节和涉及的G1专用数据结构。
1. 普通年轻代回收 (Minor GC / Young GC)
这是G1最频繁的活动。
触发条件
当应用程序不断分配对象,所有Eden Region被填满时触发。
工作步骤(全部STW,即Stop-The-World)
- 确定CSet(回收集合):G1会选择所有Eden Region + 所有Survivor Region。注意:此时不选任何Old Region。为什么?因为年轻代回收必须快速完成,而老年代Region很大,扫进去会严重超时。
- 根扫描:从GC Roots(栈、JNI引用、全局变量等)出发,标记直接可达的对象。
- RSet处理:检查外部(老年代Region)对当前CSet中对象的引用。这是关键点:G1并不扫描全部老年代,而是利用每个Region的RSet(Remembered Set,记录“谁引用了本Region内的对象”)。对于CSet中的每个Region,扫描它的RSet,把那些从老年代指向年轻代对象的引用也作为GC Roots的一部分。
- 对象拷贝/晋升:将CSet中存活的对象拷贝到新的Region中:
- 如果对象年龄未达到阈值,拷贝到新的Survivor Region。
- 如果对象年龄达到阈值,或者目标Survivor Region放不下,则拷贝到Old Region(晋升)。
- 同时,这些存活对象在新的位置会记录下它们的引用关系,并为新Region维护RSet。
- 清理CSet中的原Region:原来的Eden/Survivor Region被完全清空,变成空白Region放回空闲队列中。
- 更新RSet:所有引用指向新地址,并更新对应RSet。
特点:
- 停顿时间可控,因为回收的Region数量大致固定(所有年轻代Region),且拷贝存活对象的工作量与存活数据量成正比。
- 没有老年代扫描,全靠RSet记录外部引用。
2. 并发标记周期 (Concurrent Marking Cycle)
当老年代占用达到一定比例(默认堆的45%,由-XX:InitiatingHeapOccupancyPercent控制)时,G1会启动一次并发标记,目的是找出老年代中真正可回收的垃圾,为后续混合回收做准备。
这个周期包含多个阶段,其中只有“初始标记”、“最终标记”、“清理”需要STW,其他阶段与应用线程并发。
2.1 初始标记 (Initial Mark) - STW
- 时机:这个阶段其实是伴随一次年轻代GC发生的,不单独暂停。
- 工作:在年轻代GC的STW阶段,额外标记从GC Roots直接可达的老年代对象(例如,被静态变量引用的老年代对象、被年轻代对象引用的老年代对象等)。这些对象作为并发标记的起点。
- 输出:为每个Region维护一个
TAMS(Top at Mark Start)指针,表示并发标记开始时Region中已分配对象的顶。并发标记期间新分配的对象位于TAMS以上,默认存活。
2.2 并发标记 (Concurrent Mark) - 并发
- 工作:从初始标记的根出发,遍历整个对象图,标记所有可达的对象。这个过程与应用线程同时运行。
- SATB (Snapshot-At-The-Beginning):G1采用SATB算法保证正确性。并发标记开始时,逻辑上对整个堆做了一个“快照”。后续应用线程修改引用时,G1会通过写屏障(Write Barrier)记录下被覆盖的旧引用,放在
SATB缓冲区中。这样,并发标记线程仍然能根据快照完成所有存活对象的标记。 - 进度:并发标记线程会逐步将对象从“灰色”变为“黑色”,最终所有可达对象都被标记为存活。
2.3 最终标记 (Final Mark / Remark) - STW
- 目标:处理并发标记阶段残留的SATB缓冲区,以及在此期间因引用变化而漏标记的对象。
- 工作:暂停所有应用线程,清空所有的SATB缓冲区,确保快照中的所有存活对象都被标记完成。这个阶段比CMS的Remark要快很多,因为SATB缓冲区内容较少。
2.4 清理 (Cleanup) - STW
- 工作:
- 统计Region存活度:计算每个Region中存活对象的比例。
- 识别完全空闲的Region:如果某个Region中没有任何存活对象,立即将它回收到空闲列表(不需要拷贝任何东西)。
- 更新RSet:如果发现某些RSet不再需要(例如,引用来源Region已被回收),也会做修剪。
- 准备混合回收:根据存活度对所有老年代Region排序,选出垃圾最多的那些Region,放入一个候选列表,供后续混合回收使用。
注意:此时并不实际回收那些有垃圾的Old Region,只是完成了标记和统计,为下一步做决策。
3. 混合回收 (Mixed GC)
在并发标记完成后,G1不会再做传统的Full GC,而是执行一系列混合回收(Mixed GC)。混合回收会同时回收一部分年轻代Region + 一部分垃圾最多的老年代Region。
触发与执行
- 触发:清理阶段结束后,G1会先发起一次普通的年轻代回收(因为Eden可能又满了),但这次回收会额外增加一些老年代Region到CSet中,从而变成混合回收。
- CSet构成:全部年轻代Region(Eden+Survivor) + 若干经过挑选的Old Region(从候选列表中选,按垃圾最多优先)。
- 次数:混合回收会连续进行多次,每次STW。默认情况下,直到候选列表中的老年代Region绝大部分被回收完,或者达到了暂停时间目标(
-XX:G1MixedGCCountTarget控制最多混合回收次数,默认8次),才会结束。
混合回收内部的STW步骤
与年轻代回收类似,但多了对老年代Region的回收:
- 根扫描(包括年轻代和老年代GC Roots)。
- RSet处理:既要处理年轻代RSet,也要处理被回收的老年代Region的RSet(查看有哪些外部引用指向它们)。
- 对象拷贝:
- 年轻代存活对象:晋升到Survivor或Old。
- 老年代存活对象:也会被拷贝到其他空闲的Old Region。为什么?因为当前CSet中的老Region要被整体清空,所以必须把里面的存活对象移走。
- 这个过程可能导致对象年龄再次增加,甚至晋升到其他老区域。
- 清理与更新:清空回收的Region,更新RSet。
混合回收结束后,如果候选列表中仍有垃圾较多的Region,且堆占用仍然很高,会继续下一次混合回收。如果堆占用下降到阈值以下,则暂停混合回收,回到普通年轻代回收模式。
4. 特殊情况:Full GC
如果并发标记和混合回收来不及释放内存,应用程序又继续分配大量对象,导致老年代Region占满或者拷贝晋升时找不到空闲Region,G1就会退化为一次单线程的Full GC(JDK 10之前是单线程的,JDK 10+改为并行Full GC,但仍然是全局STW,停顿很长)。Full GC会压缩整理整个堆,标记-清除-压缩。
如何尽量避免Full GC? 合理设置-XX:MaxGCPauseMillis(不要太激进,否则每次回收量太小,积压垃圾),调大堆大小,或者增加-XX:G1HeapRegionSize以减少Region数量。
5. 总结:一句话串起流程
G1大部分时间在做年轻代回收(只清Eden+Survivor);当老年代垃圾积累到阈值时,就插入一次并发标记(标记老年代垃圾);标记完成后,后续的几次混合回收会同时清理年轻代+部分老年代,逐步消化垃圾;如果一切顺利,从不触发Full GC。
G1的精髓就在于将全堆回收打散成多次、少量、可预测停顿的回收,并且每次只选垃圾最多的Region回收——这就是Garbage First名字的由来。
五、重要优化与特性演进
| 版本/JDK | 重要特性 |
|---|---|
| JDK 7u4 | 正式商用支持,移除实验标识 |
| JDK 8u40 | 并发类卸载支持,成为"全功能垃圾收集器" |
| JDK 9 | 成为服务端默认GC,废弃CMS |
| JDK 10 | 并行Full GC引入 |
| JDK 12 | OOM机制优化 |
| JDK 16 | 分代式G1收集器 |
| 持续优化 | 字符串去重、RSet维护开销降低、大内存场景持续优化 |
六、调优参数
| 参数 | 作用 | 默认值 |
|---|---|---|
-XX:+UseG1GC | 启用G1(JDK9+默认开启) | — |
-XX:G1HeapRegionSize=n | 设置Region大小 | 1~32MB,2的幂次 |
-XX:MaxGCPauseMillis=n | 最大停顿时间目标 | 200ms |
-XX:InitiatingHeapOccupancyPercent=n | 触发并发标记的堆占用阈值 | 45% |
-XX:G1NewSizePercent=n | 新生代初始大小占比 | 5% |
-XX:G1MaxNewSizePercent=n | 新生代最大大小占比 | 60% |
-XX:G1MixedGCCountTarget=n | 混合回收总次数目标 | 8 |
-XX:G1HeapWastePercent=n | 可接受堆垃圾占比 | 5% |
七、总结与选型建议
G1的核心优势:
- 可预测的低停顿时间(用户可配置目标)
- 整体标记-整理算法 + 区域间复制算法,从源头避免内存碎片
- 优先回收垃圾最多的Region,最大化回收效率
- 面向大堆内存(4GB~64GB)和服务器多核环境优化
选型建议:
- 堆内存大于4GB、需要兼顾吞吐量与低延迟的应用 → G1是首选
- 堆内存较小(<4GB)、对停顿不敏感 → 可考虑Parallel GC
- 要求极致低延迟(STW < 10ms) → 可考虑ZGC(JDK 11+)
- JDK 9及以上版本 → G1是默认收集器,无需额外配置
- JDK 8 → 需手动添加
-XX:+UseG1GC启用

1万+

被折叠的 条评论
为什么被折叠?



