本文对常见的垃圾收集器 CMS、G1和 ZGC 进行了详细的介绍,不能说全网最细,但应该比大部分博客讲解的要详细的多。 在阅读本文之前,首先推荐阅读三色标记算法。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
基于标记-清除算法实现。
GC 过程
- 初始标记:标记 GC Roots 能直接关联到的对象,需要 STW,速度很快。
- 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,标记所有可达的对象,耗时较长,但不需要暂停用户线程,用户线程可以与垃圾回收线程并发运行。
- 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要 STW。
- 并发清除:清除不可达的对象,并回收它们所占用的内存空间,不需要 STW。
跨代引用问题
因为 CMS 是分代垃圾回收器,所以会存在跨代引用的问题。跨代引用是指老年代空间中的对象引用了新生代的对象,或者新生代中的对象引用了年老代中的对象。面对这种情况,在进行可达性分析扫描存活对象时,不可能从新生代一直扫描至年老代的,因为这样就会出现整堆扫描的情况,效率必然会很低。
卡表
为了解决跨代引用的问题,CMS 收集器使用了一种叫卡表的数据结构 , 它是一个字节数组,用于记录堆内存的映射关系,下面是 HotSpot 虚拟机默认的卡表标记逻辑。
// >> 9 代表右移 9位,即2^9=512
CARD_TABLE[this address >> 9] = 1;
字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页。
因为卡页代表的是一个区域,所以可能存在很多对象,只要有一个对象存在跨代引用,就把对应卡表的数组元素值设为1,称该元素变脏,该卡页叫脏页。在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。
写屏障
卡表元素何时变脏呢?当有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点应该发生在引用类型字段赋值的那一刻,把维护卡表的动作放到每一个赋值操作之中。
在 HotSpot 虚拟机里是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,也就是说赋值的前后都在写屏障的覆盖范围内。在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。下面是简化的代码逻辑:
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令。
CMS 收集器的缺点
-
吞吐量低。
-
无法处理浮动垃圾(浮动垃圾:在CMS 并发标记和并发清理阶段,由于用户线程还在运行所产生的新的垃圾对象)。
-
使用“标记-清除”算法产生碎片空间,导致频繁 Full GC。
G1 收集器
相比于 CMS 收集器,G1 收集器不仅能提供能提供规整的内存,而且能够实现可预测的停顿,能够将垃圾回收时间控制在 N 毫秒内。
和其他收集器不同,G1 有两种 GC 模式,分别是 Young GC 和 Mixed GC。
G1 收集器采用标记 - 复制算法,G1 的 STW 时间可预测,可由用户指定。
G1 堆内存结构
G1 将堆内存划分为若干个固定大小的分区(Region) ,每个 Region 大小在1MB ~ 32MB 之间。对于 Region,可以通过-XX:G1HeapRegionSize参数设置其大小。内存的回收以 Region 作为基本单位。每次回收优先处理回收价值收益最大的那些 Region。
G1 收集器在逻辑上分代,在物理上不分代。每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间或老年代空间。
每个 Region 内部又被分成了若干个大小为512 Byte 的卡片(Card),标识堆内存最小可用粒度。
G1 对内存的使用以 Region 为单位,而对象的分配则以卡片(Card)为单位。
大对象
任何超过一个 Region 大小一半的对象都被视为大对象(Humongous Objects)。
大对象通常被视为老年代,存放大对象的 Region 不会再存放其他对象。
大对象分配过程:
- 寻找连续的Humongous Regions: 大对象将被分配到一个或多个连续的 Region 中,这些 Region 专门用于存储大对象,称为 Humongous Regions。
- 如果一个大对象可以完全放入一个 Region,那么它将被分配到单个 Humongous Region 中。
- 如果对象太大而无法放入单个 Region,G1将分配足够多的连续 Humongous Regions 来容纳该对象。
Region 内部结构
卡表
卡表是 Region 的内部结构划分。每个 Region 内部被划分为若干的内存块,被称为 Card。这些 Card 的集合被称为卡表。
比如下面的例子,Region1中的内存区域被划分为9块 Card,9块 Card 的集合就是卡表。
RSet
RSet 中记录了其他 Region 指向本 Region 的所有引用,在 G1 收集器中,每一个 Region 都维护一个 RSet。
通过 RSet,可以避免当有老年代引用新生代的对象时,去扫描整个老年代对象。
RSet 本质上是哈希表,key 是引用本 Region 的其他 Region 的起始地址,value 是被其他 Region 引用的自身 card 索引位置。
如下图:region3 中的3号和5号索引位置被 region1 引用,Rset 中记录的 key 是 region1 的起始位置,value 是自身被引用的3号索引和5号索引。
RSet 中记录的是“谁引用了我的对象”,而卡表中记录的是“我引用了谁的对象”。
GC 模式
G1 提供了两种 GC 模式,年轻代回收(Young GC)和混合回收(Mixed GC)。
年轻代回收(Young GC)
-
Stop The World:整个 Young GC 的流程都是在 STW 里进行的。控制 Young GC 开销的办法只有减少 Young Region 的个数,也就是减少年轻代内存的大小,还有就是并发,多个线程同时进行 GC,尽量减少 STW的时间。
-
扫描 GC Roots:扫描直接指向年轻代的对象,那如果 GC Root 是直接指向老年代对象的,则会直接停止在这一步,也就是不往下扫描了。被老年代对象指向的年轻代对象会在接下来的利用 RSet 中 key 指向老年代的卡表识别出来,这样就避免了对老年代整个大的 heap 扫描,提高了效率。
-
更新 RSet: RSet 中记录了哪些对象被老年代跨代引用,当新生代对象被老年代对象引用时,更新这个记录到 RSet 中。
-
扫描 RSet: 扫描所有 RSet 中老年代到年轻代的引用。到这一步就确定出了年轻代哪些对象是存活的。
-
拷贝对象到 Survivor 区域或者晋升 Old 区域。
-
处理各种引用。
混合回收(Mixed GC)
- 初始标记:标记 GC Roots 能直接关联到的对象,触发 STW,这一步伴随着 Young GC。之所以要 Young GC 是为了处理跨代引用,老年代也可能被年轻代跨代引用,但是老年代不能使用 RSet 来解决跨代引用。还有就是Young GC 也会 STW,在第一步 Young GC 可以共用 STW 的时间,尽量减少 STW 时间。
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,扫描整个堆中的对象图,找出所有要回收的对象;
- 最终标记:处理并发标记后新产生的对象,触发 STW;
- 筛选回收:对各个 Region 的回收价值和成本进行排序,根据用户期待的 GC 停顿时间指定回收计划,选中部分Old Region 和全部的 Young Region,把决定回收的这部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 空间,触发 STW。
G1 收集器的优缺点
优点:不会产生内存碎片,更少触发 Full GC。
缺点:因为使用了大量的额外结构来存储引用关系,所以会带来额外的内存占用。
ZGC 收集器
由于 CMS 和 G1 收集器的 STW 时间比较长,因此在 JDK11 引入了 ZGC(Z Garbage Collector),ZGC 的 STW 时间非常短,不会超过10ms。
和 G1 一样,ZGC 也采用基于 Region 的堆内存布局,但与 G1 不同的是,ZGC 的 Region 具有动态性:动态创建和销毁,以及动态的区域容量大小。
ZGC 的 Region 可以分为小、中、大三个容量:
-
小型 Region:容量固定为 2MB,用于放置小于 256KB 的小对象。
-
中型 Region:容量固定为 32MB,用于放置大于等于 256KB 但小于4MB 的对象。
-
大型 Region:容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。每个大型 Region 中只会存放一个大对象。
ZGC 基于标记-复制算法,在标记、转移和重定位阶段几乎都是并发的。
ZGC 工作过程:
- 初始标记
- 并发标记
- 再标记
- 初始转移
- 并发转移
- 重定位
ZGC 只有三个 STW 阶段:初始标记,再标记,初始转移。
其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短。再标记阶段 STW 时间很短,最多1ms,超过1ms则再次进入并发标记阶段。
ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段是完全 STW 的,且停顿时间随存活对象的大小增加而增加。
染色指针
染色指针是一种将信息存储在指针中的技术。
ZGC 出现之前, GC 信息是保存在对象头的 Mark Word 中的,而 ZGC 的染色指针将这些信息直接标记在引用对象的指针上。染色指针将其高4位提取出来存储四个标志信息,分别为 Marked0、Marked1 、 Remapped 和 Finalizable。
-
Marked0:对象在当前并发标记周期内被标记为存活。
-
Marked1:对象在前一个并发标记周期内被标记为存活。
-
Remapped:对象已经被重定位。
通过这四个标志位,无需进行对象访问就可以获得 GC 信息,大大提高了 GC 效率。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC 同时会为该对象在 Marked0、Marked1 和 Remapped 地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。
ZGC 之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低 GC 停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
读屏障
读屏障是 JVM 向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA; // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o; // 无需加入屏障,因为不是从堆中读取引用
o.dosomething(); // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB; //无需加入屏障,因为不是对象引用
ZGC 中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
GC 过程
初始标记
从 GC Roots 出发,找出 GC Roots 直接引用的对象,放入活跃对象集合,这个过程需要 STW,不过 STW 的时间跟 GC Roots 数量成正比,耗时比较短。
并发标记
ZGC 初始化之后整个内存空间的地址视图都是 Remapped。
进入并发标记阶段后,从 GC Roots 开始扫描标记的对象、标记过程中新创建的对象和被业务线程访问过的对象都会切换到 Marked0 视图。当标记结束后,对象的地址要么是 Marked0 视图,要么是 Remapped 视图。如果对象地址视图是 Marked0,那就是活跃的,如果对象地址视图是 Remapped,那就是不活跃的。
这里采用两个视图是为了区分前一次标记和这一次标记。如果这次标记的视图是 Marked0,那下一次并发标记就会把视图切换到 Marked1。这样做可以配合 ZGC 按照页回收垃圾的做法。
重新标记
重新标记并发标记阶段发生变化的对象,还会对非强引用(软应用,虚引用等)进行并行标记。
这个阶段需要 STW,但是需要标记的对象少,耗时很短。
初始转移
转移就是把活跃对象复制到新的内存,之前的内存空间可以被回收。
初始转移需要扫描 GC Roots 直接引用的对象并进行转移,这个过程需要 STW,STW 时间跟 GC Roots 成正比。
并发转移
并发转移过程 GC 线程和 Java 线程是并发进行的。转移过程中对象视图会被切回 Remapped。
-
如果 GC 线程访问对象的视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
-
并发转移过程中 Java 应用线程创建的新对象地址视图是 Remapped。
-
如果 Java 应用线程访问的对象被标记为活跃并且对象视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
-
如果 GC 线程访问对象的视图是 Remapped,说明被其他 GC 线程处理过,跳过不再处理。
重定位
转移过程中对象的地址发生了变化,在这个阶段,把所有指向对象旧地址的指针调整到对象的新地址上。