ZGC

194 阅读34分钟

1 ZGC收集器

ZGC(“Z”并非什么专业名词的缩写,这款收集器的名字就叫作THE Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。2018年Oracle创建了JEP333将ZGC提交给OpenJDK,推动其进入OpenJDK11的发布清单之中。

ZGC的设计目标包括:

  • 停顿时间不超过10ms;
  • 支持 8MB~4TB级别的堆(JDK13支持16TB),我们生产环境的硬盘还没有上TB的,这应该可以满足未来十年内,所有 Java应用的需求了吧;
  • 最糟糕的情况下吞吐量会降低15%。停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。

ZGC 的核心是一个并发垃圾收集器,这意味着所有繁重的工作都在Java 线程继续执行的同时完成。这极大地限制了垃圾收集对应用程序响应时间的影响。

首先从ZGC的内存布局说起。和G1一样,ZGC也采用基于Region的堆内存布局,,ZGC的Region(在一些官方资料中将它称为Page或者ZPage)具有动态性——动态创建和销毁,以及动态的区域容量大小。

在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的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)的,因为复制一个大对象的代价非常高昂。

2 ZGC 内存管理

ZGC为了能高效、灵活地管理内存,实现了两级内存管理:虚拟内存和物理内存,并且实现了物理内存和虚拟内存的映射关系。这和操作系统中虚拟地址和物理地址设计思路基本一致。ZGC主要的改进点就是重新定义了虚拟内存和物理内存的映射关系。

我们先从一个问题出发。我们知道ZGC目前仅支持64位Linux,最多管理4TB的内存。但是我们知道,64位系统支持的内存远超过4TB,那么为什么我们一直强调它只能支持4TB的内存,为什么不使用更多的虚拟内存?

+--------------------------------+ 0x00007FFFFFFFFFFF (127TB)
-                                -
-                                -
-                                -
+--------------------------------+ 0x0000140000000000 (20TB)
| Remapped View                  |
+--------------------------------+ 0x0000100000000000 (16TB)
| (Reserved, but unused)         |
+--------------------------------+ 0x00000c0000000000 (12TB)
| Marked1 View                   |
+--------------------------------+ 0x0000080000000000 (8TB)
| Marked0 View                   |
+--------------------------------+ 0x0000040000000000 (4TB)

+--------------------------------+ 0x0000000000000000 

上面的示意图中提到了3个视图,分别是Marked0、Marked1和Remapped,这3个视图会映射到操作系统的同一物理地址。这就是ZGC中Color Pointers的概念,通过Color Pointers来区别不同的虚拟视图。 在ZGC中常见的几个虚拟空间有[0 ~ 4TB)、[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)。其中[0 ~ 4TB)对应的是Java的堆空间;[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)分别对应Marked0、Marked1和Remapped这3个视图。 这三个视图的关系上面的示意图中提到了3个视图,分别是Marked0、Marked1和Remapped,这3个视图会映射到操作系统的同一物理地址。这就是ZGC中Color Pointers的概念,通过Color Pointers来区别不同的虚拟视图。 在ZGC中常见的几个虚拟空间有[0 ~ 4TB)、[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)。其中[0 ~ 4TB)对应的是Java的堆空间;[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)分别对应Marked0、Marked1和Remapped这3个视图。 这三个视图的关系

  • 4TB是理论上最大的堆空间,其大小受限于JVM参数。
  • 0 ~ 41bit的虚拟地址是ZGC提供给应用程序使用的虚拟空间,它并不会映射正的物理地址。
  • 操作系统管理的虚拟内存为Marked0、Marked1和Remapped这3个空间,且对应同一物理空间。
  • 在ZGC中这3个空间在同一时间点有且仅有一个空间有效。为什么这么设计就是ZGC的高明之处,利用虚拟空间换时间;
  • 应用程序可见并使用的虚拟地址为0 ~ 41bit,经ZGC转化,真正使用的虚址为42bit、43bit和45bit,操作系统管理的虚拟也是42bit、43bit和45bit。应用程序可见的虚拟0 ~ 41bit和物理内存直接的关联由ZGC来管理。

操作系统管理的虚拟内存为Marked0、Marked1和Remapped这3个空间,且对应同一物理空间。这项技术叫做多重映射寻址

2.1 多重映射寻址

先看一个简单的例子。

你在你爸爸妈妈眼中是儿子,在你女朋友眼中是男朋友。在全世界人面前就是最帅的人。你还有一个名字,但名字也只是你的一个代号,并不是你本人。

假如你的名字是全世界唯一的,通过“你的名字”、“你爸爸的儿子”、“你女朋友的男朋友”,“世界上最帅的人”最后定位到的都是你本人。

2.2 操作系统地址管理

物理内存非常直观,就是真实存在的,其大小就是插在主板内存槽上的内存条的容量大小。我们经常所说的一台计算机配置有1GB或者2GB内存,指的就是真实的物理内存的大小。

虚拟内存是伴随着操作系统和硬件的发展出现的。虚拟地址是操作系统根据CPU的寻址能力,支持访问的虚拟空间,比如前些年大家使用的32位操作系统,对应的虚拟地址空间为,即0~4GB,而我们计算机的物理内存可能只有512MB,所以涉及物理内存和虚拟内存的映射。虚拟内存的发展解决了很多问题,也带来了很多好处。具体可以参考其他文献。

稍微介绍一下虚拟内存和物理内存的映射机制。上面提到虚拟内存和物理内存大小并不匹配,所以需要一个额外的机制把两者关联起来。当程序试图访问一个虚拟内存页面时,这个请求会通过操作系统来访问真正的内存。首先到页面表中查询该页是否已映射到物理页框中,并记录在页表中。如果已记录,则会通过内存管理单元(Memory Management Unit,MMU)把页码转换成页框码(frame),并加上虚拟地址提供的页内偏移量形成物理地址后去访问物理内存;如果未记录,则意味着该虚拟内存页面还没有被载入内存,这时MMU就会通知操作系统发生了一个页面访问错误(也称为缺页故障(page fault)),接下来系统会启动所谓的“请页”机制,即调用相应的系统操作函数,判断该虚拟地址是否为有效地址。如果是有效的地址,就从虚拟内存中将该地址指向的页面读入内存中的一个空闲页框中,并在页表中添加相对应的表项,最后处理器将从发生页面错误的地方重新开始运行;如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。当然,也存在这样的情况:在请页成功之后,内存中已经没有空闲物理页框了,这时,系统必须启动所谓的“交换”机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不再使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。对移出页面根据两种情况来处理:如果该页未被修改过,则删除它;如果该页曾经被修改过,则系统必须将该页写回辅存

2.2.1 多重映射寻址在操作系统层面的实现

前面我们提到MMU负责映射虚拟地址和物理地址,操作系统主要负责维护页表(page table),页表维护了虚拟地址和物理地址的映射关系。实际上现在的系统还支持多个虚拟地址同时映射到一个物理地址上,多个虚拟地址可以认为它们是彼此之间的别名。当我们操作其中一个虚拟地址,例如存储数据时,所有的虚拟地址都应该能访问到最新的数据。

这一特性在某些场景中特别有用,例如可以利用这一特性在两个虚拟地址之间复制大量的数据。这里介绍一下Linux和Windows这两种系统下是如何实现多视图映射的。

1. Linux系统

首先我们通过一个例子演示Linux多视图映射。Linux中主要通过系统函数mmap完成视图映射。多个视图映射就是多次调用mmap函数,多次调用的返回结果就是不同的虚拟地址。示例代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdint.h>

int main()
{
    //创建一个共享内存的文件描述符
    int fd = shm_open("/example", O_RDWR | O_CREAT | O_EXCL, 0600);
    if (fd == -1) return 0;
    //防止资源泄露,需要删除。执行之后共享对象仍然存活,但是不能通过名字访问
    shm_unlink("/example"); 
    
    //将共享内存对象的大小设置为4字节
    size_t size = sizeof(uint32_t);
    ftruncate(fd, size); 
    
    //两次调用mmap,把一个共享内存对象映射到两个虚拟地址上
    int prot = PROT_READ | PROT_WRITE;    
    uint32_t *add1 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
    uint32_t *add2 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
    
    //关闭文件描述符
    close(fd);
    
    //测试,通过一个虚拟地址设置数据,两个虚拟地址得到相同的数据
    *add1 = 0xdeafbeef;
    printf("Address of add1 is: %p, value of add1 is: 0x%x\n", add1, *add1);
    printf("Address of add2 is: %p, value of add2 is: 0x%x\n", add2, *add2);
    
    return 0;
}

在Linux上通过gcc编译后运行文件,得到的结果如下:

这里使用的系统调用shm_open()函数,需要在编译时加上-lrt,否则可能会出现链接错误。示例中调用mmap两次返回两个地址变量,从结果我们可以发现,两个变量对应两个不同的虚拟地址,分别是0x7f56f2989000和0x7f56f2988000,但是因为它们都是通过mmap映射同一个内存共享对象,所以它们的物理地址是一样的,并且它们的值都是0xdeafbeef。

2. Windows系统

Windows系统也提供地址映射函数,使用系统函数CreateFileMapping()创建内存映射对象,再多次调用MapViewOfFile()把一个内存映射对象映射到多个虚拟地址上,然后再操作虚拟地址。整体实现和Linux非常类似,这里提供一个示例程序,如下所示:

#include <Windows.h>
#include <WinBase.h>

int main()
{
    size_t size = sizeof(LPINT);
    HANDLE hMapFile = CreateFileMapping(INVALID_HANDLE_VALUE,
        NULL,
        PAGE_READWRITE,
        0, size,
        NULL);

    LPINT add1 = (LPINT)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, size);
    LPINT add2 = (LPINT)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, size);

    *add1 = 0xdeafbeef;
    printf("Address of add1 is: %p, value of add1 is: 0x%x\n", add1, *add1);
    printf("Address of add2 is: %p, value of add2 is: 0x%x\n", add2, *add2);

    UnmapViewOfFile(add1);
    UnmapViewOfFile(add2);
    CloseHandle(hMapFile);
    return 0;
}

这个例子非常简单,仅保留必要工作,省略了很多异常处理。笔者在Windows平台使用Visual Studio Community 2017运行上述代码,可以得到如下结果:

这是与Linux中一样的结果。介绍完Linux和Windows平台如何实现运行的结果多视图映射,下面我们看一下ZGC是如何实现地址的多重映射的。

本质就是使用 mmap 把不同的虚拟内存地址映射到同一个物理内存地址上。

2.3 ZGC是如何实现地址的多重映射的

由于ZGC仅支持Linux 64位系统,所以可以想象ZGC是通过系统调用mmap来完成地址多视图映射的。ZGC实现多视图映射的过程和2.2.1节基本一致,步骤可以总结如下:
创建并打开一个文件描述符,这个文件描述符可以是内存文件描述符,也可以是普通文件描述符(最好是内存文件描述符,其性能更高)。创建并打开文件描述的动作是在JVM启动时完成的,简化的流程图如图

创建内存文件描述符通过系统调用memfd_create函数完成,而memfd_create函数是内核态才能调用的函数,所以必须通过syscall函数从用户态进入内核态,并传递参数__NR_memfd_create,最终操作系统调用相应的函数完成。另外需要注意的是在初始化过程中,如果不能创建文件描述符,将导致初始化失败,JVM则不能启动

在ZGC中明确地指定了3个映射视图中的虚拟地址。这个代码片段就是ZPhysicalMemoryBacking中的成员函数map,它的实现非常简单,我们直接看一下源码,如下所示

void ZPhysicalMemoryBacking::map(ZPhysicalMemory pmem, uintptr_t offset) const {
  if (ZUnmapBadViews) {
    // 在调试参数ZUmmapBadViews为true时,只把地址映射到正在使用的地址视图中
    // 此时如果访问其他地址视图,将导致内存访问故障
    map_view(pmem, ZAddress::good(offset), AlwaysPreTouch);
  } else {
    // 把地址映射到3个视图中,根据垃圾回收进行的阶段自动选择地址视图
    map_view(pmem, ZAddress::marked0(offset), AlwaysPreTouch);
    map_view(pmem, ZAddress::marked1(offset), AlwaysPreTouch);
    map_view(pmem, ZAddress::remapped(offset), AlwaysPreTouch);
  }
}

只要记住操作系统管理的虚拟内存为Marked0、Marked1和Remapped这3个空间,且对应同一物理空间。就行了

3 染色指针

在操作系统中,指针是一种数据类型,用于存储内存地址。指针的大小通常与操作系统的位数有关,如在32位操作系统中,指针通常为4字节(32位),而在64位操作系统中,指针通常为8字节(64位)。

指针可以存储内存地址,使得程序可以间接地访问和操作内存中的数据。指针本身并不存储数据,而是指向内存中的数据。因此,指针可以用来访问任何数据类型的变量,包括基本数据类型、数组、结构体等。

  • 在内存中,每8位在内存中作为一个储存单元,每8位(bit)也叫一字节(byte),作为内存单位
  • 在32位系统中,每一个存储单元用4字节(也就是32位二进制数)给自己编号,也就是说,一个地址用32位二进制数来表示。编址范围是 2^32,也就是4G。
  • 在64位系统中,每一个存储单元用8字节(也就是64位二进制数)给自己编号,也就是说,一个地址用64位二进制数来表示。编址范围是2^64,也就是16777216T,但实际上没有使用那么多位。

ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)。从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?这样的要求并非不合理的刁难,先不去说并发移动对象可能带来的可访问性问题,此前我们就遇到过这样的要求——追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果。HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),而ZGC的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。

染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。

尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。

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

ZGC必须要在64bit的机器上才能运行,其中使用低42位来标识堆空间,然后借用几个高位来记录GC中的状态信息,分别为M0、M1、Remapped和一个预留字段,因为对象的引用地址大于35bit,所以在ZGC中是无法使用压缩指针的。

正如图上所示,这个对象引用地址的64bit表示的是虚拟地址空间,并不是真实的物理地址空间,为什么这么设计呢?

想一下,在GC的过程中,指针中用于存储额外信息的bit位是变化的,也就是说对象的引用地址一直是在变化的,这怎么可能是物理空间的地址呢,对程序来说,只要这个对象没有被移动,那么它的物理地址空间就一定是不变的,那么ZGC的对象引用地址一直在变,这里指的是对象的虚拟地址,JVM怎么知道真实的物理地址空间呢?

这里面就涉及到了虚拟地址空间与物理地址空间的映射,不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面以及软件进程层面来实现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。

Linux/x86-64平台上的ZGC使用的是多重映射(Multi-Mapping),即将多个不同的虚拟内存地址映射到同一个物理内存地址上,这种多对一映射,意味着ZGC在虚拟地址中看到的地址空间要比实际的堆空间容量更大,因为在虚拟空间中,ZGC的对象占45bit,而在物理内存空间中,只有其中的41bit表示内存地址。

有个多重映射,ZGC在GC过程中,不论M0、M1和Remapped对应的bit位怎么变化,它们对应的具体的对象的内存空间都是同一个。

染色指针的数据结构

  • 0 ~ 41位:用于描述真正的虚拟地址

  • 42 ~ 45位:用于描述元数据,其实就是Color Pointers

    • 0001 = Marked0
    • 0010 = Marked1
    • 0100 = Remapped
    • 1000 = Finalizable
  • 46位:目前暂时没有使用

  • 47 ~ 63位:固定为0,暂未使用

 6                 4 4 4  4 4                                             0
 3                 7 6 5  2 1                                             0
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
|                   | |    |
|                   | |    * 41-0 Object Offset (42-bits, 4TB address space)
|                   | |
|                   | * 45-42 Metadata Bits (4-bits)  0001 = Marked0
|                   |                                 0010 = Marked1
|                   |                                 0100 = Remapped
|                   |                                 1000 = Finalizable
|                   |
|                   * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)

由于42位地址最大的寻址空间就是4TB,这就是ZGC一直宣称自己最大支持4TB内存的原因。这里还有视图的概念,Marked0、Marked1和Remapped就是3个视图,分别将第42、43、44位设置为1,就表示采用对应的视图。在ZGC中,这4位标记位的目的并不是用于地址寻址的,而是为了区分Marked0、Marked1和Remapped这3个视图。当然对于操作系统来说,这4位标记位代表了不同的虚拟地址空间,操作系统在寻址的时候会把标记位和虚拟地址结合使用。由于42位地址最大的寻址空间就是4TB,这就是ZGC一直宣称自己最大支持4TB内存的原因。这里还有视图的概念,Marked0、Marked1和Remapped就是3个视图,分别将第42、43、44位设置为1,就表示采用对应的视图。在ZGC中,这4位标记位的目的并不是用于地址寻址的,而是为了区分Marked0、Marked1和Remapped这3个视图。当然对于操作系统来说,这4位标记位代表了不同的虚拟地址空间,操作系统在寻址的时候会把标记位和虚拟地址结合使用。

我们还可以从另外一个角度来考虑为什么ZGC目前设计为支持4TB的内存管理。这是由于X86_64处理器硬件的限制,目前X86_64处理器地址线只有48条,也就是说64位系统支持的地址空间为256TB。为什么处理器的指令集是64位的,但是硬件仅支持48位的地址?最主要的原因是成本问题,即便到目前为止由48位地址访问的256TB的内存空间也是非常巨大的,也没有多少系统有这么大的内存,所以在设计CPU时仅仅支持48位地址,可以少用很多硬件。如果未来系统需要扩展,则无须变更指令集,只需要从硬件上扩展即可。
对于ZGC来说,由于多视图(Color Pointers)的缘故,会额外占用4位地址位,所以真正可用的应该是44位,理论上ZGC可以支持16TB的内存。目前支持的4TB只是人为的限制,很容易扩展到16TB,但是如果要扩展超过16TB时,则需要重新设计这一部分。

4 ZGC垃圾收集流程

4.1 复制算法

ZGC垃圾回收算法实际上是以复制算法为基础,增加了并发处理。我们先回顾一下复制算法,它可以概括为3个阶段,分别为标记(mark)、转移(relocate)和重定位(remap)。这3个阶段分别完成的功能是:

  • 标记:从GC Roots出发,标记活跃对象,此时内存中存在活跃对象和垃圾对象。
  • 转移:把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收。
  • 重定位:因为对象的内存地址发生了变化,所以所有指向对象老地址的指针都要调整到对象新的地址上。

ZGC垃圾回收算法实际上把上述3个阶段都修改成并发处理,并发复制算法示意图

4.2 ZGC垃圾回收的流程

ZGC并发垃圾回收算法包括10个步骤,我们具体看一下每一步所完成的工作:

  1. 初始标记:该步骤从根集合出发,找出根集合直接引用的活跃对象,并入栈;该步需要STW。
  2. 并发标记:根据初始标记找到的对象,作为并发标记的根对象,使用深度优先遍历对象的成员变量进行标记;并发标记需要解决标记过程中引用关系变化导致的漏标记问题。
  3. 再标记和非强根并行标记:在并发标记结束后尝试终结标记动作,理论上并发标记结束后所有待标记的对象会全部完成,但是因为GC工作线程和用户线程是并发运行,所以可能存在GC工作线程执行结束标记时,用户线程又有新的引用关系变化导致漏标记,所以这一步先判断是否真的结束了对象的标记,如果没有结束就还会启动并发标记,所以这一步需要STW。另外,在该步中,还会对非强根进行并行标记。
  4. 并发处理非强引用和非强根并发标记:在非强引用处理时对定义了finalize()函数的对象需要特殊处理,为此ZGC设计了特殊的标记,5.1.4节会详细介绍。另外,ZGC为了优化停顿时间,把一些需要在STW中并行处理的任务并发运行,这都被设计成非强根的并发标记。
  5. 重置转移集合中的页面:实际上第一次垃圾回收时无须处理这一步。
  6. 回收无效的页面:实际上在内存充足的情况下不会触发这一步。
  7. 并发选择对象的转移集合,转移集合中就是待回收的页面。
  8. 并发初始化转移集合中的每个页面,在后续重定位(也称为Remap)时需要的对象转移表(Forward Table)就是在这一步初始化的。
  9. 转移根对象引用的对象,该步需要STW。很多地方把这步叫做初始转移
  10. 并发转移:把对象移动到新的页面中,这样对象所在的老的页面中所有活跃对象都被转移了,页面就可以被回收重用。

为了画图方便,把步骤5~步骤8)放在一个并发步骤中,实际中这是4步,并且这4步是串行执行,每一步都是并发执行的。前4步都是做和标记相关的事情,可以总结为转移阶段,第5~10步都是做和转移相关的事情,称为转移。

初始标记

先STW,并记录下gc roots直接引用的对象。

并发标记/对象重定位

ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新颜色指针中的 Marked0、Marked1标志位,记录在指针的好处就是对象回收之后,这块内存就可以立即使用。存在对象上的时候就不能马上使用,因为它上面还存放着一些垃圾回收的信息,需要清理完成之后才能使用。

再标记/非强引用并行标记

在并发标记结束后尝试再标记动作,理论上并发标记结束后所有待标记的对象会全部完成标记,但是因为GC工作线程和用户线程是并发运行的,可能存在GC工作线程执行结束标记时,用户线程又有新的引用关系变化导致漏标记,所以这一步先判浙是否真的结束了对象的标记,如果没有结束还会启动并发标记,所以这一步需要STW。另外,在该步中,还会对非强根 (软引用,虚引用等) 进行并行标记。

并发预备重分配

这个阶段需要根据特定的查询条性统计出本次收集过程要清理那些 Region,将这些 Region组成重分配集(Relocation Set)。ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取G1中记忆集和维护成本。

初始转移

转移根对象引用的对象,该步需要STW。

并发重分配

重分配是 ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region上,并为重分配集中的每个 Region维中了一个转发表(Forward Table) ,记录从旧对象到新对象的转换关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集中,如果用户线程此时并发访问位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region上的转发表记录将访问转到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的"自愈” (Self-Healing) 能力。

ZGC的颜色指针具有"自愈” (self-healing) 能力,所以只有第一次访问旧对象会变慢,一旦重分配集合中某个 Region的存活对象都复制完毕后,这个 Region就可以立即释放用于新对象的分配,但是转发表还得留着不释放掉,因为可能还有访问在使用这个转发表。

并发重映射

重映射所做的就是修正整个堆中指向重分配集合中旧对象的所有引用,但是ZGC中对象引用存在"自愈"功能,所以这个重映射操作并不是那么迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正他们都是要遍历所有对象,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

ZGC只有三个阶段STW,分别是:初始标记、再标记、初始转移,其中初始标记和初始转移:只需要扫描所有的GC Root,其处理时间和GC Roots的数量成正比,一般情况耗时很短。最多1ms。超过1ms则再次进入并发标记阶段,即,ZGC几乎所有暂停都只依赖于 GCRoots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。

5 一个Demo

5.1 初始化阶段

在ZGC初始化之后,此时地址视图为Remapped,程序正常运行,在内存中分配对象,满足一定条件后(垃圾回收的触发时机)垃圾回收启动,此时进入标记阶段。

假设现在有四个对象,都在小页面(对象大小<2MB)A中,因为此时还没开始进行GC,所以所有引用的指针都处于Remapped状态

5.2 标记阶段

经过初始标记阶段后,对象A被GC Root引用,对象D没被任何对象引用,A对象引用的指针颜色发生变化:

然后进行并发标记,并发标记过程中,将所有存活对象的指针状态全部改成M0,由于D对象不可达,所以它的指针颜色还是蓝色,但在并发标记阶段,B对象又引用了一个新的对象E,因为是新的对象还没有被GC扫描过,所以指针颜色是蓝色:

而在重新标记阶段,根据原始快照(STAB)会从B对象继续开始扫描,然后把E对象的指针变为绿色:

至此标记阶段就完成。

如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是 Remapped视图,说明对象是不活跃的,转移阶段进行回收。

当标记阶段结束后,ZGC会把所有活跃对象的地址存到对象活跃集合中,活跃对象的地址视图都是M0。

5.3 转移阶段

转移阶段:转移阶段切换到 Remapped视图。因为应用程序和转移线程也是并发执行,那么对象的访问可能来自转移线程和应用程序线程。

在并发转移阶段,会分析最有价值的GC分页,这个过程类似于G1筛选回收阶段的成本与收益比分析。

在初始转移阶段,只会转移初始标记的存活对象,同时做对象的重定位,假设小页面A的对象都要转移到小页面B中,经过初始转移后,对象的位置及指针颜色如下:

因为A对象转移到新的页面时进行了重定位,所以A对象的指针状态变为Remapped蓝色。但对B对象的引用指针还是绿色。

然后进行并发转移,把B、C、E对象也都转移到小页面B中:

在并发转移阶段,只会把B、C、E对象转移到新的小页面B中,并不会修改它们对应的引用指针,也就是B对C的引用还指向原来小页面中的旧地址,并没有对转移对象做重定位。

在并发转移阶段,只会把B、C、E对象转移到新的小页面B中,并不会修改它们对应的引用指针,也就是B对C的引用还指向原来小页面中的旧地址,并没有对转移对象做重定位。

但对象既然转移了,那肯定需要根据之前旧的地址找到新的地址,所以每个页面都会维护一张转发表,这个转发表就记录了指针旧地址到新地址的映射。

注:对象转移与转发表插入记录这是一个原子操作,要么都成功,要么都失败。

至此ZGC的一次垃圾收集过程就结束了,但前面我们也说了,ZGC的整个垃圾收集流程是涉及到两次GC的。

5.4 重定位

在第一次GC的时候,只是用了M0,在第二次GC的时候,就会用到M1,这两个标志位就是为了区分两次不同的垃圾回收的。

在第一次GC完成和第二次GC开始的间隙,A对象又引用了一个新的对象F,如下图所示:


因为是一个新创建的对象,所以指针状态为Remapped。

当第二次GC开始,由于上一次GC使用M0来标识存活对象,那么这一次就采用M1来标识存活对象,然后经过初始标记后,对象A的引用就变成了红色:

然后进行并发标记,在并发标记的过程中,因为F对象的指针为蓝色,就将其直接改为红色。当对象A扫描引用B时,发现它的指针颜色为绿色,状态为M0,它就会到原来的小页面A的转发表取到对象B的新地址进行重定位,然后再从转发表删除B指针的记录,重定位和删除转发表同样也是原子操作。C对象和E对象的操作是一样,经过并发标记后,新的对象布局如下:

然后依次类推,下一次GC做标记时,在使用M0来标记存活对象。

为何要设计M0和M1:我们提到在标记阶段存在两个地址视图M0和M1,为什么设计成两个? 简单地说是为了区别前一次标记和当前标记。ZGC是按照页面进行部分内存垃圾回收的,也就是说当对象所在的页面需要回收时,页面里面的对象黑要被转移,如果页面不熏要转移,页面里面的对象也就不票要转移

  • M1: 本次垃圾回收中识别的活跃对象
  • M0:前一次垃圾回收的标记阶段被标记过的活跃对象,对象在转移阶段未被转移,但是在本次垃圾回收中被识别为不活跃对象,
  • Remapped:前一次垃圾回收的转移阶段发生转移的对象或者是被应用程序线程访问的对象,但是在本次垃圾回收中被识别为不活跃对象

把重定向放到下一次GC的并发标记过程的好处

重定向需要遍历所有存活对象,而下一次GC并发标记的时候也需要遍历所有存活,直接利用并发标记过程中遍历对象顺带做重定位减少一次扫描全部存活对象的开销,这样可以非常显著地提高垃圾收集性能。

6 ZGC的读屏障

上面介绍了并发标记阶段会进行对象的重定位以及删除对应的转发表,在ZGC中是通过读屏障来实现的。

读屏障也是是ZGC 的核心设计之一,读屏障是JVM向应用代码插入一小段代码的技术,当应用程序线程需要从堆中读取对象引用时,就会执行这段代码。

注:仅从堆中读取对象引用时才会触发这段代码

ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

7 NUMA-Aware

ZGC还有一个常在技术资料上被提及的优点是支持“NUMA-Aware”的内存分配。NUMA(Non-UniformMemoryAccess,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。

由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北桥芯片中的内存控制器也被集成到了处理器内核中,这样每个处理器核心所在的裸晶(DIE)[12]都有属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过Inter-Connect通道来完成,这要比访问处理器的本地内存慢得多。在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在ZGC之前的收集器就只有针对吞吐量设计的ParallelScavenge支持NUMA内存分配,如今ZGC也成为另外一个选择。

是不是不知道在说什么?

ZGC为了实现更高效的内存访问,在进行内存分配时实现了对NUMA的支持。我们先看一下什么是NUMA,然后再介绍一下ZGC是如何支持NUMA的。

7.1UMA

在过去,对于X86架构的计算机,内存控制器还没有整合进CPU,所有对内存的访问都需要通过北桥芯片来完成。X86系统中的所有内存都可以通过CPU进行同等访问。任何CPU访问任何内存的速度是一致的,不必考虑不同内存地址之间的差异,这称为“统一内存访问”(Uniform Memory Access,UMA)。UMA系统的架构示意图如图所示。

在UMA中,各处理器与内存单元通过互联总线进行连接,各个CPU之间没有主从关系。

UMA的特点:

  1. CPU数量增多后引起内存访问冲突加剧
  2. CPU的很多资源花在争抢内存地址上面
  3. 4颗CPU左右比较合适

UMA表示内存只有一块,所有 CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权) ,有竞争就会有锁,有锁效率就会受到响。而且CPU核心数越多,竞争就越激烈。

7.2 NUMA

之后的X86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为瓶颈,所以人们希望能够把CPU和内存集成在一个单元上(称为Socket),这就是非统一内存访问(Non-Uniform Memory Access,NUMA)。很明显,在NUMA下,CPU访问本地存储器的速度比访问非本地存储器快一些。下图所示是初期处理器架构示意图。

NUMA架构的设计目标是为了提高系统的可扩展性和性能,适用于多处理器、多核心的计算机系统。

NUMA架构通常由多个节点组成,每个节点包含处理器、内存和其他计算机资源。每个节点都可以独立地运行操作系统和应用程序,同时也可以通过高速互连网络互相通信

CPU0 访问Memory0的速度要快于CPU0访问Memory1的速度,因为CPU0访问Memory1需要通过总线。

7.3 NUMA-Aware

NUMA-Aware是指软件或硬件能够感知并充分利用NUMA架构,以便更好地利用系统资源,提高系统性能和可扩展性。在NUMA架构中,处理器只能访问其本地节点的内存,而访问远程节点的内存需要更长的时间。因此,NUMA-Aware的应用程序可以根据内存访问的本地性和远程性,将计算任务分配到本地节点和远程节点之间,从而减少内存访问延迟和网络开销,提高系统的整体性能。

随着系统的演化,可以把多个CPU集成在一个节点(node)上,例如在下图中,一个节点上集成了两个处理器,它们优先访问本地的内存。

ZGC是支持NUMA的,在进行小页面分配时会优先从本地内存分配,当不能分配时才会从远端的内存分配。对于中页面和大页面的分配,ZGC并没有要求从本地内存分配,而是直接交给操作系统,由操作系统找到一块能满足ZGC页面的空间。

ZGC这样设计的目的在于,对于小页面,存放的都是小对象,从本地内存分配速度很快,且不会造成内存使用的不平衡,而中页面和大页面因为需要的空间大,如果也优先从本地内存分配,极易造成内存使用不均衡,反而影响性能。

在进行页面释放时,并不是真正释放内存,而是把页面加入页面缓存中。页面缓存是按照页面类型组织的,所以有小页面缓存、中页面缓存和大页面缓存。对于小页面,会根据CPU的编号存储到相应的缓存队列中,供下一次内存分配,而中页面和大页面是直接加入缓存队列中,也不会涉及NUMA。

7.4 总结

UMA和NUMA是计算机系统中常用的两种体系结构。

  • UMA(Uniform Memory Access,统一内存访问)指所有的处理器都可以访问同一块共享内存,
  • NUMA(Non-Uniform Memory Access,非统一内存访问)则是一种分布式内存架构,其中处理器只能访问其本地节点的内存,并且访问远程节点的内存需要更长的时间。

NUMA-Aware是指软件或硬件能够感知并充分利用NUMA架构,以便更好地利用系统资源,提高系统性能和可扩展性。NUMA-Aware的应用程序可以根据内存访问的本地性和远程性,将计算任务分配到本地节点和远程节点之间,从而减少内存访问延迟和网络开销,提高系统的整体性能。

  • UMA表示内存只有一块,所有 CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权) ,有竞争就会有锁,有锁效率就会受到响。而且CPU核心数越多,竞争就越激烈。
  • NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那么效率自然就提高了。
  • NUMA-Aware 保存CPU优先读写本地内存