写点什么

深入解析 ZGC 垃圾回收器

作者:码农BookSea
  • 2023-08-29
    浙江
  • 本文字数:4455 字

    阅读完需:约 15 分钟

深入解析ZGC垃圾回收器

本文已收录至 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 技术干货,文章持续更新,可以关注公众号第一时间阅读。


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

发布于: 2023-08-29阅读数: 37
用户头像

码农BookSea

关注

Java开发工程师 2021-12-26 加入

Java开发菜鸟工程师,写博客的初衷是为了沉淀我所学习,累积我所见闻,分享我所体验。希望和更多的人交流学习。

评论

发布
暂无评论
深入解析ZGC垃圾回收器_Java_码农BookSea_InfoQ写作社区