新一代Java垃圾回收器ZGC简介
什么是ZGC
G1作为目前Java届使用最广泛的GC策略之一,对广大Java民工来说称得上是家喻户晓。G1的并发标记、分代收集等特性大大缩短了STW的时间,提升了GC的性能。对Java的发展历程来说,G1算是一个重大事件。
然而,随着时代的发展,硬件水平的提升,服务器内存越来越大,很多人觉得,G1策略在面对超大内存的应用场景时,其STW表现仍然无法满足需求。在这种情况下,Oracle公司搞出了新的垃圾回收策略,即本文介绍的ZGC策略。
ZGC在JDK 11中加入,在JDK 15中达到production-ready状态,号称可以回收TB级内存而STW不超过10ms。
关于STW
ZGC的核心目标就是缩短STW的时间,STW的全称是Stop The World,即在GC过程中整个进程除GC线程外的其他线程全部暂停,等待GC完成的过程。
可想而知,STW对整个系统的影响是很大的。尤其对时下流行的分布式低延迟高可用系统来说,关键节点频繁触发STW很可能会拖累整个系统的吞吐量和可用性。
举个例子来说,在某个分布式系统中,对一个关键RPC节点的设计标准是99.9%的RPC请求要在50ms之内完成,该节点每分钟触发10次Young GC,每次Young GC的平均STW时间是30ms,无GC时该RPC接口的平均业务处理时间是30ms。
当触发GC时,由于STW机制,业务的处理总时长会变成30+30=60ms,在触发STW时的所有响应都是不满足50ms标准的。假设每秒钟的RPC请求数量是平稳无波动的话,我们可以算出不满足标准的请求占比是60*10/60000=1%,显然无法达到99.9%的高可用标准。
G1中的STW
G1策略在STW方面已经做了很多努力,在G1策略的标记整理过程中,涉及到STW的过程主要是以下过程:
1、初始化标记阶段
该阶段从所有的GC Root出发,对GC Root和其直接可达的对象进行初始化标记,该过程是STW的,不过由于GC Root的数量并不多,因此并非主要耗时点。
2、并发标记阶段
该阶段从GC Root出发使用可达性分析算法,对所有的对象进行标记。虽然该阶段耗时相对较长,不过该阶段并不是STW的,因此除了耗些CPU之外没什么影响。
3、Remark阶段
由于第二阶段不是STW的,因此很有可能在执行的过程中,其他线程又产生了新的对象或者原有对象发生了变化,因此Remark阶段会进行STW,将其他线程暂停,将第二阶段发生了变化的对象再次进行标记。该阶段的STW时长视发生变化的对象数量而定,一般来说不会太长。
4、清理阶段
该阶段是STW的,也是整个GC过程中最主要的STW耗时步骤。在清理阶段,G1策略会将对象回收,同时对内存空间进行整理以清理内存碎片(关于内存碎片的详细介绍,可以查看之前介绍内存池的文章)。整理的过程主要是使用复制的方式,也就是将会产生碎片的对象集中归整到另一片区域去来清除碎片。
分析一下G1的过程,我们可以看出,在不改变可达性分析这个基础算法的前提下,前三个阶段中可以做的文章并不算太多,如果要改进算法,主要还是在清理阶段上。
在G1策略中,堆内存被划分为一个个Region(内存块),Region里存储着堆内存中的对象。在G1中所有Region的大小都是一样的,可以手动调整,通过JVM参数XX:G1HeapRegionSize来配置(当然大多数Java工程中是没人动这个配置的)。
Region是G1分代算法执行的基础,新生代和老年代的标记是挂在Region上的。同时,Region也是标记整理执行的基础,在清理阶段,G1会选择那些空闲空间较多的Region,将这些Region中的存活对象复制合并到另一个Region中,空出前者作为全新的空闲Region,以此来解决内存碎片的问题。
从这个过程可以看出,随着堆内存的扩大,Region越来越多,处理Region的过程会越来越长。未来面对上百GB甚至是TB级堆内存时,该问题也愈加突出。
ZGC的主要改进
ZGC的基础同样是可达性分析和标记整理,为了解决G1的缺陷,ZGC主要向两个方向进行了改进:
充分利用多核CPU,让尽可能多的操作并发处理
缩短对象移动过程的STW。
基于这两个思路,ZGC主要进行了这么几项改造:不同尺寸的Region分片、染色指针改造、Read Barrier(读屏障)、内存的多重映射。ZGC的最大特点,就是在移动存活对象时不需要完全STW,可以和业务线程进行一定程度的并发。
不同尺寸的Region分片
目前的ZGC是没有分代策略的,相应地,ZGC的堆内存Region不再是同一种尺寸,而是分成了大中小三种尺寸,用于存放不同尺寸的对象(TCMalloc既视感)。与TCMalloc不同的是,大Region的尺寸是不固定的,视其中存放的对象大小而定,而每个大Region中只存放一个对象,在标记整理的过程中也不会被移动。
ZGC中的染色指针
染色指针是可达性分析算法的一部分,并非ZGC的专利,在G1中同样有染色指针策略。二者不同的是,G1的染色指针是将染色信息记录在另一块结构体上,而ZGC的染色信息是直接记录在了指针本身上。
在可达性分析算法中,使用三种颜色(即三种不同的状态)来记录可达状态:
白色标记表示对象还没有被访问过,在可达性分析的起始阶段,所有对象指针都是白色的。
黑色表示该对象和对象的所有引用都已经被扫描过,黑色对象是从GC Root可达,即存活的对象,不进行回收。
灰色是中间状态,表示已经被扫描过,但是该对象上至少还有一个引用没有被扫描过。
可达性分析的执行过程,就是从所有的GC Root开始,将白色指针都变成黑色的过程,如果算法已经执行完毕,对象指针仍然是白色的,那么说明已经没有引用指向该对象,对象可以被回收。
ZGC目前仅适用于64位操作系统,在64位系统中,每个指针占64位的长度,其中高18位是空闲位,ZGC再占用4位记录染色信息,剩下的42位用来做堆内存。而在G1策略中,染色信息是记录在另外的header结构体中。从染色标记的访问速度来说,ZGC存在指针本身上的做法显然要更快一些。
ZGC中的Read Barrier和内存多重映射
ZGC中的Read Barrier和内存多重映射,其实都是基于染色指针做的,它们为了同一个目标服务:在移动对象时其他线程可以访问对象。
在染色指针的4个bit中,低2位用于可达性分析(称为M0和M1),在完成可达性分析后,低2位的信息被更新,然后ZGC会计算出哪些Region是需要被清理的,这些Region组成重分配集。
接下来,重分配集中的对象会被复制到新的Region中,同时为重分配集的Region构建转发表,记录新旧Region的对应关系。指针是否经过重分配,记录在第三个bit(称为Remap)上。当应用线程访问对象时,会触发Read Barrier,根据该bit的情况做出不同的操作:
当该bit为0时,若对象不在重分配集中,直接返回引用
当该bit为0,对象在重分配集中,也没有完成移动时,会移动对象,再做上一条的事
从这个过程可以看出,由于存在移动转发的过程,ZGC触发后首次访问对象的速度会变慢,后续再访问就会比较快。
这就涉及到ZGC的另一项技术:内存多重映射技术。
ZGC的缺陷
不同的GC策略各有其用武之处,没有最好的GC策略,只有最合适的GC策略。ZGC也并非完美无缺,相比G1,也存在一些缺陷。
首先就是刚刚提到过的,RSS异常问题,可能会引起一些误判,不过这只是个可以解决的小问题。
其次,就是ZGC真的占内存会更高一点。首先是因为它把染色信息记录在了指针上,导致指针本身无法压缩。不过话说回来,ZGC本来就是为超大内存服务的,指针压缩这么点内存不是事儿。而且,ZGC不分代,一次并发标记的时间比较长,无法解决在这个过程中会产生的浮动垃圾垃圾,浮动垃圾只能等待下次GC再回收,也会导致内存占用升高。
ZGC的另一个大问题是,在堆内存不大的情况下,尽管减少了STW的时间,但是总吞吐量其实是不如G1的。乍听起来有点不可思议,不过其实也不难理解:ZGC的基础算法仍然是可达性分析和标记整理,GC总共要干的事情都是那么多,G1的选择是其他线程都让开,等我干完了你们再来,而ZGC追求的是在干事时,可以和用户线程进行一定的并发,为了实现这个目标,ZGC的一些额外处理增加了CPU的开销,导致总吞吐量的下降。比如说,在分配对象时可能会卡顿,在Region进行尺寸转换时也可能会卡顿。
以目前的情况来看,适用于ZGC的情况主要有两种:一个是超大堆内存的应用,再一个就是追求高可用低停顿(例如我们前文举例的场景)的应用。