jvm垃圾回收器 - G1详解

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之所以能做到可预测的停顿,全靠这个精密的流程。

整个流程可以用下面这张图来建立全局印象:

混合回收

并发标记周期

年轻代回收

否(且还有垃圾多的老Region)

Eden区满

年轻代收集
(STW,全部暂停)

存活对象
晋升或复制到Survivor/Old

重置Eden,年龄+1

堆占用达到阈值
InitiatingHeapOccupancyPercent

初始标记
(STW,伴随一次年轻代GC)

并发标记
(与应用线程并发)

最终标记
(STW,处理SATB缓冲区)

清理
(STW,统计RSet,回收空Region)

混合回收
(多次STW,每次回收部分老年代Region+CSet)

是否达到目标暂停时间?

结束混合回收阶段

下面我们按照时间顺序,把每一步都展开,包括细节和涉及的G1专用数据结构。


1. 普通年轻代回收 (Minor GC / Young GC)

这是G1最频繁的活动。

触发条件

当应用程序不断分配对象,所有Eden Region被填满时触发。

工作步骤(全部STW,即Stop-The-World)

  1. 确定CSet(回收集合):G1会选择所有Eden Region + 所有Survivor Region。注意:此时不选任何Old Region。为什么?因为年轻代回收必须快速完成,而老年代Region很大,扫进去会严重超时。
  2. 根扫描:从GC Roots(栈、JNI引用、全局变量等)出发,标记直接可达的对象。
  3. RSet处理:检查外部(老年代Region)对当前CSet中对象的引用。这是关键点:G1并不扫描全部老年代,而是利用每个Region的RSet(Remembered Set,记录“谁引用了本Region内的对象”)。对于CSet中的每个Region,扫描它的RSet,把那些从老年代指向年轻代对象的引用也作为GC Roots的一部分。
  4. 对象拷贝/晋升:将CSet中存活的对象拷贝到新的Region中:
    • 如果对象年龄未达到阈值,拷贝到新的Survivor Region。
    • 如果对象年龄达到阈值,或者目标Survivor Region放不下,则拷贝到Old Region(晋升)。
    • 同时,这些存活对象在新的位置会记录下它们的引用关系,并为新Region维护RSet。
  5. 清理CSet中的原Region:原来的Eden/Survivor Region被完全清空,变成空白Region放回空闲队列中。
  6. 更新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

  • 工作
    1. 统计Region存活度:计算每个Region中存活对象的比例。
    2. 识别完全空闲的Region:如果某个Region中没有任何存活对象,立即将它回收到空闲列表(不需要拷贝任何东西)。
    3. 更新RSet:如果发现某些RSet不再需要(例如,引用来源Region已被回收),也会做修剪。
    4. 准备混合回收:根据存活度对所有老年代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的回收:

  1. 根扫描(包括年轻代和老年代GC Roots)。
  2. RSet处理:既要处理年轻代RSet,也要处理被回收的老年代Region的RSet(查看有哪些外部引用指向它们)。
  3. 对象拷贝
    • 年轻代存活对象:晋升到Survivor或Old。
    • 老年代存活对象:也会被拷贝到其他空闲的Old Region。为什么?因为当前CSet中的老Region要被整体清空,所以必须把里面的存活对象移走。
    • 这个过程可能导致对象年龄再次增加,甚至晋升到其他老区域。
  4. 清理与更新:清空回收的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 12OOM机制优化
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 启用
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值