ZGC(Z Garbage Collector)是在 JDK 11 中新加入的低延迟垃圾收集器。ZGC 的目标是在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在 10ms 内的低延迟。
ZGC 收集器是一款基于 Region 内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
内存布局
与 G1 一样,ZGC 也采用基于 Region 的堆内存布局,区别是 ZGC 的 Region 具有动态性——动态创建、销毁以及动态的区域容量大小。在 x64 硬件平台下可具有如下图所示的大、中、小三类容量:
- 小型 Region 容量固定为 2MB,用于放置小于 256KB 的小对象。
- 中型 Region 容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。
- 大型 Region 的容量可动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。
ZGC 的每个大型 Region 中只会存放一个大对象,这样大型 Region 在 ZGC 中就不会被重分配,因为复制一个大对象的代价非常高昂。
染色指针技术
ZGC 收集器有一个标志性的设计是它采用的 染色指针技术(Colored Pointer)。ZGC 的染色指针技术直接把标记信息记在引用对象的指针上。这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。
染色指针技术是一种直接将少量额外的信息存储在指针上的技术。尽管 Linux 下 64 位指针的高 18 位不能用来寻址,但剩余的 46 位指针所能支持的 64TB 内存在今天仍充分满足大型服务器的需要。因此 ZGC 的染色指针技术将这剩下的 46 位指针宽度的高 4 位提取出来存储四个标志信息。
通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过 finalize() 方法才能被访问到。由于这些标志位进一步压缩了原本就只有 46 位的地址空间,也直接导致 ZGC 能够管理的内存不可以超过 4TB(2 的 42 次幂)。
虽然染色指针有 4TB 的内存限制,不能支持 32 位平台,不能支持压缩指针等诸多约束,但它带来的收益也是非常可观的,其中主要有三大优势:
染色指针 可以使得一旦某个 Region 的存活对象被移走后,这个 Region 能立即被释放和重用掉,而不必等整个堆中所有指向该 Region 的引用都被修正后才能清理(通过转发表实现指针自愈)。使得理论上只要还有一个空闲 Region,ZGC 就能完成收集,避免了堆中几乎所有对象都存活的极端情况而要占用一半的空闲 Region 来完成收集。
染色指针 可大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,ZGC 将这些信息直接维护在指针中,就省去了记忆集中引用关系的维护,因此也不需要写屏障了。
染色指针 可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
虚拟内存映射技术
但 Java 虚拟机作为一个普通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统和处理器是否支持?因为程序代码最终都要转换为机器指令流交给处理器去执行,处理器不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。
因此 ZGC 的设计者提出了一种 虚拟内存映射技术 的解决方案。在远古时代的 x86 计算机系统里面,所有进程都是共用同一块物理内存空间的,这会导致不同进程间的内存无法相互隔离,为此处理器提供了“保护模式”用于隔离进程。处理器会使用 分页管理机制 把线性虚拟空间和物理地址空间分别划分为大小相同的页(Page),通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会通过映射关系完成线性地址到物理地址空间的转换。
Linux x86-64 平台上的 ZGC 使用 多重映射 将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是多对一映射,意味着 ZGC 在虚拟内存中看到的地址空间要比实际的堆内存容量更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经 多重映射 转换后,就能使用染色指针正常进行寻址了。
在上图中,有三个特别的视图:Marked0、Marked1 和 Remapped,它们映射到操作系统的同一物理地址。在 ZGC 中这三个空间在同一时间点有且仅有一个空间有效,它们之间的切换是由垃圾回收的不同阶段触发的。这三个视图分别将对应的位设置为 1,就表示采用对应的视图。但对于操作系统来说,在寻址的时候会把标记位和虚拟地址结合使用。
由于 42 位地址最大的寻址空间是 4TB,所以 ZGC 最大支持 4TB 内存,其中 0~4TB 的虚拟地址是 ZGC 提供给应用程序使用的虚拟空间,但它不会映射到真正的物理地址。真正使用的虚拟地址为 4TB~8TB、8TB~12TB 和 16TB~20TB(即普通指针通过染色指针技术修改后的指针)。
垃圾收集过程
ZGC 的运作过程大致可划分为以下四个大阶段,这四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,具体过程如下图所示:
**
与 G1 一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似 G1 的初始标记、最终标记的短暂停顿,与 G1 不同的是 ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked0、Marked1 标志位。
并发预备重分配(Concurrent Prepare for Relocate)
这个阶段要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,然后将这些 Region 组成 重分配集(Relocation Set)。重分配集与 G1 收集器的 回收集(Collection Set)还是有区别的,ZGC 划分 Region 的目的并非为了像 G1 那样做收益优先的增量回收。相反,ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中 记忆集(保存跨区域的引用,但要通过写屏障维护)的维护成本。
因此,ZGC 的重分配集只是决定了里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而并不能说回收行为就只是针对这个集合里面的 Region 进行,因为标记过程是针对整个堆的。
并发重分配(Concurrent Relocate)
重分配过程要把 重分配集 中的存活对象复制到新的 Region 上,并为 重分配集 中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针技术,ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的自愈能力。
这样做的好处是只有第一次访问旧对象会转发,也就只慢一次,并且由于染色指针的存在,一旦 重分配集 中某个 Region 的存活对象都复制完毕后,这个 Region 就能立即释放用于新对象的分配了,因为转发表还留着不会释放掉,所以即使堆中还有很多指向这个对象的未更新指针也没关系,它们都是能自愈的。
并发重映射(Concurrent Remap)
重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,前面说过,即使是旧引用,它也是可以自愈的,只是第一次使用时多了一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢,还有清理结束后可以释放转发表这样的附带收益。一旦所有指针都被修正后,原来记录新旧对象关系的转发表就可以被释放掉了。
优缺点
相比 G1,ZGC 在实现细节上做了一些不同的权衡选择,譬如 G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现 Region 的增量回收。但记忆集需要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。
ZGC 就完全没有使用记忆集,它甚至连分代都没有,连像 CMS 中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担小得多。但 ZGC 的这种选择也限制了它能承受的对象分配速率不会太高。