Garbage First(简称 G1)收集器是垃圾收集器技术发展史上里程碑式的成果:它开创了「面向局部收集」的设计思路和「基于 Region」的内存布局形式。
G1 收集器是一款主要面向服务端应用的垃圾收集器,其定位是「CMS 收集器的替代者和继承人」。它的发展简史如下:
JDK 7 Update 40 时,Oracle 认为它达到了足够成熟的商用程度;
JDK 8 Update 40 时,G1 收集器提供了并发的类卸载支持,被 Oracle 称为“全功能的垃圾收集器(Fully-Featured Garbage Collector)”。
JDK 9 中,G1 取代 Parallel Scavenge + Parallel Old 组合,成为服务端模式下的默认垃圾收集器。而 CMS 收集器则不推荐(Deprecate)使用了。
虽然 G1 收集器也遵循分代收集理论,但其堆内存的布局与其他收集器有非常明显的差异:
G1 不再坚持固定大小和固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。
Region 中还有一类特殊的 Humongous 区域,专门来存储大对象(大小超过一个 Region 容量的一半)。
而对于超过整个 Region 的超大对象,将会被存在 N 个连续的 Humongous Region 中(G1 的大多数行为都把 Humongous Region 作为老年代的一部分看待)。
G1 收集器的堆内存划分如图所示:
停顿时间模型(Pause Prediction Model):指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元(每次收集到的内存空间都是 Region 大小的整数倍),这样可以有计划地避免整个 Java 堆进行全区域垃圾收集。
更具体的处理思路:让 G1 收集器去跟踪各个 Region 中的垃圾堆积的“价值”大小,然后在后台维护一个优先级列表,每次根据用户设定的收集停顿时间,优先处理回收价值收益最大的那些 Region(这就是“Garbage First”名字的由来)。
“价值”的衡量指标是:每次回收所获得的空间大小以及回收所需时间的经验值。
G1 收集器之前的其他所有收集器(包括 CMS 收集器),垃圾收集的目标范围要么是整个新生代(Minor GC),或者整个老年代(Major GC),抑或整个 Java 堆(Full GC)。
而 G1 跳出了这个樊笼:它可以面向堆内存中任何部分来组成回收集(Collection Set,一般称 CSet)进行回收。衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多、回收收益最大。这就是 G1 收集器的 Mixed GC 模式。
G1 收集器的运行示意图如下:
它的运作过程大致可分为以下四个步骤:
主要工作
仅标记 GC Roots 能直接关联到的对象。
修改 TAMS 指针的值,使得下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。
特点
需要停顿用户线程,但耗时很短,且是借用 Minor GC 时同步完成的。
TAMS:Top at Mark Start,Region 中的指针,用于并发标记时为对象分配内存空间。
主要工作
从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。
特点
耗时较长
可与用户程序并发执行
此外,扫描完成后,还需要重新处理 STAB 记录下的在并发时有引用变动的对象。
STAB: Snapshot At The Begining,原始快照,参考前文「JVM笔记-HotSpot的算法细节实现」的 6.3 小节。
主要工作
处理并发标记结束后仍遗留下来的最后少量的 STAB 记录。
特点
需要暂停用户线程(时间较短)。
主要工作
更新 Region 统计数据,对各个 Region 的回收价值和成本进行排序。
根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
特点
由于涉及存活对象的移动,需要暂停用户线程。
G1 将堆内存划分为多个 Region,那些跨 Region 引用对象如何处理呢?
解决思路就是使用前文「JVM笔记-HotSpot的算法细节实现」第 4 小节的「记忆集」来避免全堆作为 GC Roots 扫描。
但是,G1 的记忆集更复杂,因为:
卡表是双向的(“我指向谁”、“谁指向我”),比原先的卡表更复杂;
Region 数量比传统收集器的分代数量多出很多,每个 Region 都要维护自己的记忆集,因此 G1 收集器比其他的传统垃圾收集器有更高的内存占用负担。
根据经验,G1 至少要耗费大约 Java 堆容量大小的 10%~20% 的额外内存空间来维持收集器工作。
并发标记阶段如何保证收集线程与用户线程互不干扰地运行呢?
解决思路是前文第 6 小节分析的:CMS 收集器使用增量更新算法,而 G1 收集器则是通过原始快照(STAB)算法实现的。
TAMS
此外,由于并发标记时用户线程仍在继续执行,肯定会持续创建新对象。
G1 为每个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配(默认都是存活的,不纳入回收范围)。
需要注意的是:如果内存回收速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World”。
如何建立可靠的停顿预测模型(满足用户设定的期望停顿时间)?
G1 收集器的停顿模型是以衰减均值(Decaying Average)为理论基础来实现的:垃圾收集过程中,G1 收集器会根据每个 Region 的回收耗时、记忆集中的脏卡数量等,分析得出平均值、标准偏差等。
“衰减平均值”比普通的平均值更能准确地代表“最近的”平均状态,通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才能在不超期望停顿时间的约束下获得最高收益。
G1 收集器经常会被拿来与 CMS 收集器进行比较。
且不论 G1 的一些创新设计:可以指定最大停顿时间、分 Region 的内存布局、按收益动态确定回收集等,这里只对比一些其他较为通用的地方。
CMS:“标记-清除”算法
G1
整体:“标记-整理”算法
局部:“标记-复制”算法
G1 的这两种算法使其在运作期间不会产生内存空间碎片,垃圾收集完成后能提供规整的可用内存。而且这样有利于程序长时间运行(大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集)。
CMS 和 G1 都使用卡表来处理跨代指针,但 G1 的卡表实现更复杂,且 Region 较多(本文 5.1 小节)。
相比而言,CMS 的卡表相对简单,只有一份,只需处理老年代到新生代的引用。
与 CMS 相比,G1 的内存占用会更大。
由于二者细节实现不同导致用户程序执行时负载会有不同。以写屏障为例:
CMS:使用写后屏障维护卡表;
G1:除了写后屏障维护卡表,为了实现原始快照(STAB)算法,还需使用写前屏障跟踪并发时的指针变化。
G1 的写屏障比 CMS 要消耗更多的运算资源。因此,CMS 写屏障是同步操作,而 G1 则是采用类似消息队列的异步操作。
整体而言:
小内存应用上,CMS 大概率会优于 G1;
大内存应用上,G1 则很可能更胜一筹。
这个临界点大概是在 6~8G 之间(经验值)。
一些相关的虚拟机参数如下:
# 使用 G1 收集器-XX:+UseG1GC # 设置 Region 大小(范围 1~32M,且为 2 的 N 次幂) -XX:G1HeapRegionSize # 最大收集停顿时间(默认 200 毫秒) -XX:MaxGCPauseMillis复制代码