前言
上文讲到了Shenadoah收集器,这一节我们来讲一下ZGC收集器,ZGC收集器是JDK11之后由Oracle官方开发的一款低延迟垃圾收集器。另外这里吐槽一句ZGC的内容非常复杂并且知识点巨多,所以建议泡杯茶边喝边看。
在正式的介绍之前,先看下ZGC支持的内容:
关于ZGC的关键字如下:
- Concurrent(并发)
- Region-based(region)
- Compacting(压缩-整理算法)
- NUMA-aware(NUMA支持)
- Using colored pointers(染色指针)
- Using load barriers(读屏障)
概述
- 介绍ZGC收集器,以及ZGC收集器的特点(重点:染色指针)
- 了解ZGC的基本工作原理,以及工作流程和步骤
- ZGC的深入学习方式了解(文末)
ZGC兼容性
Supported Platforms
Platform | Supported | Since | Comment |
Linux/x64 | YES | JDK 11 | |
Linux/AArch64 | YES | JDK 13 | |
macOS | YES | JDK 14 | |
Windows | YES | JDK 14 | Requires Windows version 1803 (Windows 10 or Windows Server 2019) or later. |
ZGC收集器
介绍
ZGC收集器从名称上来看是一个缩写,然而实际上他是Z Garbage Collector
的缩写,ZGC和Shenadoah垃圾收集器类似,都是面对低延迟为设计目标的垃圾收集器,并且都希望垃圾收集器的收集时间控制在10ms以内。
可以说Shenandoah是对G1垃圾收集器的扩展和升级。而ZGC更像是对于PGC垃圾收集器和C4垃圾收集器的结合。
至于PGC和C4是个啥东西,这里简单理解为一款 实现了标记和整理阶段都全程与用户线程并发运行 的垃圾收集,但是只能在Azul VM的虚拟机上运行(2005年就实现了,有点牛)
如果一定要用一段简单的话介绍的话:ZGC收集器是一个基于Region内存布局的,(暂时**)不设置分代**,同时使用了读屏障(注意没有使用写屏障),使用了染色指针和内存多重映射等技术,并且是基于 标记-整理算法的,以低延迟为核心的垃圾收集器。
ZGC的特点与特性:
下面来说说ZGC的垃圾收集器的特点,ZGC的特性十分复杂,也是本文最为重点的内容:
压缩-整理算法
ZGC使用的是压缩整理+复制算法进行处理,复制算法用于将存活对象复制到空闲的region。标记整理用于保证收集之后不会出现内存碎片。
Region
和Shenandoah收集器一样,ZGC使用了region作为堆内存的布局但是ZGC的region具备大中小三个容量的region:
- 小型:固定为2MB,放置小于256Kb的小对象
- 中型:固定为32MB,放置大于256Kb以及小于4Mb的对象
- 大型:容量不固定,可以动态的扩展,但是必须为2MB的整数倍,放置4MB以上的对象,每个大region只会存放一个对象,虽然称作大对象,但是明显可以存放4MB的对象,并且大对象有个比较严重的问题是不能进行重分配(ZGC的处理动作,在工作流程会提到),复制一个大对象的代价十分高昂,所以zgc禁止了这一个操作。
并发整理算法
ZGC使用的也是是并发整理的垃圾回收算法,但是ZGC并发整理是通过读屏障和转发指针实现的,和shenandoah的实现方式完全不同。下面我们先来了解一下什么是染色指针。
染色指针:
首先,ZGC使用的转发指针被称为 染色指针。染色指针是最纯粹的标记记录存在的方法,它直接将少量额外的信息存储在指针上,ZGC盯上的是寻址空间被操作系统占用之后剩下的46位空间的物理地址空间,将高4位提取出来存储4个标志信息,虚拟机可以直接通过这几个信息指针看到引用的三色标记状态,是否重分配(移动过),是否只能通过finalize()才能访问到,(64位的linux高18位是占用的)当然只有46位的地址空间也直接导致ZGC能够管理的内存不可以超过4TB,使用物理地址空间意味着不能使用指针压缩技术。
为了更好的理解,我们来看一下官方源代码中给出的图,说白了染色指针就是用了一部分地址空间来存放一些对象的标记信息,同时在对象移动之后也能保证对象引用的同步移动,0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB (理论上可以16TB)的内存,因为就只用了42 位用来表示地址,这也决定了他不能进行指针压缩和不支持32位的操作系统,42-45位表示的是标志位,他们就是用来记录对象引用的,同时会指向同一个对象。
这里有一点需要注意的是这几个变量:M0、M1、Remapped、Finalizable。其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[16TB ~ 20TB) 称为Remapped空间。这里有个问题就是**[12TB ~ 16TB)** 这一段空间是没有使用的,其实染色指针是可以实现到16TB,并且在JDK13中已经实现了,为什么JDK11没有做呢?就是在这里进行了预留,在JDK13已经将这个预留空间进行的填充,让ZGC支持16G的内存。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
对象引用移动在以前是如何实现的?
在以前的实现中,如果想在对象存储额外的信息比如想要收集垃圾收集器的信息,就需要在对象头额外的扩展字段,比如对象头和对象年龄以及对象的锁状态等信息,这些信息在通常情况下是十分流畅好用的,但是一旦对象移动,事情就变得十分复杂了,这些信息究竟和谁产生关联?注意这里有个误区,认为这些数据和对象本身有关,然而实际上,它和对象的引用存在关系,胃泌素会这样,试想一下假设只存在对象但是本身没有对象的引用,这种对象有价值么?显然这种对象是垃圾对象。所以对象的引用才是和这些数据存在关联的。
而为了实现对象的引用记住这一点,在Hotspot的设计方案中,出现过把标记标记在对象头(Serial),把标记记录放置到独立的数据结构(G1,Shenadoah )Bitmap。
染色指针是如何工作的?
介绍完染色指针的实现,我们来看下染色指针是如何工作的:染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。意味着只要有空闲的region,ZGC就可以完成回收的操作。
Shenandoah的问题就在于此,转发指针的方式毫无疑问需要对于引用的指向进行修复(CAS锁),意味着会出现所有的region都会存活的极端情况,这时候如果需要复制的话会需要一个至少有一半空闲空间Region来完成回收的操作。
为什么染色指针可以做到这种事情,这又和染色指针的自愈特性有关系了。
指针自愈
简单概括来说就是在访问到重分配的对象会被内存屏障捕获之后通过转发指针记录表将指向旧对象的引用修复到指向新引用,这个过程就是指针自愈。
什么是染色指针的指针自愈呢?这里牵扯到 “并发重分配”的过程,为了加深指针的概念这里放到一起讲解,我们跳过并发重分配的处理过程,实现这一步的关键就是染色指针,在ZGC中可以根据染色指针知道对象的引用是否在一个重分配集当中,如果用户线程访问了重分配集中的对象,这一个操作就会被预先放置的内存屏障截获,然而立即根据region的转发表记录将访问转发到新复制的对象,同时修正引用的值,然后让引用指向新对象。
注意这个过程看起来和shenandoah的转发指针没两样,但是要注意的是shenadoah用的是读写屏障+带CAS锁操作的转发指针实现的。而ZGC直接通过染色指针加上转发指针记录表记录以及写屏障直接实现了这一个操作。两者存在本质的差别。
虚拟内存映射技术
注意这个技术是为了实现染色指针使用的,它的作用是多个虚拟地址指向同一个物理地址,经过多重映射转换之后,就可以实现染色指针的正常访问和寻址了。
读屏障
G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收,Shenadoah之前文章也说过只用转发指针(brooks pointer)+读写屏障完成对象新旧引用的修复动作。而ZGC没有用写屏障,而是只是用读屏障实现了并发垃圾回收的动作,具体如何应用
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障 <Load barrier> Object p = o // 无需加入屏障,因为不是从堆中读取引用 o.dosomething() // 无需加入屏障,因为不是从堆中读取引用 int i = obj.FieldB //无需加入屏障,因为不是对象引用 复制代码
NUMA 支持(JDK15)
下面是NUMA的官方wik介绍,注意在jdk15的版本才支持,JDK11是没有进行支持的,另外g1收集器在jdk14版本中也完成了支持。ZGC实现NUMA的方式如下:
当Java线程分配一个对象时对象将最终位于正在执行的Java线程CPU的本地内存中,如果本地内存不足则从远程内存分配,zgc收集器会优先尝试在请求线程当前处理器多本地内存上分配对象,
ZGC具有NUMA 支持,这意味着它会尽量将 Java 堆分配定向到 NUMA 本地内存。 默认情况下启用此功能。 但是如果 JVM 检测到它绑定到系统中的 CPU子集它将自动禁用。 也就是说我们通常基本不需要管这个参数,但是可以使用
-XX:+UseNUMA
或-XX:-UseNUMA
选项来进行控制。在 NUMA 机器(例如多路 x86 机器)上运行时,启用 NUMA 支持通常会显着提升性能。
在介绍什么是NUMA之前,先了解什么是SMP:
对称多处理器结构(Symmetric Multi-Processor,SMP)
对称多处理系统内有许多紧耦合多处理器,在这样的系统中,所有的CPU共享全部资源,如总线,内存和I/O系统等,简单来说就是这种结构中所有的CPU共享一个资源,最大的特点也是计算机共享一个内存资源,这种结构在早期的南北桥的CPU结构上非常常见,结构图如下:
网络异常,图片无法展示|
从图中可以看到,由于所有的CPU访问到的内存内容都是一致的(访问速度也是一致的),所以 SMP 也被称为一致存储器访问结构 (UMA : Uniform Memory Access)
随着现代处理器的不断进步,SMP架构让内存跟不上CPU的处理速度,导致大量的内存被“浪费”,所以后来人们改进出了NUMA的架构:
非一致内存访问 (Non-Uniform Memory Access,NUMA)
wik地址:en.wikipedia.org/wiki/Non-un…
由于 SMP 在扩展能力上的限制,人们开始探究如何进行有效地扩展从而构建大型系统的技术, NUMA 就是这种努力下的结果之一。NUMA实现的就是把内容和CPU集成到一个单元上,同时由于这种CPU和内存并到一起的结构,会出现内存的访问“不一致”的特性,所以这也是被称为非一致性内存访问的原因
网络异常,图片无法展示|
从上图可以看到NUMA 尝试为每个处理器提供单独的内存来解决这个问题,避免在多个处理器尝试寻址同一内存时性能下降。
最后,在ZGC之前的收集器就只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配,在JDK14G1完成了支持,JDK15中ZGC也完成了支持。
仅支持64位系统
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,原因是使用了染色指针。