垃圾回收之CMS、G1、ZGC对比

2023-06-15,,

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

停顿时间不超过10ms;
停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
支持8MB~4TB级别的堆(未来支持16TB)。

从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。

特性包括:

基于Region内存布局
暂时不设分代
使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法
以低延迟为首要目标。

ZGC在JDK15达到production-ready,JDK17是第一个开始推出成熟的ZGC的长期支持的ZGC版本。

一、标记清除、复制、标记整理

1、标记清除 Mark-Sweep

Mark-Sweep算法是现代垃圾回收算法的思想基础。

标记阶段:首先通过根节点,标记所有从根节点开始的可达对象。未被标记的对象就是未被引用的垃圾对象
清除阶段:清除所有未被标记的对象。

不足:

效率问题:标记和清除两个过程的效率都不高。
空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。

注意:何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

2、复制 Copying

将原有的内存空间分为两块,每次只使用一块
在垃圾回收时,将正在使用的内存中的存活对象复制到未被使用的内存块中,然后清除正在使用的内存块中的所有对象。
交换两块内存的角色,完成垃圾回收。

与标记-清除算法相比,复制算法是一种相对高效的回收方法。

不适用于存活对象较多的场合,如老年代。
不用考虑内存碎片等复杂情况,只要一动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
只是这种算法的代价是将内存缩小为了原来的一半,未免太高了点。

复制算法理论上是不需要标记过程的,从gc roots开始,遇到活对象就复制走了,gc roots找可达对象的过程结束就复制完了。

3、标记整理算法 Mark—Compact

Copying复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用这种算法。

根据老年代的特点,使用“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。

4、分代收集 generation collection

现代虚拟机基本都采用分代收集理论来进行垃圾回收。

分代清理并非是一种单独的算法,而是一种收集理论,分代收集结合了以上的 3 种算法,根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。

现在的商业虚拟机都采用复制算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。

堆内存中的新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块From Survivor,当回收时,将Eden和From Survivor中还存活着的对象一次性地复制到另外一块To Survivor空间上,最后清理掉Eden和刚才用过的From Survivor空间。

HotSpot虚拟机默认Eden:From Survivor:To Survivor = 8:1:1 ,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。

二、CMS和G1回顾

1、CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。这是因为 CMS 收集器工作时,GC 工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的。

CMS 收集器仅作用于老年代的收集,是基于标记-清除算法的,它的运作过程分为 4 个步骤:

初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。

CMS 收集器优点:并发收集、低停顿。

CMS 收集器缺点:

CMS 收集器对 CPU 资源非常敏感。
CMS 收集器无法处理浮动垃圾(Floating Garbage)。
CMS 收集器是基于标记-清除算法,该算法的缺点都有(内存碎片)。
停顿时间是不可预期的。

CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。

2、G1收集器

G1 在 1.9 版本后成为 JVM 的默认垃圾回收算法,G1 的特点是保持高回收率的同时,减少停顿。

G1 算法取消了堆中年轻代与老年代的物理划分,但它仍然属于分代收集器。G1 算法将堆划分为若干个区域,称作 Region,如下图中的小方格所示。一部分区域用作年轻代,一部分用作老年代,另外还有一种专门用来存储巨型对象的分区。

G1 也和 CMS 一样会遍历全部的对象,然后标记对象引用情况,在清除对象后会对区域进行复制移动整合碎片空间。

G1 回收过程如下。

G1 的年轻代回收,采用复制算法,并行进行收集,收集过程会 STW。
G1 的老年代回收时也同时会对年轻代进行回收。主要分为四个阶段:
依然是初始标记阶段完成对根对象的标记,这个过程是STW的;
并发标记阶段,这个阶段是和用户线程并行执行的;
最终标记阶段,完成三色标记周期;
复制/清除阶段,这个阶段会优先对可回收空间较大的 Region 进行回收,即 garbage first,这也是 G1 名称的由来。

G1 采用每次只清理一部分而不是全部的 Region 的增量式清理,由此来保证每次 GC 停顿时间不会过长。

G1 是逻辑分代不是物理划分,需要知道回收的过程和停顿的阶段。

此外还需要知道,G1 算法允许通过 JVM 参数设置 Region 的大小,范围是 1~32MB,可以设置期望的最大 GC 停顿时间等。

三、ZGC核心原理

ZGC和其他GC算法的对比,来自RednaxelaFX:

这种并发算法的核心思想就是:

在标记阶段,与其说是标记对象(记录对象是否已经被标记),不如说是标记指针(记录GC堆里的每个指针是否已经被标记)。这就与传统的三色标记对象的GC算法有非常大的区别,虽然两者从收敛性上看是等价的——最终所有对象以及所有指针都会被遍历过。
在标记和移动对象的阶段,每次从GC堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。最简单的事情就是,在标记阶段它会把指针标记上并把堆里的这个指针给“修正”到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过stop-the-world这种最粗粒度的同步方式来让GC与应用之间同步。
LVB中有一点很重要,就是“self healing”性质:如果堆上有指针当前处于“尚未更新”的状态,一旦经过LVB之后就会被就地更新,于是在同一个GC周期内再次访问这个字段的话就不需要再修正了。这样LVB带来的性能开销(吞吐量的下降)就是非常短暂的,而不像Shenandoah GC所使用的Brooks indirection pointer那样一直都慢。

1、更全面的并发

与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

ZGC垃圾回收周期如下图所示:

ZGC只有三个STW阶段:初始标记,再标记,初始转移。

其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

2、指针着色和读屏障

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。

大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。

假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。

而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。

那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

下面介绍着色指针和读屏障技术细节。

(1)着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

(2)读屏障

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用

ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

 
 

3、ZGC并发处理演示

接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:

初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。

其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

 

四、ZGC有什么缺点

虽然ZGC属于最新的GC技术, 但优点不一定真的出众。

ZGC只在特定情况下具有绝对的优势, 如巨大的堆和极低的暂停需求,而实际上大多数开发在这两方面都不太成问题(尤其是在服务器端), 而对GC的性能/效率更在意。

GC技术这些年其实并没有很大的发展, 也就是说没有银弹, 某些方面具有优势肯定是牺牲其它方面换来的, ZGC也很明显, 官方的设定目标是不损失超过15%的G1GC性能, 也就是说从吞吐速率上肯定无法跟G1相比了, 更没法跟完全STW的GC去比.

ZGC运行的时候能观察到下面的一些问题:

    单代GC吞吐低:最显著的问题是Concurrent Mark阶段都需要全堆标记(耗时长),导致回收速度跟不上对象分配速度:
    会出现分配停顿(Allocation Stall),需要启动一次新的ZGC,这次ZGC周期内所有应用线程都要暂停下来;
    最坏情况甚至发生OOM:Concurrent Relocate阶段如果剩余的空间依然不够,就会抛出OOM;
    GC线程并发运行导致CPU偏高;
    由于ZGC采用colored pointer技术,因此不支持UseCompressedOops(相比之下ShenandoahGC却能支持),一定程度上影响小堆(32GB以下)的性能;
    不过JDK15后可以支持UseCompressedOops关闭时依然开启UseCompressedClassPointers,这样一定程度上缓解了性能上的缺憾;
    对象分配卡顿,除了ZGC的暂停阶段之外,还受到下面的一些因素的影响:
    Page Cache Flush问题影响分配速度:ZGC把堆分为不同大小的page(对应G1的Region)——small/medium/large page(不同大小的object分配到不同类型的page中),如果各种大小对象分配速度不稳定(比如medium大小的object突然变多,那么就需要把large/small page转换成medium page,比较耗时),JDK15 production-ready之后有所缓解;
    只有单个medium page:应用线程较多的情形下,如果多个线程同时分配medium大小的object且当前medium page空闲大小不够时,那么就会同时请求分配新的medium page,undo多余的分配会延迟分配,还有可能导致上述Page Cache Flush发生;
    RSS特别高,可达3倍Xmx,这是由ZGC的multi-mapping机制导致的。

 

垃圾回收之CMS、G1、ZGC对比的相关教程结束。

《垃圾回收之CMS、G1、ZGC对比.doc》

下载本文的Word格式文档,以方便收藏与打印。