带你读《新一代垃圾回收器ZGC设计与实现》之二:ZGC内存管理-阿里云开发者社区

开发者社区> 华章出版社> 正文
登录阅读全文

带你读《新一代垃圾回收器ZGC设计与实现》之二:ZGC内存管理

简介: JDK 11于2018年9月25日正式发布,这个版本引入了许多新的特性,其中最为引人注目的就是实现了一款新的垃圾回收器ZGC。

点击查看第一章
点击查看第三章

第2章

ZGC内存管理

对象的分配直接关系到内存的使用效率、垃圾回收的效率,不同的分配策略也会影响对象的分配速度,从而影响应用程序的运行。
ZGC为了支持太字节(TB)级内存,设计了基于页面(page)的分页管理(类似于G1的分区Region);为了能够快速地进行并发标记和并发移动,对内存空间重新进行了划分,这就是ZGC中新引入的Color Pointers;同时ZGC为了能更加高效地管理内存,设计了物理内存和虚拟内存两级内存管理。
为了能清晰地了解ZGC内存管理,在本章中,我们先介绍操作系统的虚拟内存和物理内存;随后介绍了ZGC的内存管理,主要包括多视图映射、NUMA支持和ZGC的两级内存管理;最后介绍了ZGC的对象分配,包括对象的快速分配和慢速分配、页面的分配。

2.1操作系统地址管理

物理内存非常直观,就是真实存在的,其大小就是插在主板内存槽上的内存条的容量大小。我们经常所说的一台计算机配置有1GB或者2GB内存,指的就是真实的物理内存的大小。
虚拟内存是伴随着操作系统和硬件的发展出现的。虚拟地址是操作系统根据CPU的寻址能力,支持访问的虚拟空间,比如前些年大家使用的32位操作系统,对应的虚拟地址空间为0~232,即0~4GB,而我们计算机的物理内存可能只有512MB,所以涉及物理内存和虚拟内存的映射。虚拟内存的发展解决了很多问题,也带来了很多好处。具体可以参考其他文献。
这里我们稍微介绍一下虚拟内存和物理内存的映射机制。上面提到虚拟内存和物理内存大小并不匹配,所以需要一个额外的机制把两者关联起来。当程序试图访问一个虚拟内存页面时,这个请求会通过操作系统来访问真正的内存。首先到页面表中查询该页是否已映射到物理页框中,并记录在页表中。如果已记录,则会通过内存管理单元(Memory Management Unit,MMU)把页码转换成页框码(frame),并加上虚拟地址提供的页内偏移量形成物理地址后去访问物理内存;如果未记录,则意味着该虚拟内存页面还没有被载入内存,这时MMU就会通知操作系统发生了一个页面访问错误(也称为缺页故障(page fault)),接下来系统会启动所谓的“请页”机制,即调用相应的系统操作函数,判断该虚拟地址是否为有效地址。如果是有效的地址,就从虚拟内存中将该地址指向的页面读入内存中的一个空闲页框中,并在页表中添加相对应的表项,最后处理器将从发生页面错误的地方重新开始运行;如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。当然,也存在这样的情况:在请页成功之后,内存中已经没有空闲物理页框了,这时,系统必须启动所谓的“交换”机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不再使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。对移出页面根据两种情况来处理:如果该页未被修改过,则删除它;如果该页曾经被修改过,则系统必须将该页写回辅存。

2.2ZGC内存管理

ZGC为了能高效、灵活地管理内存,实现了两级内存管理:虚拟内存和物理内存,并且实现了物理内存和虚拟内存的映射关系。这和操作系统中虚拟地址和物理地址设计思路基本一致。ZGC主要的改进点就是重新定义了虚拟内存和物理内存的映射关系。
在介绍内存管理之前,我们先从一个问题出发。我们知道ZGC目前仅支持64位Linux,最多管理4TB的内存。不知道你有没有注意到这个地方似乎有点问题,64位系统支持的内存远超过4TB,那么为什么我们一直强调它只能支持4TB的内存,为什么不使用更多的虚拟内存?
要回答这个问题,首先要了解ZGC的内存管理机制。ZGC对整个内存空间进行划分,这是来自于源码中关于地址空间的一个说明,如图2-1所示。
image.png

图2-1ZGC地址空间设计

在图2-1中,特别提到了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个视图。这几个区域有什么关系?我们先看图2-2:
image.png

图2-2ZGC中虚拟地址和物理地址映射关系

图2-2是ZGC在运行时虚拟地址和物理地址的转化。从图中我们可以得到:

  • 4TB是理论上最大的堆空间,其大小受限于JVM参数。
  • 0~4TB的虚拟地址是ZGC提供给应用程序使用的虚拟空间,它并不会映射到真正的物理地址。
  • 操作系统管理的虚拟内存为Marked0、Marked1和Remapped这3个空间,且它们对应同一物理空间。
    在ZGC中这3个空间在同一时间点有且仅有一个空间有效。为什么这么设计?这就是ZGC的高明之处,利用虚拟空间换时间;这3个空间的切换是由垃圾回收的不同阶段触发的,详见4.2.2节。
  • 应用程序可见并使用的虚拟地址为0~4TB,经ZGC转化,真正使用的虚拟地址为[4TB~8TB)、[8TB~12TB)和[16TB~20TB),操作系统管理的虚拟地址也是[4TB~8TB)、[8TB~12TB)和[16TB~20TB)。应用程序可见的虚拟地址[0~4TB)和物理内存直接的关联由ZGC来管理。

这里涉及的一个最关键的知识点就是多视图的映射,这是ZGC高效回收垃圾的基础,垃圾回收的相关内容将在第4章和第5章介绍。这里我们介绍一下ZGC是如何实现多视图映射的。
ZGC支持64位系统,我们看一下ZGC是如何使用64位地址的。ZGC中低42位(第0~41位)用于描述真正的虚拟地址(这就是图2-2中提到的应用程序可以使用的堆空间),接着的4位(第42~45位)用于描述元数据,其实就是大家所说的Color Pointers,还有1位(第46位)目前暂时没有使用,最高17位(第47~63位)固定为0,具体如图2-3所示。
image.png

图2-3ZGC地址

由于42位地址最大的寻址空间就是4TB,这就是ZGC一直宣称自己最大支持4TB内存的原因。这里还有视图的概念,Marked0、Marked1和Remapped就是3个视图,分别将第42、43、44位设置为1,就表示采用对应的视图。在ZGC中,这4位标记位的目的并不是用于地址寻址的,而是为了区分Marked0、Marked1和Remapped这3个视图。当然对于操作系统来说,这4位标记位代表了不同的虚拟地址空间,操作系统在寻址的时候会把标记位和虚拟地址结合使用。在介绍ZGC的多视图映射之前,我们先回顾一下如何实现多视图映射。
我们还可以从另外一个角度来考虑为什么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时,则需要重新设计这一部分。

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编译后运行文件,得到的结果如下:
image.png
这里使用的系统调用shm_open()函数,需要在编译时加上-lrt,否则可能会出现链接错误。示例中调用mmap两次返回两个地址变量,从结果我们可以发现,两个变量对应两个不同的虚拟地址,分别是0x7f56f2989000和0x7f56f2988000,但是因为它们都是通过mmap映射同一个内存共享对象,所以它们的物理地址是一样的,并且它们的值都是0xdeafbeef。
2. Windows系统
Windows系统也提供地址映射函数,使用系统函数CreateFileMapping()创建内存映射对象,再多次调用MapViewOf File()把一个内存映射对象映射到多个虚拟地址上,然后再操作虚拟地址。整体实现和Linux非常类似,这里提供一个示例程序(代码可以从GitHub下载),如下所示:

#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运行上述代码,可以得到如下结果:
image.png
这是与Linux中一样的结果。介绍完Linux和Windows平台如何实现运行的结果多视图映射,下面我们看一下ZGC是如何实现地址的多视图映射的。

2.2.2ZGC多视图映射

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

图2-4ZGC初始化多映射视图用到的文件描述符

这里有一个小知识点,创建内存文件描述符通过系统调用memfd_create函数完成,而memfd_create函数是内核态才能调用的函数,所以必须通过syscall函数从用户态进入内核态,并传递参数__NR_memfd_create,最终操作系统调用相应的函数完成。另外需要注意的是在初始化过程中,如果不能创建文件描述符,将导致初始化失败,JVM则不能启动。保存文件描述符的数据结构如图2-5所示。
我们可以看到在ZBackingFile中有一个成员变量_fd,该成员变量用于保存上面所提到的文件描述符。在ZBackingFile中还有一个成员函数try_expand,用于扩大共享内存对象的大小,成员函数create_mem_fd和create_file_fd分别是创建内存文件描述符或者基于文件系统的文件描述符,它们在图2-4中创建文件描述符时被调用。在ZPhysicalMemoryBacking中,成员变量_manager是分配内存管理器,其作用是分配或者回收内存,具体内存分配在2.2.5节介绍;成员函数map用于完成多视图映射。
image.png

图2-5ZPhysicalMemoryBacking类结构关键成员

在分配内存的时候,新分配的虚拟地址转化成3个映射视图(Marked0、Marked1和Remapped)中的虚拟地址,再使用mmap映射到这个文件描述符上。
映射方法和2.2.1节中采用的稍有不同,在2.2.1节的例子中,我们使用mmap时并未指定虚拟地址,而是由Linux自动分配一个虚拟地址。在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);
  }
}

这里简单解释一下上面代码中map_view的3个参数,第1个参数传递的是ZGC关于物理内存的一个结构体,实际上这里使用的是物理内存的大小;第2参数是虚拟地址,这里会把应用程序使用的[0~4TB)虚拟地址转化成Marked0、Marked1和Remapped视图对应的虚拟地址,其处理方法非常简单,把最低42位的地址分别与第42~44位进行“位或”运算,即得到不同视图里面的虚拟地址。
目前ZGC并不支持Windows平台,这会影响ZGC的普及。在2.2.1节我们提到Windows系统上也可实现多视图映射功能,而且Windows系统也有把多个指定的地址映射到一个物理地址上的API(MapViewOf FileEx),这里不再赘述,具体可以参考Windows系统编程。

2.2.3页面设计

为了细粒度地控制内存的分配,和G1一样,ZGC将内存划分成小的分区,在ZGC中称为页面(page)。ZGC支持3种页面,分别为小页面、中页面和大页面。其中小页面指的是2MB的页面空间,中页面指32MB的页面空间,大页面指受操作系统控制的大页。我们先回顾一下操作系统所支持的大页。
标准大页(huge page)是Linux Kernel 2.6引入的,目的是通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。它有两种大小:2MB和1GB。2MB页块大小适合用于吉字节级的内存,1GB页块大小适合用于太字节级别的内存;2MB是默认的大页尺寸。在ZGC中还支持透明大页(Transparent Huge Pages,THP),这是RHEL 6开始引入的一个功能,在Linux 6上THP是默认启用的。由于设置大页比较麻烦,很难手动管理,而且通常需要对代码进行重大的更改才能有效地使用,因此RHEL 6中开始引入了THP,它是一个抽象层,能够自动创建、管理和使用传统大页。关于如何使用大页可以参考官网,这里不再介绍。
ZGC中的页面和操作系统中的页面并没有直接的关系。ZGC中的页面是ZGC为了管理内存进行的抽象,操作系统中的页面是管理物理内存的单位。由于物理内存页面的大小受操作系统的管理,通常来说物理内存页面大小比较固定,例如为4KB、2MB等(依赖于操作系统以及操作系统是否开启大页),所以操作系统中的页面一般比ZGC的页面小,换句话说,一个ZGC的页面可能由几个不连续的操作系统页面组成。
在ZGC中,不同对象的大小会使用不同的页面类型。表2-1列出了ZGC页面大小、对象大小和对象对齐数据。
表2-1中,MinObjectAlignmentInBytes的默认值是8,它由参数ObjectAlignmentIn- Bytes控制,大小在8~256之间,且为2的幂次。对象对齐的粒度影响的是对象的分配和访问速度以及内存空间的浪费,通常来说,粒度越大,处理器访问内存的速度越快,但可能导致过多的浪费。在实际中可以根据应用系统对象的平均大小来合理地设置该值。

表2-1页面类型信息

image.png
另外,在进行垃圾回收时,ZGC对于不同页面回收的策略也不同。简单地说,小页面优先回收;中页面和大页面则尽量不回收。

2.2.4对NUMA的支持

ZGC为了实现更高效的内存访问,在进行内存分配时实现了对NUMA的支持。我们先看一下什么是NUMA,然后再介绍一下ZGC是如何支持NUMA的。
在过去,对于X86架构的计算机,内存控制器还没有整合进CPU,所有对内存的访问都需要通过北桥芯片来完成。X86系统中的所有内存都可以通过CPU进行同等访问。任何CPU访问任何内存的速度是一致的,不必考虑不同内存地址之间的差异,这称为“统一内存访问”(Uniform Memory Access,UMA)。UMA系统的架构示意图如图2-6所示。
在UMA中,各处理器与内存单元通过互联总线进行连接,各个CPU之间没有主从关系。
之后的X86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为瓶颈,所以人们希望能够把CPU和内存集成在一个单元上(称为Socket),这就是非统一内存访问(Non-Uniform Memory Access,NUMA)。很明显,在NUMA下,CPU访问本地存储器的速度比访问非本地存储器快一些。图2-7所示是初期处理器架构示意图。

image.png

图2-6UMA系统架构示意图

image.png

图2-7NUMA结构示意图

随着系统的演化,可以把多个CPU集成在一个节点(node)上,例如在图2-8中,一个节点上集成了两个处理器,它们优先访问本地的内存。
在Linux中可以通过命令numactl来查看NUMA的状态以及每个节点上内存、处理器等信息,例如可以通过numactl --hardware来查看节点信息,如下所示:

[root@b-iaas-pdos-svc-001 example]# numactl --hardware
available: 1 nodes (0)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 16383 MB
node 0 free: 13270 MB
node distances:
node   0 
  0:  10 

image.png

图2-8NUMA演化结构示意图

在本例中,系统并不支持NUMA,所以只有一个节点。关于NUMA更多的介绍可以参考其他文献,这里不再赘述。
ZGC是支持NUMA的,在进行小页面分配时会优先从本地内存分配,当不能分配时才会从远端的内存分配。对于中页面和大页面的分配,ZGC并没有要求从本地内存分配,而是直接交给操作系统,由操作系统找到一块能满足ZGC页面的空间。
ZGC这样设计的目的在于,对于小页面,存放的都是小对象,从本地内存分配速度很快,且不会造成内存使用的不平衡,而中页面和大页面因为需要的空间大,如果也优先从本地内存分配,极易造成内存使用不均衡,反而影响性能。
在进行页面释放时,并不是真正释放内存,而是把页面加入页面缓存中。页面缓存是按照页面类型组织的,所以有小页面缓存、中页面缓存和大页面缓存。对于小页面,会根据CPU的编号存储到相应的缓存队列中,供下一次内存分配,而中页面和大页面是直接加入缓存队列中,也不会涉及NUMA。页面释放发生在垃圾回收的并发转移中,在5.1.6节还会介绍。

2.2.5ZGC中的物理内存管理

前面提到ZGC实现了两级内存管理,其中虚拟内存类似于操作系统的虚拟内存,不过ZGC对虚拟内存做了额外的处理。在ZGC中应用程序可见的虚拟地址为0~4TB,ZGC在多视图映射时会把这个虚拟地址转化为3个视图——Marked0、Marked1和Remapped中的虚拟地址,把这3个虚拟地址关联到一个物理地址上。
但是这存在一个问题,就是应用程序看到的是0~4TB的虚拟地址,操作系统看到的是Marked0、Marked1和Remapped的虚拟地址和物理地址。我们并不知道应用程序使用真正物理地址的情况,所以ZGC提供了物理内存管理。
ZGC的物理地址并不是操作系统中的物理地址,从概念上它和虚拟地址更加类似,是为了管理应用程序物理地址的使用,如何理解这句话呢?ZGC中内存空间的分配是以页面为粒度(实际上最小粒度是2MB)的。ZGC为了减少对操作系统物理内存频繁的请求/释放,设计了自己的物理内存管理系统,ZGC中的物理内存实际上仅仅记录了操作系统物理内存的使用情况。
ZGC中物理内存管理的基本单位是段(segment),它包含start和end。在内存分配阶段每一个段都是2MB,也就是说ZGC在向操作系统请求物理内存的时候最小的粒度是2MB,对于超过2MB的内存空间会被划分成多个段。
由于ZGC的物理内存仅仅记录的是应用程序物理内存的使用情况,所以它完全可以设计成一个连续的地址结构,当向操作系统申请新的空间时,在物理空间后面追加空间的大小,当释放空间时,在物理空间的后面减去空间的大小。ZGC中物理内存的数据结构ZPhysicalMemory如图2-9所示。
image.png

图2-9ZGC物理内存结构

我们再举一个例子来看一下ZGC物理内存。假如应用程序申请一个超大的对象需要10MB的空间,则将其转化成5个段,每个段为2MB。在ZGC和操作系统交互时,将分成5次向操作系统申请内存,所以可以把一个大对象分配在操作系统层面不连续的物理空间中。但是在ZGC的物理内存管理中,分配成功后会把这些段进行合并,因为这5个段是连续的地址。
前面提到ZGC管理对象是以页面为单位,这里需要一个大页面,这个页面包含了5个段共同组成这个对象。由此可以看出段是ZGC向操作系统请求内存的基本单位,而页面是对象内存管理的单位。
ZGC中设计了物理内存管理器,称为ZPhysicalMemoryManager,它主要管理物理内存使用的情况,其结构如图2-10所示。

image.png


图2-10 ZPhysicalMemoryManager类结构

在图2-12中,仅仅列出了几个关键的成员变量和成员函数。其中成员变量_backing是在2.2.2节中提到的数据结构,_max_capacity是ZGC设置的最大堆空间,capacity是当时可以使用的容量,used是已经使用的空间,_current_max_capacity的目的是在内存扩张时,如果扩张失败,记录失败时的容量,避免后续内存扩张失败。最重要的成员函数是alloc,用于分配物理内存,这里的分配是指分配ZGC管理的物理内存,就是我们上面提到的。另外还有一个成员函数map,它才是真正把3个视图的虚拟内存映射到操作系统物理内存的函数。

2.2.6ZGC中的虚拟内存管理

在本节开头已经介绍了ZGC中几块虚拟地址的作用,这里看一下它在ZGC中的实现,以及在ZGC中如何分配和回收虚拟内存。ZGC中虚拟内存的管理也非常简单,我们先看一下虚拟内存相关的数据结构,如图2-11所示。
image.png

图2-11ZGC虚拟内存相关的数据结构

ZGC的虚拟管理器ZVirtualMemoryManager中最主要的成员函数是alloc和free,其中alloc根据应用程序请求的大小来分配空间,这个空间用ZVirtualMemory来保存,在alloc中对于小页面ZGC会从虚拟空间的头部开始分配,对于中页面和大页面ZGC从虚拟空间的尾部开始分配。
这样设计的目的是方便对象的管理,小页面分配和释放的成本都不高,但是分配和释放中页面和大页面成本很高。
物理内存和虚拟内存都使用了ZMemoryManager进行真正的内存管理。它的成员函数主要有alloc_from_front(从头部分配)和alloc_from_back(从尾部分配)。再次强调一点,ZGC中物理内存的分配和虚拟内存的分配都是ZGC管理的内存分配,并不是分配操作系统的物理内存,只有当调用ZGC物理内存管理器中的map方法时,才会真正向操作系统请求内存。

2.2.7ZGC内存预分配

在ZGC启动时会预分配一部分内存,在初始化成功后这部分内存可以直接使用,加快程序执行的效率。如果在JVM启动时设置了xms,将使用xms指定的最小堆空间作为预分配空间,如果没有设置,则JVM会根据参数来推断预分配空间的大小。参数InitialHeapSize直接设置预分配内存的大小,该参数的默认值为0。如果用户没有设置该参数,JVM将根据另外一个参数InitialRAMPercentage推断出预分配内存的大小。InitialRAMPercentage的默认值是1.5625,它表示的是将物理内存的1/1.5625(64%)的大小作为预分配的空间。
ZGC的预分配处理会根据预分配空间的大小完成:物理内存的分配、虚拟内存的分配和操作系统物理内存的映射。预分配定义的数据结构在ZPreMappedMemory中,如图2-12所示。
image.png

图2-12预分配数据结构

在这个类中,最关键的是构造函数。我们看一下这个构造函数的源码,如下所示:

ZPreMappedMemory::ZPreMappedMemory(ZVirtualMemoryManager &vmm, 
ZPhysicalMemoryManager &pmm, size_t size) :
    _vmem(),
    _pmem(),
    _initialized(false) {
  //如果虚拟内存管理器和物理内存管理器不能正确地初始化,直接返回
  if (!vmm.is_initialized() || !pmm.is_initialized())   return;

  if (size > 0) {
    //分配物理内存
    _pmem = pmm.alloc(size);
    //如果不能分配物理内存,则直接返回
    if (_pmem.is_null())  return;

    //分配虚拟内存,注意预分配的空间都认为是小页面
    _vmem = vmm.alloc(size, true /* alloc_from_front */);
    //不能分配虚拟地址,释放资源并返回
    if (_vmem.is_null()) {
      pmm.free(_pmem);
      return;
    }

    //把ZGC的物理内存真正映射到操作系统的物理内存
    pmm.map(_pmem, _vmem.start());
  }

  _initialized = true;
}

在内存预分配成功以后,ZGC启动完成,当应用程序请求内存时,优先从预分配的空间中分配。

2.3ZGC对象分配管理

上面介绍了ZGC内存管理的基本知识,本节详细介绍ZGC对象分配管理。我们知道JVM已经发展了很多年,系统架构的设计非常完善。JVM中定义了垃圾回收器的基类,任何垃圾回收器只要继承该类,并实现其中的虚函数,就可以实现自己的垃圾回收器。
ZGC的实现类是ZCollectedHeap,它重载了一些关键的函数,主要有对象分配相关的函数、垃圾回收相关的函数以及其他相关的辅助函数。真正实现内存分配、回收、对象标记、转移动作是在ZHeap中。我们先看一下这两个类的结构,如图2-13所示。
首先了解一下ZCollectedHeap的几个重要成员函数,如表2-2所示。
在ZGC中对象分配的处理和其他垃圾回收器有所不同,对象的快速分配和慢速分配实际上调用同样的代码。那么ZGC中不需要快速分配了吗?还是说把慢速分配变成与快速分配相同了?ZGC中实现了另外一套对象管理方法,在这套方法中还是有快速分配和慢速分配的,都依赖于ZHeap的实现。
我们再看一下ZCollectedHeap的成员变量,如表2-3所示。
ZCollectedHeap中,_director、_driver、_stat这3个垃圾回收控制线程将在第3章详细介绍。ZCollectedHeap中最主要的成员变量_heap是ZGC的堆对象。对象空间分配、对象标记、转移等都可用这个类实现。实际上ZCollectedHeap中的慢速分配和快速分配都是调用ZHeap对象中的方法,其中allocate_new_tlab会调用ZHeap中的alloc_tlab方法,mem_allocate会调用ZHeap中的alloc_object方法。
image.png

图2-13ZCollectedHeap和ZHeap数据结构

表2-2ZCollectedHeap的成员函数

image.png

表2-3ZCollectedHeap的成员变量

image.png
我们已经知道ZCollectedHeap在对象分配的过程中完成了对象空间的分配,实际上整个对象分配过程中除了对象空间的分配,还有对象的初始化。我们来看一下对象分配的整体流程图,如图2-14所示。
image.png

图2-14对象分配流程图

一般Java对象分配的入口点在InstanceKlass::allocate_instance中,这里不再分析这一过程,给出对象分配流程图对应的调用栈,感兴趣的读者可以根据第9章调试的方法进一步研究整个对象分配的过程。调用栈(有删减,只保留了主要的函数)如下所示:

...
->instanceOop InstanceKlass::allocate_instance(TRAPS)
->oop CollectedHeap::obj_allocate(Klass* klass, int size, TRAPS) 
->oop MemAllocator::allocate() const 
->HeapWord* MemAllocator::mem_allocate(Allocation& allocation) const
->HeapWord* ZCollectedHeap::allocate_new_tlab(size_t min_size, size_t 
requested_size, size_t* actual_size)
-> HeapWord* ZCollectedHeap::mem_allocate(size_t size, 
bool* gc_overhead_limit_was_exceeded)
...

要理解对象空间的分配,需要深入理解ZHeap这个类。ZHeap中主要的成员变量如表2-4所示。

表2-4ZHeap成员变量

image.png

ZHeap中和对象分配相关的成员变量是_object_allocator和_page_allocator,它们分别用于分配对象空间和页面。一个页面可存放多个对象,页面的分配是需要向操作系统请求的,所以花费时间比较多,这也是ZGC中设计页面的原因。对象空间分配是从页面中请求的,如果页面有足够的空间,对象空间分配能快速地完成,页面相当于缓存。下面我们分别看一下对象空间分配和页面分配。

2.3.1对象空间分配

对象空间分配是由对象分配管理器负责的,对象分配管理器的主要成员变量和成员函数,如图2-15所示。
我们先看一下对象分配管理器中的成员变量。
_used是一个模板类的变量,类型为ZPerCPU,它实质上是一个数组,元素类型为size_t,其内存示意图如图2-16所示。

image.png 

 

图2-15ZObjectAllocator类图

image.png

 

图2-16ZPerCPU内存示意图

  
              
在ZGC中支持多CPU同时进行对象分配,为了能准确地记录每个CPU已经分配的字节数,所以给每个CPU设置一个计数器,这其实也是为了减少竞争。
_shared_medium_page也是一个模板类的变量,类型为ZContended,它存储的是页面指针。可以直接把它简化成一个页面的缓存,这里为什么要使用模板类?最主要的功能是为了实现对象的对齐。我们知道在现代计算机系统中硬件都支持缓存,如果按照缓存行大小对齐,则CPU在操作对象时速度更快,在ZGC中CPU缓存一般按照64位进行对齐。
_shared_small_page是一个模板类的变量,类型为ZPerCPU,它实质上是一个数组,每个数组中的元素类型为页面指针。这个变量用于应用程序请求分配对象空间时使用。这里要注意的是所有的应用程序线程都从这个缓存中分配对象,但是为了加速对象的分配,按照CPU进行缓存。
_worker_small_page是一个模板类的变量,类型为ZPerWorker,它实质上是一个数组,每个数组中的元素类型为页面指针。这个变量用于并行工作线程分配对象空间时使用。ZPerWorker这个模板类实现每个工作线程对应一个页面缓存,这也是为了最大限度地减少并发/并行(依赖于Worker的工作状态)时的竞争。
ZObjectAllocator最主要的成员函数是alloc_object。我们在介绍ZCollectedHeap时提到allocate_new_tlab会调用ZHeap中的alloc_tlab方法,mem_allocate会调用ZHeap中的alloc_object方法,实际上alloc_tlab和alloc_object都调用对象分配器中的alloc_object方法,二者最大的不同在于,alloc_tlab在分配空间之前,要求请求对象的大小不能超过TLAB所支持的最大值。对象空间器中alloc_object方法的流程图如图2-17所示。
image.png

图2-17对象空间分配流程图

这里再说明一下:
分配小对象时,会判断对象的请求来自哪里。如果来自于应用线程,所有的应用线程根据所在的CPU从共享的页面中分配对象空间。如果请求来自工作线程,则每个工作线程都有一个缓存的页面,优先从缓存的页面分配,不成功则分配新的页面。这样设计的主要目的在于使工作线程分配对象只发生在对象的并发转移中,多个缓存能加快对象的转移。
分配中等对象的时候,所有的中等对象都共享一个中等页面,也就是说该函数会被并发访问(可能是工作线程和应用程序线程并发执行,也有可能是多个应用程序线程之间并发执行),所以涉及竞争,需要额外的处理。ZGC中是先分配页面空间,再尝试设置新页面为共享页面,在设置过程中需要原子操作(通常在一个循环中处理),如果不能成功设置,说明有多个线程并发执行,且有其他的线程已经成功申请到新的页面,此时要释放多申请的页面。从这里可以看出,如果应用程序中含有大量中等对象,ZGC在空间分配时很容易发生页面申请竞争,导致性能下降。
对于大对象来说,ZGC不会在一个大页面中共享多个大对象,也就是说每个大对象都独占一个大页面,当然大页面的大小可能不相同(主要取决于对象的大小)。
还有一点,在上述的流程图中并没有体现,在ZGC中有一个参数ZStallOnOut- OfMemory,用于控制当发生OOM时终止程序还是等待垃圾回收器回收空间后继续运行。该参数在页面分配时使用,2.3.2节会介绍。
还要注意工作线程和应用程序线程竞争的情况,在工作线程转移对象的同时应用程序线程也转移对象(这和垃圾回收的设计相关,应用程序线程发现访问一个需要转移的对象,会先转移对象后访问)时,这种情况会出现,导致了竞争,会发生重复申请页面的释放,在第6章我们也能看到这一现象。

2.3.2页面分配

在对象空间分配的时候,我们看到页面实际上作为缓存使用,如果页面还有空间,则能快速分配对象,如果页面空间不足,则需要申请新的页面。页面分配主要通过页面管理器实现。页面管理器的类结构如图2-18所示。

image.png


图2-18页面管理器ZPageAllocator类图

类图中列出了页面管理器的主要成员变量,其作用如表2-5所示。
页面管理器中最主要的成员函数是alloc_page和free_page,free_page是与alloc_page相反的操作。map_page主要是把新分配的内存映射到操作系统的物理内存上。detach_page是在页面分配过程中发现内存不足时,把可回收的页面进行回收。
我们主要看一下页面分配alloc_page。页面分配可以总结为两步:申请新的页面;调用map_page,完成内存的多视图映射。我们来看一下页面申请的流程,如图2-19所示。

image.png


图2-19页面分配流程图

表2-5页面管理器主要的成员变量

image.png
非阻塞分配时优先从页面缓存分配,不成功则从预分配的内存中分配,还不成功,则将释放预分配的未使用的内存和部分页面缓存的内存用于创建新的页面,其主要的流程图如图2-20所示。
在这里还有一点需要注意,在申请页面的时候需要加锁,这是因为只有一个heap。
在进行页面分配时,还有可能进入阻塞内存分配。这通过参数ZStallOnOutOfMemory控制,当设置参数ZStallOnOutOfMemory为true时进行阻塞分配,为false时进行非阻塞分配,默认值为true,即阻塞分配。两者的区别在于阻塞分配中,如果不能成功分配内存,则启动垃圾回收,并等待垃圾回收完成后再次进行内存分配直到成功,而非阻塞分配则是在内存不足、无法分配内存时直接抛出OutOfMemoryError(OOM异常)。
在进行页面分配时,还涉及几个小的细节,分别是页面缓存分配、NUMA的使用和页面分配时阻塞机制的实现,下面一一介绍。
1. 页面缓存分配
ZGC为了方便地管理页面缓存,设计了页面缓存的数据结构,如图2-21所示。
其中的中页面缓存和大页面缓存都是一个列表,比较简单,小页面缓存ZPerNUMA是一个模板类。在模板类中,NUMA中的节点分配一个页面缓存列表。最终的方案类似于数组,如图2-22所示。
image.png

图2-20一般页面分配的流程

image.png

 
 

图2-21ZPageCache数据结构

   
  
 

image.png


图2-22小页面缓存内存布局图

          
ZPageCache中最主要的成员函数就是alloc_page和free_page。
alloc_page是根据页面的类型从对应的缓存列表中分配页面。对于中页面和大页面则非常简单,直接从缓存中分配空间。对于小页面,涉及NUMA,简单地说就是优先从本地内存分配页面空间,如果不成功,则从其他节点对应的内存(也称为远端内存)分配空间。在ZGC中为了统计这两种不同的缓存命中情况,将本地内存分配称为一级缓存(L1),将远端内存分配称为二级缓存(L2)。在日志中我们可以看到Page Cache Hit L1和Page Cache Hit L2,分别指的就是这两种情况。不过要注意的是Page Cache Hit L1不仅包含了小页面的成功分配次数,也包含了中页面和大页面成功分配的次数。
free_page就是把可以再利用的页面加入缓存中,也非常简单,根据页面类型加入相应的缓存中。小页面缓存有点例外,小页面根据操作系统NUAM的分配方法找到正在使用的节点,加入节点缓存中。
在进行新页面分配时,我们提到NUMA的分配,这里也涉及NUMA的分配,我们对ZGC中NUMA的内存分配做一个总结。
2. NUMA的使用
NUMA的内存分配策略有localalloc、preferred、membind、interleave这4种。
  • localalloc:进程从当前节点上请求分配内存。
  • preferred:比较宽松地指定了一个推荐的节点来获取内存,如果被推荐的节点上没有足够内存,进程可以尝试别的节点分配。
  • membind:指定一个节点列表(包含了若干个节点),进程只能从节点列表中的节点上请求分配内存。
  • interleave:进程从指定的若干个节点上以轮询(Round Robin,RR)的方式循环地从节点上请求分配内存。

ZGC使用了2种NUMA内存分配策略:
1)新页面分配时采用的是interleave。
2)当页面回收的时候,将按照操作系统的分配策略(不一定是interleave,可能是上述4种策略的任意一种)把地址对应的空间放入页面缓存中。
为什么这样设计?在新页面分配的时候如果不是采用RR的方式,如采用绑定处理器的方法,可能会导致本地内存已经耗尽,而远端内存还有可用空间的情况,此时最容易发生的就是交换内存(swap)暴增,性能下降。所以在进行新页面分配时,采用interleave将是最好的选择。
在进行页面回收时,可根据操作系统的分配策略找到访问这一块内存的效率最高的CPU,然后把这一空间放入缓存中,在下一次分配时则可以加速分配。
对于中页面和大页面缓存,并不支持NUMA,最主要的原因是中页面和大页面所占用的空间都比较大,如果把这些空间放入处理器关联的本地内存,很容易导致其节点上内存不足。
3. 页面分配时阻塞机制的实现
ZGC使用类ZPageAllocRequest来管理所有的页面阻塞分配的请求。下面是这个类的类图,包含主要的成员变量和成员函数,如图2-23所示。

image.png


图2-23页面阻塞请求分配的类图

该类中的成员变量_node用于形成一个请求的列表,_result的类型ZFuture,保存的是页面的指针。成员函数satisfy和wait是设置当前请求成功分配到的页面和获取页面。
在阻塞分配请求中,进行新页面分配时,如果页面分配失败,则产生一个新的阻塞请求,并把请求加入请求列表的末尾处,然后开始启动垃圾回收。在垃圾回收的最后会释放空闲的页面,此时说明有可用的页面了,会检查是否有分配阻塞请求,如果有请求,则分配页面,并从请求列表中取第一个请求,然后把分配成功的页面放入请求的_result中。
这是一个典型的等待/通知模型。等待的位置在新页面分配失败的地方,通知的位置在页面释放的地方。
4. ZGC页面介绍
本章介绍的重点之一就是对象空间的分配,对象空间建立在页面的基础上。针对页面的设计,人们在ZGC中做了大量工作,包括设计不同的粒度、支持NUMA、设计并发分配的数据结构等。所以在本节的最后,我们看一下页面的结构,页面的类图如图2-24所示。

image.png


图2-24页面类图

页面中的主要成员变量介绍如表2-6所示。
页面中的主要成员函数有alloc_object、mark_object、recloate_object和forward_object。它们的主要功能如表2-7所示。
关于标记、转移和重定位将在第5章中详细介绍。
页面贯穿整个ZGC的活动周期,在分配、释放、标记、转移等过程中都有涉及,所以对于页面也设计了不同的状态用于不同的阶段。页面的状态总结如表2-8所示。

表2-6页面成员变量介绍

image.png

表2-7页面主要成员函数介绍

image.png

表2-8页面状态

image.png

这里仅仅总结了页面的状态,从表2-8我们可以看出页面的状态主要和垃圾回收过程紧密相关,在进行垃圾回收时可以再回顾这些状态。
另外需要注意的是,页面中的状态是通过几个成员变量共同定义的,涉及的成员变量有_pinned、refcount、_seqnum、forwarding和_physical等。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接