一、简介
ZGC这个名字中的Z,并不是什么单词的缩写,这个垃圾回收器的英文名字就叫做Z Garbage Collector,是一款追求低延迟的垃圾回收器,在jdk11中被加入到垃圾回收器家族中,注意在这个版本中,它是具有实验性质的,如果想在生产中使用,建议使用更高版本的jdk。
二、工作原理
与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC停顿时间及其短的关键所在。
ZGC垃圾回收周期如下图所示:
- 并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1初始标记和最终标记(ZGC中就是名字不同而已)的短暂的停顿,整个标记阶段只会更新染色指针中的Marked 0、Marked 1标志位。停顿时间和堆大小无关,只和GC Roots数量有关。总结就是:并发标记阶段会有两个短暂STW。ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
- 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。ZGC的重分配集只是决定里面的存活对象会被复制到其他的Region。不是为了效益回收。JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。
- 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。ZGC的染色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢,而Shenandoah的Brooks转发指针是每次都会变慢。 一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表。 举例如:因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。
- 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。
总结一下,STW的时间段是初始标记,再标记,初始转移(就是被我用很丑的字体标出来的那块)。初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
三、关键技术
1、染色指针和lvb
这两个技术是位了解决并发转移过程中准确访问对象的问题。
为了防止你看懵,我解释下并发过程转移过程中对象访问问题 并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新, 那么应用线程可能访问到旧地址,从而造成错误
染色指针是一种将信息存储在指针中的技术,而ZGC中的染色指针指的是把对象的状态存储在指针中,ZGC把64位虚拟地址空间划分为多个子空间(这是它仅支持6位系统的原因),如下图所示:
标记信息存储在引用对象的指针上:高18位没有使用,4位用来存储标记信息,低42位存储对象地址,最重要的是用来存储标记信息的这4位,作用分别如下:
- Marked0 /Marked1:标记位,标记对象是否可用,
- Remapped:记录对象是否进入过重分配集(对象是否移动过)
- Finalizable:标记对象是否只能通过fnalize()访问 使用两个标记位Marked0、Marked1:在不同的回收周期交替使用,上一回收周期的标志位在本周期失效,重置为0,如:一个周期中使用Marked0,存活对象标记为01,则下一个周期使用标记位Marked1,存活对象标记为10。
lvb的本质上是读屏障,读屏障你可以理解为读对象之前的aop,也就是读对象之前做了一些别的事情。引用R大的话说,在ZGC中,LVB做的事情都有:在标记阶段它会把指针标记上并把堆里的这个指针给“修正”到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过stop-the-world这种最粗粒度的同步方式来让GC与应用之间同步,其实这也就是所谓的自愈(self-healing)。
2、支持NUMA—Aware
NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种多处理器或多核处理器计算机所设计的内存架构。
现在多CPU插槽的服务器都是Numa架构,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。 ZGC默认支持NUMA架构,在创建对象时,根据当前线程在哪个CPU执行,优先在靠近这个CPU的内存进行分配,这样可以显著的提高性能。
四、再聊分区和分代
1、ZGC中的分区技术
ZGC也使用了分区,和G1不同的是ZGC中根据不同大小,将区分成了如下几种(其实ZGC的术语中这个叫Page):
- 小型region:固定为2M,用于存储小于256KB的小对象;
- 中型region:固定为32M,用于存储大于等于256KB,小于4M的对象;
- 大型region:大小可变化,需为2M的整数倍,存储大于等于4M的对象,大型region最小为4M,每个大region中只会存放一个大对象。大型region是不会进行重分配的动作的,因为复制大对象的代价还是很大的。
2、分代技术和分区技术
分区和分代的细节我就不介绍了,不明白的可以看看前面的文章。分区和分代是丝毫不冲突的,这也是很多人不理解的地方,其实这完全是两件不同的事情,但是目的是相同的,都是避免一次性扫描太大的内存空间而造成延迟增加和吞吐量的降低。那为什么ZGC没有分代呢,通过查看各种资料发现,其实ZGC也在规划分代,所以之前没有应该也纯粹就是因为设计者的时间问题吧。