深入解析ZGC垃圾回收器

发布时间 2023-08-29 19:25:28作者: Booksea

本文已收录至GitHub,推荐阅读 ? Java随想录

微信公众号:Java随想录

原创不易,注重版权。转载请注明原作者和原文链接

这篇文章来介绍这个最后出场的人物:ZGC。

ZGC有人称它为Zero GC,其实「Z」并非什么专业名词的缩写,这款收集器的名字就叫作Z Garbage Collector。

根据OpenJDK官方网站的说明ZGC其实并没有什么特殊意义,就是一个名字而已。起初只是为了致敬ZFS 文件系统,表示ZGC与ZFS一样都是革命性的,是一个跨时代的产品。更像是一种崇拜命名法。所以ZGC就是要做革命性的与以往的垃圾回收器性能上有很大提高的GC。

ZGC的目标是希望在尽可能对吞吐量影响不太大的前提下 ,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。

在ZGC算法中,并没有分代的概念,所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。

Region布局

先从ZGC的内存布局说起。

和G1一样,ZGC也采用基于Region的堆内存布局,但与G1不同的是,ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。

在x64硬件平台下,ZGC的Region可以有小、中、大、三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作「大型Region」,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

读屏障

之前的GC都是采用写屏障(Write Barrier),而ZGC采用的是读屏障。

读屏障(Load Barriers)类似于 Spring AOP 的前置通知。

在ZGC中,当读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为叫做指针的「自愈能力」。

这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW,类似JDK里的CAS自旋,读取的值发现已经失效了,需要重新读取。

好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当「自愈」完成后,后续访问就不会变慢了。

正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。不过这点开销是值得的。

染色指针

ZGC收集器有一个标志性的设计是它采用的「染色指针」技术。

ZGC 出现之前, GC 信息保存在对象头的 Mark Word 中,如对象的哈希码、分代年龄、锁记录等就是这样存储的。

追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。

例如对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关。

而ZGC的染色指针将这些信息直接标记在引用对象的指针上。

染色指针是一种直接将少量额外的信息存储在指针上的技术,Linux下64位指针的高18位不能用来寻址,ZGC的染色指针技术盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息

当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。

JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)。

18位:预留给以后使用;
1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
1位:Marked1标识;
1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
42位:对象的地址(所以它可以支持2^42=4T内存);

染色指针的优势

染色指针主要有三大优势:

  • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。理论上只要还有一个空闲Region,ZGC就能完成收集。
  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。因为信息直接维护在指针中。
  • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
    如果开发了前18位指针,既可以腾出已用的4个标志位,将ZGC可支持的最大堆内存从4TB拓展到64TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。

运作过程

ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化GC Root直接关联对象的Mark Start,ZGC的运作过程具体如图所示。

  • 并发标记(Concurrent Mark):并发标记是遍历对象图做可达性分析的阶段。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位

  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。

    重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。

    相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本

    因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。

    此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。

  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系

    得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。

    ZGC将这种行为称为指针的「自愈”(Self-Healing)」能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢。

    因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。

    还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。

  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。

    这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要「迫切」去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。

    重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很「迫切」。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

ZGC几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。

ZGC的优缺点

相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择。

譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。

ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。

可是,有优就有劣,ZGC的这种选择也限制了它能承受的对象分配速率不会太高。

因为ZGC四个阶段都支持并发,如果分配速率高,将创造大量的新对象,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。

目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建。所以分代算法有利有弊。

最后对本篇文章做一个提炼总结:

ZGC(Z Garbage Collector)是一个面向并行、无停顿时间的垃圾回收器,它作为JDK11的一部分首次引入,并且在JDK15开始被正式视为生产就绪级别:

  1. 出现意义:面对现代硬件环境中大内存、多核心的趋势以及微服务等新型应用的需求,如低延迟和高吞吐量,传统的垃圾收集器(如Parallel GC和CMS等)可能无法满足要求,尤其在处理多达数TB内存的情况下。在这样的背景下,ZGC应运而生,其设计目标是处理大型堆内存,同时将停顿时间限制在10ms以内,并且不牺牲整体吞吐量。
  2. 主要特点:其采用读屏障(Read Barrier)和染色指针(Colored Pointer)技术,实现了可扩展性,可以从几百MB到4TB的Java堆大小进行高效处理。此外,ZGC能够实现几乎所有的工作都在并行和并发阶段完成,包括对象可达性的标记、对象重定位和引用更新等操作。这使得它能够大幅度地降低垃圾收集带来的停顿时间。
  3. 适用场景:ZGC非常适合需要大内存,低延时,以及可预测的响应时间的系统,例如,金融交易、游戏、广告科技等领域的应用。
  4. 局限性:尽管ZGC有许多优点,但也有一些局限性。例如,由于其复杂的实现,对JVM的代码入侵较深,可能会与一些JVM特性或者优化手段不兼容。另外,尽管ZGC的暂停时间很短,但并发处理可能占用较多的CPU资源,所以在CPU敏感的环境下,其表现可能不如其他垃圾收集器。

总的来说,ZGC是一种创新的垃圾收集器,它解决了大内存和低延迟之间的矛盾,为构建现代大规模、高性能的Java应用提供了更多可能。


感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。

老铁们,关注我的微信公众号「Java 随想录」,专注分享Java技术干货,文章持续更新,可以关注公众号第一时间阅读。

一起交流学习,期待与你共同进步!