818虚拟内存

说明:本文大部分内容参考了 <深入理解计算机系统> 一书中关于虚拟内存的讲解,综合了自己的一些理解及其他资料,可以认为是一篇读书笔记。

基本概念

现代处理器为了更加有效的管理物理内存并减少出错,提出了 “虚拟内存” 的概念,英文是 Virual Memory (VM)。虚拟内存是对内存空间的一种抽象表示,为每个进程提供一个大的、一致的和私有的地址表示。它有以下特性:

  • 将物理内存看成一个存储在磁盘上的地址空间的高速缓存,该缓存只保存进程内的活动区域,并根据需要在磁盘和内存之间来回交换数据。
  • 每个进程有自己私有的独立的虚拟内存空间,对内存的操作都是通过虚拟内存间接进行的,保证了每个进程的内存不被其他进程破坏。
  • 虚拟内存空间和物理内存空间有特有的映射关系,访问内存时,会将虚拟地址翻译为物理地址。

虚拟内存的工作机制

物理寻址和虚拟寻址

计算机的内存被组织成一个由 M 个连续的字节大小的单元组成的数组,每个字节都有唯一的物理地址 (Physical Address, PA)。早期的 CPU 在寻址时采用直接访问物理地址的方式,称为物理寻址。现代CPU都普遍采用一种称为虚拟寻址的方式来寻址,如下图所示:

image-20181221175910154

使用虚拟寻址,CPU 通过虚拟地址 (Virtual Address, VA) 来访问内存,这个虚拟地址在被送到内存之前先转换成物理地址,这个转换过程是通过 CPU 芯片上的内存管理单元 (Memory Management Unit, MMU) 来完成的。

地址空间与虚拟内存状态

地址空间

不管是物理内存还是虚拟内存,都是有大小限制的,也因此对应了一段特定大小的地址空间。

现代系统通常支持 32 位 或 64 位 地址空间,也就是可以表示的最大地址为 1 << 32 (即4G) 或者 1 << 64 。 对于 64 位的情况,目前实际可支持的空间还没有这么大。例如 Intel Core i7 处理器,支持 48 位 (256T) 虚拟地址空间和 52 位 (4PB) 物理地址空间,还有一个兼容模式,支持 32 位 (4GB) 的虚拟和物理地址空间。

页面 Page

概念上,虚拟内存被组织为一个由存放在磁盘上的 N 个连续字节组成的数组。每个字节都有一个唯一的地址,作为数组的索引。但实际工作时,并不是直接通过这个索引来定位内存。VM 系统通过将虚拟内存分割为虚拟页 (Virtual Page, VP) ,这是一种大小固定的内存块,在 X86 上,这个页的大小通常为 4K 个字节;类似的,物理内存也被同样大小的物理页 (Physical Page, PP)。在定位一个具体的内存地址时,是通过页号加页内偏移量来进行的。如果是一个 4G 大小的内存,就可能被分为 4G / 4K = 1024 个页面。

虚拟页状态

任意时刻,虚拟内存的页面 (VP, Virtual Page) 都是下面三种状态中的一种

  • 还未被分配
  • 已分配,缓存在物理内存中
  • 已分配,未缓存,在磁盘中

虚拟内存在生成时,默认就是未分配的状态;当程序运行时第一次访问到该地址时,才会去实际分配内存。分配时,如果物理内存足够,则直接将地址内容缓存在物理内存中;而当物理内存不足时,则需要将物理内存中暂时不用的部分给交换出去,写在磁盘上。空出来的部分就可以被分配了。如果要访问的虚拟内存页面已经被分配,但是未缓存在物理内存上,则同样会触发内存和磁盘之间的交换,将暂时不同的内容换出到磁盘,而将要访问的内容从磁盘上换入到物理内存中。

下图是一个这三种状态的简单展示

image-20181221180023209

页表

同任何缓存系统一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在物理内存的某个地方。如果是,系统还必须确定这个虚拟页存放在物理内存的哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个暂时不用的页,并将虚拟页的内容从磁盘复制到物理内存中,替换掉该暂时不用的页。

这些功能是由软硬件联合提供的,包括系统内核、CPU芯片中MMU中的地址翻译硬件和一个存放在物理内存中叫做页表 (Page Table) 的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件需要将虚拟地址转换为物理地址时,都会读取页表。系统内核负责维护页表的内容,以及在磁盘和物理内存之间交换页。下图展示了一个页表的基本组织结构:

image-20181221180104513

页表是由一个个页表条目 (Page Table Entry, PTE) 组成的数组。虚拟地址空间中的每个页通过一定的换算规则,在页表中一个固定偏移量处都对应一个 PTE。可以假设 PTE 由一个有效位和一个 n 位的地址字段组成。有效位表明了该虚拟页当前是否被缓存在物理内存中。如果设置了有效位,那地址字段就表示物理内存中相应物理页的起始位置,这个物理页中缓存了该虚拟页。如果未设置有效位,那么一个空地址表示虚拟页还未分配。否则对于非空地址,地址字段就指向虚拟页在磁盘上的起始位置。

寻址情形

下面根据上文提到的页表,我们来分析一下寻址时涉及的几种情形。

页分配

虚拟内存页的分配过程,是在磁盘上创建空间并且更新页表中的对应 PTE 条目。下图中 VP5 的分配就是创建磁盘空间后将 PTE5 的地址指向该空间地址。注意,这里并不立即在物理内存里缓存该页,而是等分配好的页被实际访问时才会实际分配物理内存。

image-20181222195635912

页命中

当 CPU 需要访问一个虚拟地址时,地址翻译硬件会将根据虚拟地址计算出一个索引,根据索引定位到页表上的某个 PTE。页命中就是指该 PTE 的有效位为1,表示要访问的内容已经缓存在物理内存中了,因此直接命中。根据 PTE 中地址字段中保存的物理内存的位置,直接去物理内存中找到内容。

例如下图中,根据虚拟地址定位出 PTE 在 PTE2 的位置,其地址字段保存的内容指向了物理内存的 PP1,直接命中。

image-20181222200529760

缺页

缺页 (Page Fault) 简单说就是未命中。当 CPU 需要访问一个虚拟地址时,地址翻译硬件会将根据虚拟地址计算出一个索引,根据索引定位到页表上的某个 PTE。缺页就是指该 PTE 的有效位为0,表示未缓存在物理内存中,此时 PTE 中地址字段中保存的是虚拟页对应的磁盘位置。缺页时会触发一个缺页异常,缺页异常会调用内核中的缺页异常处理程序,该程序会在物理内存中选择一个暂时不用的页,准备将其交换出去。如果该页已经被修改多,则会被复制到磁盘。该选中的页会被待访问的磁盘页覆盖,从而实现了换入换出。

下图描述了缺页的大致处理流程图。页表由内核维护在内存中。

image-20181223135120553

下图的例子中,由于 PTE3 对应的是磁盘中 VP3 地址,而此时物理内存不足,我们选择将 PP3 中的 VP4 换出,而将 VP3 存入其中,并更新 PTE3,最后返回给 CPU。

image-20181222202459967

交换区

前面我们说了,一个进程的虚拟空间的页面,可能缓存在物理内存中,也可能被换出到磁盘中。这部分磁盘空间就称为交换分区。在 Linux 上安装系统时往往需要指定 swap 分区,就是交换区,Windows 上也有类似概念,不过不用手动指定。

TLB

每次 CPU 访问虚拟地址,MMU就必须去内存中的页表查询一个 PTE,以便将虚拟地址翻译为物理地址。查询一次内存的开销是几十到几百个 CPU 周期。为了消除这样的开销,在 MMU 中设计了一个关于 PTE 的小的缓存,称为翻译后备缓冲器 (Translation Lookaside Buffer, TLB)。TLB 中的每一行都保存着一个由单个 PTE 组成的块。

CPU寻址流程

CPU 寻址的大致的流程如下:

  • CPU 拿到虚拟地址后,将其传给 MMU
  • MMU 查询自己维护的 TLB 缓存,看能否直接找到对应的 PTE。如果找到,则直接获取到物理地址并将其发送到内存,内存将数据返回给 CPU 从而完成寻址流程
  • TLB 未命中时,则会去查询内存中页表,找到 PTE。(注意,在查询页表前,也可能会去查询内存的高速缓存)。一般都会使用具有层次结构的多级页表,以节省页表空间大小。
  • 根据 PTE 中的有效位字段,如果为 1,则页命中,根据 PTE 地址字段中的物理地址信息构造出物理地址,然后将其发送到内存,内存将数据返回给 CPU 完成寻址流程
  • 如果 PTE 中表明未缓存,即缺页,则触发缺页异常,磁盘页和物理内存页进行交换。完成后更新 PTE 中的内容,并继续访问内存,获取数据之后完成 CPU 寻址

image-20181223153246985

TLB 查询

下图是 TLB 查询的过程。

image-20181223150834055

上图可见,查询 TLB 是通过虚拟地址的高位部分:虚拟页 VPN 来进行的。查询时,VPN 被分解为 TLB 标记和 TBL 索引。

image-20181223151736508

地址翻译

CPU 寻址时,在 MMU 中的 TLB 未命中的情况下,需要根据页表找到物理地址,这个过程称为地址翻译。一个虚拟地址,分为虚拟页号 (VPN) 和 虚拟页偏移量 (VPO) 。类似的,物理地址也可以分为物理页号 (PPN) 和 物理页偏移量 (PPO)。由于虚拟页和物理页大小一般是一致的 (Linux 中是 4K),因此在做地址翻译时,可以保持偏移量不变,只翻译页号。VPN 作为页表索引找到页表中的 PTE,如果命中,PTE 中的地址字段就是物理页号 PPN。最终的物理地址就是由 PPN 和偏移量组合而成。

下面的图很形象的表示了这个过程:

image-20181222205519279

对于多级页表,VPN 实际是被拆分成多个,以对应多级页表项。情况如下:

image-20181223150037292

下面是 Intel Core i7 CPU 的寻址示意图:

259AC393-20C2-41C0-8A27-0E2910A3503E

多进程内存管理

虚拟内存的设计,给了内存管理极大的灵活性和便利性。由于每个进程都有自己独立的虚拟地址和页表映射,这保证了各进程之间内存的独立,也有利于多进程之间的内存共享和保护。

内存隔离与共享

每个进程都有自己独立的虚拟内存,和自己的页表(由系统内核代码维护),也就是每个进程都有自己的虚拟内存空间,这保证了各进程之间的寻址互不影响。由系统维护的寻址过程能杜绝各进程之间内存的非法访问。如果进程之间想共享内存,也是可以利用进程共享机制来实现(例如 Linux 的 mmap 系统调用)。下图中进程 i 和进程 j 就共享了同一物理页面 PP7,而虚拟页面不同:进程 i 的VP2 和进程 j 的 VP1

image-20181221162312505

独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不用管代码和数据实际存放在物理内存的何处。

  • 相同的内存排布。 对于 64 位地址空间的 Linux 进程,虚拟地址总是从 0x400000 开始,分成多个段来存放不同部分,且增长方向一样。这样的一致性,极大简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件独立于物理内存中代码和数据的最终位置。

  • 程序的加载。对于程序的加载,使用虚拟内存也很方便。可执行文件中的 .text 代码段和 .data 数据段,在被 load 进进程内存时,会被分配虚拟内存页,然后在页表中把他们标记为 已分配未缓存 状态,页表中的地址则指向可执行文件中的磁盘位置。只有当程序实际运行时,对应的虚拟地址被访问,才触发缺页,执行换入换出,将实际内容页面从磁盘加载到内存中并引用。

  • 物理内存共享。进程之间经常要共享代码和数据,这能显著节约内存使用。例如,进程需要调用相同的操作系统内核代码,调用相同的库函数 (例如 C 库函数 printf ),我们不用每个进程都维护一份副本,共享就可以了。
  • 简化内存分配。如果进程需要分配一段连续的内存,可能包含了 N 个虚拟页,由于虚拟内存的映射是各个页分别映射的,所以分配出来的物理内存就可以不是连续的,因此分配操作起来更灵活,这可以显著提高物理内存利用率和分配效率。

内存保护

利用页表中的控制选项,可以控制对特定内存的保护,杜绝非法访问。哪些访问属于非法访问呢?例如程序不能修改只读的代码指令,否则问题就大了;此外,用户进程也不能直接读或者修改内核段的代码和数据,用户空间对内核空间的操作唯一的入口应该是系统调用。

image-20181221162414432

Linux的虚拟内存

进程内存排布

基本所有使用虚拟内存的系统,会为每个进程维护了一个单独的虚拟地址空间。整个空间大致分为了内核地址空间和用户地址空间。

下图是 Linux 和 Windows 平台的空间划分示意:

image-20181226212413439

简单起见,我们主要介绍 Linux的虚拟内存。

内核虚拟内存包含内核中的代码和数据结构。内核虚拟内存中的某些区域被映射到所有共享进程的物理页面。内核虚拟内存的其他区域包含每个进程都不相同的数据。比如说,页表、内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。

用户地址空间被组织成一些区域 (也叫做段) 的集合。例如,代码段、数据段、堆、共享库段以及用户栈。

image-20181221163637036

下图记录了一个进程中虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的结构 (task_struct) 。

image-20181223160605710

一个段的结构就如下:

  • vm_start: 指向这个段的起始处

  • vm_end: 指向这个段的结束处

  • vm_prot: 描述这个段内包含的所有页的读写许可权限

  • vm_flags: 描述这个段内的页面是与其他进程共享的,还是这个进程私有的

  • vm_next: 指向链表中下一个段结构

Linux 缺页异常处理

前面我们讲过,页表中发生缺页时,会触发缺页异常,调用系统内核的异常调度程序来处理。那 Linux 的缺页是如何调度的呢?

1)首先,检查虚拟地址是否合法?是落入某个段的区域中吗?为了回答这个问题,缺页处理程序依次搜索段结构的链表,检查其 vm_start 和 vm_end。如果地址非法,则触发段错误,并终止程序。

2)其次,试图进行的内存访问是否合法?即进程是否有读、写或者执行这个段内页面的权限?例如,试图对代码段中的只读页面进行写操作?或者是用户进程的代码试图访问内核空间的代码或数据?如果是,则触发异常,并终止程序。

3)如果是正常缺页,则选择一个牺牲页,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。

image-20181223162633760

虚拟内存使用

内存映射

内核将虚拟内存和一个磁盘上的对象关联起来,以初始化这个虚拟内存的内容,这个过程称为内存映射 (memory mapping)。文件的内容直接映射到内存中,操作内存相当于直接直接操作文件。映射时文件被分成页大小的片,每一片包含一个虚拟页的初始内容。由于按需页面调度的策略,这些虚拟页面没有实际交换进入物理内存,直到CPU开始访问这些虚拟地址时才触发缺页,从而将页面内容从磁盘文件中换入物理内存。

被映射的文件可以是

  • 磁盘普通文件。程序可执行文件就是一个典型例子。程序开始执行时,系统会将可执行文件的 .text.data 部分直接映射到进程虚拟内存的 Text 段和 Data 段;程序链接的动态链接库,如常用的标准 C 库 libc.so 在加载时是映射到进程的虚拟内存内,由于动态链接库没有必要每个进程都维护一份私有的,所以会将其映射到共享区域内;

  • 匿名文件。匿名文件是由内核创建,全部设置为二进制零,然后映射到虚拟内存中。进程的 BSS 段、堆、栈段就是映射到匿名文件的;

下面是上述各种映射的示意图:

image-20181227171515705

除了以上的系统的自动映射,用户程序还可以调用系统 API 来进行手动的内存映射。例如,Linux 上的 mmap() 系统调用,Windows 上的API CreateFileMapping()/MapViewOfFile() 都可以操作映射文件。类似的,可以指定映射的是普通文件还是匿名文件。如果是普通文件,就相当于通过内存来读写文件,效率很高;如果是匿名文件,相当于申请一块全部清零的内存空间。C 语言的库函数 malloc 在申请内存的时候通常会在堆区分配,当申请的内存较大 (缺省的大于128K) 时,会转而使用 mmap 在内存映射区分配内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Linux
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);

//Windows
HANDLE CreateFileMappingA(
HANDLE hFile,
LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
DWORD flProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
LPCSTR lpName
);
HANDLE OpenFileMappingA(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCSTR lpName
);

内存映射根据文件可以分为匿名和非匿名的;而根据映射方式,又可以分为私有映射和共享映射。上面系统自动映射时,我们也说了,动态链接库是通过共享方式映射的,其他都是各进程的私有映射。Linux 上使用 mmap 时,可以设置 flags 参数为 MAP_SHAREDMAP_PRIVATE 来指定映射是私有的还是共享的;在 Windows 上 CreateFileMappingA 可以创建映射,而 OpenFileMappingA 则根据映射的名字来实现共享。

如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。相对的,对一个映射到私有对象的区域的任何写操作,对于其他进程来说是不可见的。一个映射到共享对象的虚拟内存区域叫做共享区域,类似地,也有私有区域。

下图显示了进程间共享内存映射一个文件的情况

image-20181228104507208

如果对于一个文件,有多个进程映射,且是私有的,在 Linux 上通常会使用一种称为写时复制 (Copy-On-Write, COW) 的技术来实现(这是一种未写时共享,有写时才分开的懒拷贝模式)。为了节约内存,私有对象开始的生命周期与共享对象基本上是一致的(在物理内存中只保存私有对象的一份副本),并使用写时复制的技术来应对多个进程的写冲突。如下图所示

image-20181229100438749

还有一个典型的例子就是fork()函数,该函数用于创建子进程。当fork()函数被当前进程调用时,内核会为新进程创建各种必要的数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它复制了当前进程的mm_structvm_area_struct和页表的原样副本。并将两个进程的每个页面都标为只读,两个进程中的每个区域都标记为私有区域(写时复制)。这样,父进程和子进程的虚拟内存空间完全一致,只有当这两个进程中的任一个进行写操作时,再使用写时复制来保证每个进程的虚拟地址空间私有的抽象概念。

此外,Linux 平台的 glibc 库在实现线程时,会使用 mmap (设置参数 flags 为 MAP_STACK) 匿名映射来创建线程栈,从而实现线程栈的独立。

内存分配类型

从程序角度看,我们的内存分配分为以下几种

  • 静态分配。是指程序中的全局变量和静态变量。这种内存大小往往在编译期就可以确定,大小是固定的。内存只分配一次,就是在程序开始时,在程序结束时由系统回收。
  • 自动变量分配。这是指由栈内存管理的变量,程序运行时自动分配和释放。
  • 动态内存分配。指程序运行时动态分配的内存,主要在堆上。一些程序如 C/C++ 中由程序员用 malloc 或者 new 显示的分配;在 Go 中,编译器通过逃逸分析,也可能在堆上维护内存变量。

下面几小结分别讲解这些内存管理的机制。

栈内存管理

程序开始执行,完成内存映射后,栈内存和堆内存就会使用各自独有的机制来实行管理了。我们先看栈内存的管理。

栈内存如下图 (Linux 平台下) 所示,处于用户空间的一端,向下增长

image-20181228150319032

栈空间主要用来存储程序运行过程中函数调用的信息。这些信息数据在分配和回收时,遵循 last-in-first-out(LIFO) 的后进先出方式。

当一个函数被调用时,它的状态数据就被添加到栈顶;而当函数退出时,这些状态数据就又从栈上被移除。需要存入栈空间的状态数据包括:函数参数、对象实例指针(如 C++ 中的 this 指针)、返回地址、函数内局部变量。每一次的函数调用,状态数据形成的结构叫做栈帧 (stack frame),多个栈帧通过 LIFO 的方式入栈出栈,就构成了栈空间管理的基本机制。

这个基于栈的内存分配和回收操作通常很快,往往只需要移动栈顶指针即可,效率很高。在大部分高级语言中,栈内存的分配和回收都是透明的、自动完成的,不需要程序员参与,很多处理器的指令集提供了栈操作的特殊指令。

在函数调用中,函数参数除了使用栈内存空间传输,有时候也会通过 CPU 寄存器存储。通常是在参数较少的时候。

例如,DrawSquare 调用了 DrawLine , 则其可能的栈结构 (该例子是栈顶在上,向上增长) 如下:

image-20181226111249595

注意栈顶可能在上,也可能在下,要看栈的增长方向 。从前面Linux进程段排布的图我们看到,Linux上栈空间是从高地址往低地址增长的,其他系统未必如此。但不管如何,当前正在执行的函数是位于栈顶的位置。

如果函数调用的层次很多,即使用的栈帧很多,栈空间就会很快被消耗。栈空间大小一般是在程序执行时候就分配好,当分配的栈空间被消耗完毕后,则经典的 stack overflow 错误将出现,往往会导致程序崩溃。常见的错误就是函数的无限递归或者分配了很大的局部变量,导致栈溢出。这里提一下在 Linux 中栈大小初始值由操作系统设定(ulimit),且有动态增长的机制,只要其大小不大于 RLIMIT_STACK,则空间不足时会自动增长,不会出现栈溢出的错误。但如果栈已经到达最大值 RLIMIT_STACK,则栈溢出就会出现。而 Windows的栈大小通常是固定的,没有动态增长的机制。其大小可以由编译器 (如 Visual Studio) 设置,并被记录在可执行文件中。

栈的结构,可以用来调试程序。如 GDB 调试的时候,就可以看到程序执行的完整栈结构,打印栈帧数据等;另外,对于很多程序性能 profile 工具,往往通过定时采样程序执行的栈帧结构,从而统计程序执行的各种信息,定位哪些函数是性能热点。

最后,

  • 程序中的每个 task 对应一个栈
  • 一个进程本身只有一个栈 (进程是一个task),称为进程栈
  • 对于在进程中开启的线程,其栈空间和进程栈是分开的,每个线程 (也是一个task) 在创建时,线程栈通过操作系统来分配
  • Golang 的每个协程拥有自己的栈

堆内存管理

堆内存,也称为动态内存。程序需要用到动态内存分配的原因,是经常直到程序实际执行时,才知道某些数据结构的大小,不能提前分配内存。堆区域紧接在未初始化的数据段后开始,并向特定方向增长 (下图还是 Linux 系统上的情况,不代表所有)。对于每个进程,内核维护着一个变量 break,它指向堆的顶部。

image-20181228151912039

那如何管理这部分的堆内存呢?很多语言维护了 动态内存分配器 来进行管理。动态分配器将堆视为一组不同大小的块 (block) 的集合来维护。每个块就是一个连续的虚拟内存片 (chunk),要么是已分配的,要么是空闲的。最开始的时候,没有空闲块,我们的内存申请是要向内核直接申请内存 (在 Linux 上是移动 brk 指针,Windows 上 调用 HeapAlloc )。释放的时候,没有必要立即将内存归还给系统,而是作为空闲块维护起来,下次分配的时候就可以优先利用被释放的空闲块了。如下图:

image-20181229102552027

维护这些分配块和空闲块的算法需要精心设计,以满足分配效率及内存利用率的最大化。在很多编程语言中,都提供了动态分配内存的方法,例如 C/C++ 中 malloc 库函数就是在堆中分配内存;Go 中 new 一个对象也可能由于逃逸分析,导致变量被分配在堆区中。

常用的分配器有两种风格。这两种风格都需要应用显示的分配内存块。它们不同的地方在于由谁来负责释放已分配的块。

  • 显式分配器,要求应用自身显式的释放任何已分配的块。例如,C 标准库提供 malloc 来分配内存块,并通过free 来显式释放该内存块。C++ 中的 new 和 delete 也是类似。C++ 类的析构函数本质上提供了内存释放的机会 (delete 操作符触发)。智能指针也是一种自动内存管理的机制。
  • 隐式分配器,要求分配器检测不被程序继续使用的内存块,并释放这些它们。这个过程常称为 垃圾收集(Garbage Collection, GC) ,高级语言如 Java、Go、JavaScript 等拥有自己的垃圾收集机制,程序员无需自己释放申请的内存。
显式分配器的机制

显式分配器的机制,典型的如如何实现 mallocfree 。我们考虑以下几点:

  • 使用堆内存

  • 分配时指定内存 size,返回内存地址

  • 显式释放时只需要指定内存地址,分配器本身需要记录该内存对应的大小
  • 分配的内存要做对齐,最大化访问效率

一种方式是使用块维护内存。各个内存块,不管是已分配还是空闲的,一起连接起来

image-20181228172930734

单个内存块的数据格式如下

image-20181228173029000

这里不详细展开显示分配的具体细节 (例如类似 malloc 的实现原理),有兴趣的可以查看 Implementing malloc and free如何实现一个malloc

垃圾收集的机制
  • 追踪。这是最常用的垃圾收集机制,主要思想是从根节点进行追踪,所有未被引用的对象就被认为是垃圾,可以进行收集。它主要研究的是引用对象的可达性。如下图的引用关系

    image-20181224115827314

    从 root set 追踪不可达,则会被垃圾收集器认为是垃圾,可以回收。那具体的,对象的可达性是怎么样呢?主要分两种:

    • 根元素。这包括程序的所有全局变量、当前调用栈对象 (如所有栈内局部变量、当前调用的函数参数)

    • 引用元素。所有根元素引用链上的对象

    追踪的主要机制是如何维护对象之间的引用关系。对于 Java、Go 这些有标记内存变量类型的语言来说比较简单:有变量类型信息从而很简单的知道是否包含其他对象;而对于 C/C++ 语言,不会用类型信息来标记内存位置。因此,像 int、float 这类变量和对象的指针变量没有可区分的手段,很难说一个结构体的某个内存到底是引用其他结构体的指针还是一个 int/float 变量。如果 C/C++ 要设计垃圾收集器,必须保守的将所有 像指针 的内存视为可达的,尽管事实上它可能是不可达的。C/C++ 垃圾收集库的一个实现是 Boehm GC

    比较知名的追踪算法有 标记-清扫算法、三色标记算法等。详细的这里不展开,有兴趣的可以查看 这里

  • 引用计数

    引用计数机制,简单说就是为每个变量维护一个计数,有引用时累加计数,删除引用时则减计数。如果计数归零,则可以立即回收。但是因为引用计数有一些缺点,如

    • 环形引用。如果有两个变量互相引用,但是和其他变量没有引用关系,则是环形引用,因为引用计数不为0,得不到回收
    • 空间和时间消耗。每个变量维护一个计数,空间消耗不小。另外,对计数的增减,对程序执行效率也有影响
    • 要保证原子性。如果涉及并发,计数的修改需要保证原子性

    这些缺点让引用计数机制逊于追踪机制,在垃圾收集实现方面没有得到广泛应用。

Bss 段、Data 段 与 Text 段

Bss 段和 Data 都是用来存储程序的全局变量和静态变量的。不同的是,Bss 段是匿名映射的全零区域,用来存储代码中没有初始化的全局变量和静态变量 (包括文件内静态变量,函数内静态局部变量,类静态成员变量),如 static int cntActiveUsers;Data 段是通过可执行文件的 .data 映射来的,里面存储的是已经初始化 (且非0) 的 全局变量或静态变量的值,例如 static int cntWorkerBees = 10 会将将静态变量的值 10 存储到 Data 段。需要注意,Data 段的映射是私有映射,运行时对变量的修改并不会反馈到可执行文件里,否则可执行文件都被修改了,那是不合理的。

Text 段就是代码段,是从可执行文件的 .text 部分映射过来。除了代码指令,还存储了常量字符串。这部分内存是可读可执行的,但是不能写,因此无论是修改代码指令,还是修改常量字符串,都会导致程序 crash。

static const char* gonzo = "God's own prototype"; 已初始化的静态变量 gonzo 是个指针,存储在 Data 段,其内容是一个地址 0x080484f0,指向字符串的地址,而字符串在 Text 段。

image-20181229105756277

参考资料

<深入理解计算机系统V3>

GarbageCollection WiKi)

CallStack WiKI

Anatomy of a Program in Memory

虚拟内存的那点事儿

Windows Virtual Address Space

Go Memory Management