06.进程的虚拟内存管理.md

牧云@^-^@ 提交于 2020-03-01 22:15:45

正好遇到 华庭(庄命强)的glibc内存管理Ptmalloc2 源代码分析 一文,非常开心。真是大佬。我只是借着这篇文章稍微整理一下,为了以后自己回顾的时候能够更好的排查问题。

6.1 linux进程内存布局

x86 平台 Linux 进程内存布局
  Linux 系统在装载 elf 格式的程序文件时,会调用 loader 把可执行文件中的各个段依次
载入到从某一地址开始的空间中(载入地址取决 link editor(ld)和机器地址位数,在 32 位机
器上是 0x8048000,即 128M 处)。如下图所示,以 32 位机器为例,首先被载入的是.text 段,
然后是.data 段,最后是.bss 段。这可以看作是程序的开始空间。程序所能访问的最后的地
址是 0xbfffffff,也就是到 3G 地址处,3G 以上的 1G 空间是内核使用的,应用程序不可以直
接访问。
 &emsp应用程序的堆栈从最高地址处开始向下生长,.bss 段与堆栈之间的空间是空闲的,
空闲空间被分成两部分,一部分为 heap,一部分为 mmap 映射区域,mmap 映射区域一般
从 TASK_SIZE/3 的地方开始,但在不同的 Linux 内核和机器上,mmap 区域的开始位置一般是
不同的。Heap 和 mmap 区域都可以供用户自由使用,但是它在刚开始的时候并没有映射到
内存空间内,是不可访问的。在向内核请求分配该空间之前,对这个空间的访问会导致
segmentation fault。用户程序可以直接使用系统调用来管理 heap 和 mmap 映射区域,但更
多的时候程序都是使用 C 语言提供的 malloc()和 free()函数来动态的分配和释放内存。Stack
区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。

6.1.1 32 位模式下进程默认内存布局

  从下图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap 映射区
域至顶向下扩展,mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这
种结构便于 C 运行时库使用 mmap 映射区域和堆进行内存分配。上图的布局形式是在内核
2.6.7 以后才引入的,这是 32 位模式下进程的默认内存布局形式。

32位图片

6.1.2 64位进程虚拟地址空间

  在 64 位模式下各个区域的起始位置是什么呢?对于 AMD64 系统,内存布局采用经典
内存布局,text 的起始地址为 0x0000000000400000,堆紧接着 BSS 段向上增长,mmap 映射
区域开始位置一般设为 TASK_SIZE/3。

#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)
#define TASK_SIZE (test_thread_flag(TIF_IA32) ? IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define STACK_TOP TASK_SIZE
#define TASK_UNMAPPED_BASE (PAGE_ALIGN(TASK_SIZE / 3))

计算一下可知,mmap 的开始区域地址为 0x00002AAAAAAAA000,栈顶地址为
0x00007FFFFFFFF000
在这里插入图片描述

  相对于32位可以发现,64位的机器mmap是像上生长的,因为地址空间比较大,所以不用担心会和stack冲突。上图是 X86_64 下 Linux 进程的默认内存布局形式,这只是一个示意图,当前内核默认配置下,进程的栈和 mmap 映射区域并不是从一个固定地址开始,并且每次启动时的值都不一样,这是程序在启动时随机改变这些值的设置,使得使用缓冲区溢出进行攻击更加困难。当然也可以让进程的栈和 mmap 映区域从一个固定位置开始,只需要设置全局变量andomize_va_space值 为 0 , 这 个 变 量 默 认 值 为 1 。 用 户 可 以 通 过 设 置/proc/sys/kernel/randomize_va_space来停用该特性,也可以用如下命令:

sudo sysctl -w kernel.randomize_va_space=0

6.1.3 linux各个内存区域的存放

  1. 内核空间
    内核总是驻留在内存中,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。同时也包含了一些当前进程私有的进程控制块相关的信息。

  2. 栈(stack)
    栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。堆栈主要有三个用途:
    2.1. 为函数内部声明的非静态局部变量(C语言中称“自动变量”)提供存储空间。
    2.2. 记录函数调用过程相关的维护性信息,称为栈帧(Stack Frame)或过程活动记录(Procedure Activation Record)。它包括函数返回地址,不适合装入寄存器的函数参数及一些寄存器值的保存。除递归调用外,堆栈并非必需。因为编译时可获知局部变量,参数和返回地址所需空间,并将其分配于BSS段。
    2.3. 临时存储区,用于暂存长算术表达式部分计算结果或alloca()函数分配的栈内内存。
      持续地重用栈空间有助于使活跃的栈内存保持在CPU缓存中,从而加速访问。进程中的每个线程都有属于自己的栈。向栈中不断压入数据时,若超出其容量就会耗尽栈对应的内存区域,从而触发一个页错误。此时若栈的大小低于堆栈最大值RLIMIT_STACK(通常是8M),则栈会动态增长,程序继续运行。映射的栈区扩展到所需大小后,不再收缩。
    Linux中ulimit -s命令可查看和设置堆栈最大值,当程序使用的堆栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。注意,调高堆栈容量可能会增加内存开销和启动时间。
    堆栈既可向下增长(向内存低地址)也可向上增长, 这依赖于具体的实现。
    堆栈的大小在运行时由内核动态调整。

  3. 内存映射段(mmap)
      此处,内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用或Windows的CreateFileMapping()/MapViewOfFile()请求这种映射。内存映射是一种方便高效的文件I/O方式, 因而被用于装载动态共享库。
      用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据。在 Linux中,若通过malloc()请求一大块内存,C运行库将创建一个匿名内存映射,而不使用堆内存。”大块” 意味着比阈值 MMAP_THRESHOLD还大,缺省为128KB,可通过mallopt()调整。
      该区域用于映射可执行文件用到的动态链接库。在Linux 2.4版本中,若可执行文件依赖共享库,则系统会为这些动态库在从0x40000000开始的地址分配相应空间,并在程序装载时将其载入到该空间。在Linux 2.6内核中,共享库的起始地址被往上移动至更靠近栈区的位置。

  4. 堆(heap)
      堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。当进程调用malloc©/new(C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free©/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。
      分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。

  5. BSS段
    BSS(Block Started by Symbol)段中通常存放程序中以下符号:
    1.未初始化的全局变量和静态局部变量
    2.初始值为0的全局变量和静态局部变量(依赖于编译器实现)
    3.未定义且初值不为0的符号(该初值即common block的大小)
      C语言中,未显式初始化的静态分配变量被初始化为0(算术类型)或空指针(指针类型)。由于程序加载时,BSS会被操作系统清零,所以未赋初值或初值为0的全局变量都在BSS中。BSS段仅为未初始化的静态分配变量预留位置,在目标文件中并不占据空间,这样可减少目标文件体积。但程序运行时需为变量分配内存空间,故目标文件必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器(loader)加载程序时,将为BSS段分配的内存初始化为0。在嵌入式软件中,进入main()函数之前BSS段被C运行时系统映射到初始化为全零的内存(效率较高)。

  6. 数据段(Data)
    数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。

  7. 代码段(text)
      代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。

  8. 保留区
      位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。

6.2 操作系统内存分配的相关函数

  上节提到 heap 和 mmap 映射区域是可以提供给用户程序使用的虚拟内存空间,如何获得该区域的内存呢?操作系统提供了相关的系统调用来完成相关工作。

  1. 对 heap 的操作,操作系统提供了 brk()函数,C 运行时库提供了 sbrk()函数;
  2. 对 mmap 映射区域的操作,操作系统提供了 mmap()和 munmap()函数。
  3. sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。

Glibc 同样是使用这些函数向操作系统申请虚拟内存。

  这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。

6.2.1 Heap 操作相关函数

  Heap 操作函数主要有两个,brk()为系统调用,sbrk()为 C 库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。Glibc 的 malloc 函数族(realloc,calloc 等)就调用 sbrk()函数将数据段的下界移动,sbrk()函数在内核的管理下将虚拟地址空间映射到内存,供 malloc()函数使用。
  内核数据结构 mm_struct 中的成员变量 start_code 和 end_code 是进程代码段的起始和终止地址,start_data 和 end_data 是进程数据段的起始和终止地址,start_stack 是进程堆栈段起始地址,start_brk 是进程动态内存分配起始地址(堆的起始地址),还有一个 brk(堆的当前最后地址),就是动态内存分配当前的终止地址。C 语言的动态内存分配基本函数是malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用,
只是简单地改变 mm_struct 结构的成员变量 brk 的值。
这两个函数的定义如下:

 #include <unistd.h>
 int brk(void *addr);
 void *sbrk(intptr_t increment);

需要说明的是,但 sbrk()的参数 increment 为 0 时,sbrk()返回的是进程的当前 brk 值,
increment 为正数时扩展 brk 值,当 increment 为负值时收缩 brk 值。

6.2.2 Mmap 映射区域操作相关函数

mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的
大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap 执行相反的操
作,删除特定地址区域的对象映射。函数的定义如下:

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

在这里不准备对这两个函数做详细介绍,只是对 ptmalloc 中用到的功能做一下介绍,其
他的用法请参看相关资料。
参数:

 1. start:映射区的开始地址。
 2. length:映射区的长度。
 3. prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or 运算合理地组合在一起。Ptmalloc 中主要使用了如下的几个标志:
 	3.1 PROT_EXEC //页内容可以被执行,ptmalloc 中没有使用
    3.2 PROT_READ //页内容可以被读取,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
 	3.3 PROT_WRITE //页可以被写入,ptmalloc 直接用 mmap 分配内存并立即返回给用户时设置该标志
 	3.4 PROT_NONE //页不可访问,ptmalloc 用 mmap 向系统“批发”一块内存进行管理时设置该标志
4. flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
 	4.1 MAP_FIXED //使用指定的映射起始地址,如果由 start 和 len 参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。Ptmalloc 在回收从系统中“批发”的内存时设置该标志。
 	4.2 MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。Ptmalloc每次调用mmap都设置该标志。
 	4.3 MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空不被保留,同时内存不足,对映射区的修改会引起段违例信号。Ptmalloc 向系统“批发”内存块时设置该标志。
 	4.4 MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。Ptmalloc 每次调用 mmap都设置该标志。这说明malloc每次调用都是使用的匿名文件的方式进行映射。直接分配内存使用,而不是进行文件映射。
5. fd:有效的文件描述词。如果 MAP_ANONYMOUS 被设定,为了兼容问题,其值应为-1。
6. offset:被映射对象内容的起点。

6.3. 内存管理概述

  当不知道程序的每个部分将需要多少内存时,系统内存空间有限,而内存需求又是变化的,这时就需要内存管理程序来负责分配和回收内存。程序的动态性越强,内存管理就越重要,内存分配程序的选择也就更重要。

6.3.1 内存管理的方法

可用于内存管理的方法有许多种,它们各有好处与不足,不同的内存管理方法有最适用的情况。

6.3.1.1 C 风格的内存管理程序

  C 风格的内存管理程序主要实现 malloc()和 free()函数。内存管理程序主要通过调用 brk()或者 mmap()进程添加额外的虚拟内存。Doug Lea Malloc,ptmalloc,BSD malloc,Hoard,TCMalloc 都属于这一类内存管理程序。
  基于 malloc()的内存管理器仍然有很多缺点,不管使用的是哪个分配程序。对于那些需要保持长期存储的程序使用 malloc()来管理内存可能会非常令人失望。如果有大量的不固定的内存引用,经常难以知道它们何时被释放。生存期局限于当前函数的内存非常容易管理,但是对于生存期超出该范围的内存来说,管理内存则困难得多。因为管理内存的问题,很多程序倾向于使用它们自己的内存管理规则。

6.3.1.2 池式内存管理

  内存池是一种半内存管理方法。内存池帮助某些程序进行自动内存管理,这些程序会经历一些特定的阶段,而且每个阶段中都有分配给进程的特定阶段的内存。例如,很多网络服务器进程都会分配很多针对每个连接的内存——内存的最大生存期限为当前连接的存在期。

  Apache 使用了池式内存(pooled memory),将其连接拆分为各个阶段,每个阶段都有自己的内存池。在结束每个阶段时,会一次释放所有内存。在池式内存管理中,每次内存分配都会指定内存池,从中分配内存。每个内存池都有不同的生存期限。在 Apache 中,有一个持续时间为服务器存在期的内存池,还有一个持续时间为连接的存在期的内存池,以及一个持续时间为请求的存在期的池,另外还有其他一些内存池。因此,如果我的一系列函数不会生成比连接持续时间更长的数据,那么我就可以完全从连接池中分配内存,并知道在连接结束时,这些内存会被自动释放。另外,有一些实现允许注册清除函数(cleanup functions),在清除内存池之前,恰好可以调用它,来完成在内存被清理前需要完成的其他所有任务(类似于面向对象中的析构函数)。

注意,这个池不是java中的堆内存中的那个池式管理,只是分阶段的内存管理,内存池的使用是直接按照时间周期进行管理的。

6.3.1.3 引用计数

  在引用计数中,所有共享的数据结构都有一个域来包含当前活动“引用”结构的次数。当向一个程序传递一个指向某个数据结构指针时,该程序会将引用计数增加 1。实质上,是在告诉数据结构,它正在被存储在多少个位置上。然后,当进程完成对它的使用后,该程序就会将引用计数减少 1。结束这个动作之后,它还会检查计数是否已经减到零。如果是,那么它将释放内存。

  在 Java,Perl 等高级语言中,进行内存管理时使用引用计数非常广泛。在这些语言中,引用计数由语言自动地处理,所以您根本不必担心它,除非要编写扩展模块。由于所有内容都必须进行引用计数,所以这会对速度产生一些影响,但它极大地提高了编程的安全性和方便性。

6.3.1.4 垃圾收集

  垃圾收集(Garbage collection)是全自动地检测并移除不再使用的数据对象。垃圾收集器通常会在当可用内存减少到少于一个具体的阈值时运行。通常,它们以程序所知的可用的一组“基本”数据——栈数据、全局变量、寄存器——作为出发点。然后它们尝试去追踪通过这些数据连接到每一块数据。收集器找到的都是有用的数据;它没有找到的就是垃圾,可以被销毁并重新使用这些无用的数据。为了有效地管理内存,很多类型的垃圾收集器都需要知道数据结构内部指针的规划,所以,为了正确运行垃圾收集器,它们必须是语言本身的一部分。
垃圾收集的一些优点:

  • 永远不必担心内存的双重释放或者对象的生命周期。
  • 使用某些收集器,您可以使用与常规分配相同的 API。
    其缺点包括:
  • 使用大部分收集器时,您都无法干涉何时释放内存。
  • 在多数情况下,垃圾收集比其他形式的内存管理更慢。
  • 垃圾收集错误引发的缺陷难于调试。
  • 如果您忘记将不再使用的指针设置为 null,那么仍然会有内存泄漏,这一点在java中不是这个样子的。

6.3.2 常见的c内存管理程序

1.Doug Lea Malloc:Doug Lea Malloc 实际上是完整的一组分配程序,其中包括 Doug Lea的原始分配程序,GNU libc 分配程序和 ptmalloc。Doug Lea 的分配程序加入了索引,这使得搜索速度更快,并且可以将多个没有被使用的块组合为一个大的块。它还支持缓存,以便更快地再次使用最近释放的内存。ptmalloc 是 Doug Lea Malloc 的一个扩展版本,支持多线程。

2.BSD Malloc:BSD Malloc 是随 4.2 BSD 发行的实现,包含在 FreeBSD 之中,这个分配
程序可以从预先确实大小的对象构成的池中分配对象。

3.Hoard:编写 Hoard 的目标是使内存分配在多线程环境中进行得非常快。因此,它的
构造以锁的使用为中心,从而使所有进程不必等待分配内存。它可以显著地加快那
些进行很多分配和回收的多线程进程的速度。

4.TCMalloc:(Thread-Caching Malloc)是 google 开发的开源工具──“google-perftools”
中的成员。与标准的 Glibc 库的 malloc 相比,TCMalloc 在内存的分配上效率和速度
要高得多。

6.4. Ptmalloc 内存管理概述

6.4.1 ptmalloc设计假设

Ptmalloc 在设计时折中了高效率,高空间利用率,高可用性等设计目标。在其实现代码
中,隐藏着内存管理中的一些设计假设,这些设计假设包括:
的行为很诡异。这些设计假设包括:

  1. 具有长生命周期的大内存分配使用 mmap。
  2. 特别大的内存分配总是使用 mmap。(这里使用mmap和上一个使用还有不同点)
  3. 具有短生命周期的内存分配使用 brk,因为用 mmap 映射匿名页,当发生缺页异常
    时,linux 内核为缺页分配一个新物理页,并将该物理页清 0,一个 mmap 的内存块
    需要映射多个物理页,导致多次清 0 操作,很浪费系统资源,所以引入了 mmap
    分配阈值动态调整机制,保证在必要的情况下才使用 mmap 分配内存。
  4. 尽量只缓存临时使用的空闲小内存块,对大内存块或是长生命周期的大内存块在释
    放时都直接归还给操作系统。
  5. 对空闲的小内存块只会在 malloc 和 free 的时候进行合并,free 时空闲内存块可能
    放入 pool 中,不一定归还给操作系统。
  6. 收缩堆的条件是当前 free 的块大小加上前后能合并 chunk 的大小大于 64KB、,并且
    堆顶的大小达到阈值,才有可能收缩堆,把堆最顶端的空闲内存返回给操作系统。
  7. 需要保持长期存储的程序不适合用 ptmalloc 来管理内存。
  8. 为了支持多线程,多个线程可以从同一个分配区(arena)中分配内存,ptmalloc
    假设线程 A 释放掉一块内存后,线程 B 会申请类似大小的内存,但是 A 释放的内
    存跟 B 需要的内存不一定完全相等,可能有一个小的误差,就需要不停地对内存块
    作切割和合并,这个过程中可能产生内存碎片。

6.4.2 内存管理数据结构概述

6.4.2.1 Main_arena 与 non_main_arena

  在 Doug Lea 实现的内存分配器中只有一个主分配区(main arena),每次分配内存都必须对主分配区加锁,分配完成后释放锁,在 SMP 多线程环境下,对主分配区的锁的争用很激烈,严重影响了 malloc 的分配效率。于是 Wolfram Gloger 在 Doug Lea 的基础上改进使得Glibc 的 malloc 可以支持多线程,增加了非主分配区(non main arena)支持,主分配区与非主分配区用环形链表进行管理。每一个分配区利用互斥锁(mutex)使线程对于该分配区的访问互斥。
  每个进程只有一个主分配区,但可能存在多个非主分配区,ptmalloc 根据系统对分配区的争用情况动态增加非主分配区的数量,分配区的数量一旦增加,就不会再减少了。主分配区可以访问进程的 heap 区域和 mmap 映射区域,也就是说主分配区可以使用 sbrk 和 mmap向操作系统申请虚拟内存。而非主分配区只能访问进程的 mmap 映射区域,非主分配区每次使用 mmap()向操作系统“批发”HEAP_MAX_SIZE(32 位系统上默认为 1MB,64 位系统默认为 64MB)大小的虚拟内存,当用户向非主分配区请求分配内存时再切割成小块“零售”出去,毕竟系统调用是相对低效的,直接从用户空间分配内存快多了。所以 ptmalloc 在必要的情况下才会调用 mmap()函数向操作系统申请虚拟内存。
  主分配区可以访问 heap 区域,如果用户不调用 brk()或是 sbrk()函数,分配程序就可以保证分配到连续的虚拟地址空间,因为每个进程只有一个主分配区使用 sbrk()分配 heap 区域的虚拟内存。内核对 brk 的实现可以看着是 mmap 的一个精简版,相对高效一些。如果主分配区的内存是通过 mmap()向系统分配的,当 free 该内存时,主分配区会直接调用 munmap()将该内存归还给系统。
  当某一线程需要调用 malloc()分配内存空间时,该线程先查看线程私有变量中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败,该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。
  申请小块内存时会产生很多内存碎片,ptmalloc 在整理时也需要对分配区做加锁操作。每个加锁操作大概需要 5~10 个 cpu 指令,而且程序线程很多的情况下,锁等待的时间就会延长,导致 malloc 性能下降。一次加锁操作需要消耗 100ns 左右,正是锁的缘故,导致 ptmalloc在多线程竞争情况下性能远远落后于 tcmalloc。最新版的 ptmalloc 对锁进行了优化,加入了PER_THREAD 和 ATOMIC_FASTBINS 优化,但默认编译不会启用该优化,这两个对锁的优化应该能够提升多线程内存的分配的效率。

6.4.2.2 chunk

  不管内存是在哪里被分配的,用什么方法分配,用户请求分配的空间在 ptmalloc 中都使用一个 chunk 来表示。用户调用 free()函数释放掉的内存也并不是立即就归还给操作系统,相反,它们也会被表示为一个 chunk,ptmalloc 使用特定的数据结构来管理这些空闲的 chunk。

6.4.2.2.1 chunk的分类

1.bins
  用户 free 掉的内存并不是都会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk,当用户进行下一次分配请求时,ptmalloc 会首先试图在空闲的chunk 中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。ptmalloc将相似大小的 chunk 用双向链表链接起来,这样的一个链表被称为一个 bin。Ptmalloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin(如下图所示)。
  数组中的第一个为 unsorted bin,数组中从 2 开始编号的前 64 个 bin 称为 small bins,同一个small bin中的chunk具有相同的大小。两个相邻的small bin中的chunk大小相差8bytes。small bins 中的 chunk 按照最近使用顺序进行排列,最后释放的 chunk 被链接到链表的头部,而申请 chunk 是从链表尾部开始,这样,每一个 chunk 都有相同的机会被 ptmalloc 选中。
  Small bins 后面的 bin 被称作 large bins。large bins 中的每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小序排列。相同大小的 chunk 同样按照最近使用顺序排列。ptmalloc 使用“smallest-first,best-fit”原则在空闲 large bins 中查找合适的 chunk。当空闲的 chunk 被链接到 bin 中的时候,ptmalloc 会把表示该 chunk 是否处于使用中的标志 P 设为 0(注意,这个标志实际上处在下一个 chunk 中),同时 ptmalloc 还会检查它前后的 chunk 是否也是空闲的,如果是的话,ptmalloc 会首先把它们合并为一个大的 chunk,然后将合并后的 chunk 放到 unstored bin 中。要注意的是,并不是所有的 chunk 被释放后就立即被放到 bin 中。ptmalloc 为了提高分配的速度,会把一些小的的 chunk 先放到一个叫做fast bins 的容器内。

2.Fast Bins
  一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,ptmalloc 中在分配过程中引入了 fast bins,不大于 max_fast (默认值为 64B)的 chunk 被释放后,首先会被放到 fast bins中,fast bins 中的 chunk 并不改变它的使用标志 P(这个标识的意思是上一个bin是否是空闲的)。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,ptmalloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找bins中的空闲chunk。在某个特定的时候,ptmalloc会遍历fast bins中的chunk,将相邻的空闲 chunk 进行合并,并将合并后的 chunk 加入 unsorted bin 中,然后再将 usortedbin 里的 chunk 加入 bins 中。

3.Unsorted Bin
  unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于 max_fast,或者 fast bins 中的空闲 chunk 合并后,这些 chunk 首先会被放到 unsorted bin 队列中,在进行 malloc 操作的时候,如果在 fast bins 中没有找到合适的 chunk,则 ptmalloc 会先在 unsortedbin 中查找合适的空闲 chunk,然后才查找 bins。如果 unsorted bin 不能满足分配要求。malloc便会将 unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,unsorted bin 可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度。

4.Top chunk
  并不是所有的 chunk 都按照上面的方式来组织,实际上,有三种例外情况。Top chunk,mmaped chunk 和 last remainder,下面会分别介绍这三类特殊的 chunk。top chunk 对于主分配区和非主分配区是不一样的。
  对于非主分配区会预先从 mmap 区域分配一块较大的空闲内存模拟 sub-heap,通过管理 sub-heap 来响应用户的需求,因为内存是按地址从低向高进行分配的,在空闲内存的最高处,必然存在着一块空闲 chunk,叫做 top chunk。当 bins 和 fast bins 都不能满足分配需要的时候,ptmalloc 会设法在 top chunk 中分出一块内存给用户,如果 top chunk 本身不够大,分配程序会重新分配一个 sub-heap,并将 top chunk 迁移到新的 sub-heap 上,新的 sub-heap与已有的 sub-heap 用单向链表连接起来,然后在新的 top chunk 上分配所需的内存以满足分配的需要,实际上,top chunk 在分配时总是在 fast bins 和 bins 之后被考虑,所以,不论 topchunk 有多大,它都不会被放到 fast bins 或者是 bins 中。Top chunk 的大小是随着分配和回收不停变换的,如果从 top chunk 分配内存会导致 top chunk 减小,如果回收的 chunk 恰好与 top chunk 相邻,那么这两个 chunk 就会合并成新的 top chunk,从而使 top chunk 变大。如果在 free 时回收的内存大于某个阈值,并且 top chunk 的大小也超过了收缩阈值,ptmalloc会收缩 sub-heap,如果 top-chunk 包含了整个 sub-heap,ptmalloc 会调用 munmap 把整个sub-heap 的内存返回给操作系统。

  由于主分配区是唯一能够映射进程 heap 区域的分配区,它可以通过 sbrk()来增大或是收缩进程 heap 的大小,ptmalloc 在开始时会预先分配一块较大的空闲内存(也就是所谓的 heap),主分配区的 top chunk 在第一次调用 malloc 时会分配一块(chunk_size + 128KB)align 4KB 大小的空间作为初始的 heap,用户从 top chunk 分配内存时,可以直接取出一块内存给用户。在回收内存时,回收的内存恰好与 top chunk 相邻则合并成新的 top chunk,当该次回收的空闲内存大小达到某个阈值,并且 top chunk 的大小也超过了收缩阈值,会执行内存收缩,减小 top chunk 的大小,但至少要保留一个页大小的空闲内存,从而把内存归还给操作系统。如果向主分配区的 top chunk 申请内存,而 top chunk 中没有空闲内存,ptmalloc会调用 sbrk()将的进程 heap 的边界 brk 上移,然后修改 top chunk 的大小。

5.mmaped chunk
  当需要分配的 chunk 足够大,而且 fast bins 和 bins 都不能满足要求,甚至 top chunk 本身也不能满足分配需求时,ptmalloc 会使用 mmap 来直接使用内存映射来将页映射到进程空间。这样分配的 chunk 在被 free 时将直接解除映射,于是就将内存归还给了操作系统,再次对这样的内存区的引用将导致 segmentation fault 错误。这样的 chunk 也不会包含在任何bin 中。

6.Last remainder
  Last remainder 是另外一种特殊的 chunk,就像 top chunk 和 mmaped chunk 一样,不会在任何 bins 中找到这种 chunk。当需要分配一个 small chunk,但在 small bins 中找不到合适的 chunk,如果 last remainder chunk 的大小大于所需的 small chunk 大小,last remainder chunk被分裂成两个 chunk,其中一个 chunk 返回给用户,另一个 chunk 变成新的 last remainder chuk。

6.4.2.3 sbrk 与 mmap

  从进程的内存布局可知,.bss 段之上的这块分配给用户程序的空间被称为 heap (堆)。start_brk 指向 heap 的开始,而 brk 指向 heap 的顶部。可以使用系统调用 brk()和 sbrk()来增加标识 heap 顶部的 brk 值,从而线性的增加分配给用户的 heap 空间。在使 malloc 之前,brk的值等于start_brk,也就是说heap大小为0。ptmalloc在开始时,若请求的空间小于 mmap分配阈值(mmap threshold,默认值为 128KB)时,主分配区会调用 sbrk()增加一块大小为 (128KB + chunk_size) align 4KB 的空间作为 heap。非主分配区会调用 mmap 映射一块大小为HEAP_MAX_SIZE(32 位系统上默认为 1MB,64 位系统上默认为 64MB)的空间作为 sub-heap。这就是前面所说的 ptmalloc 所维护的分配空间,当用户请求内存分配时,首先会在这个区域内找一块合适的 chunk 给用户。当用户释放了 heap 中的 chunk 时,ptmalloc 又会使用 fastbins 和 bins 来组织空闲 chunk。以备用户的下一次分配。若需要分配的 chunk 大小小于 mmap分配阈值,而 heap 空间又不够,则此时主分配区会通过 sbrk()调用来增加 heap 大小,非主分配区会调用 mmap 映射一块新的 sub-heap,也就是增加 top chunk 的大小,每次 heap 增加的值都会对齐到 4KB。

  当用户的请求超过 mmap 分配阈值,并且主分配区使用 sbrk()分配失败的时候,或是非主分配区在 top chunk 中不能分配到需要的内存时,ptmalloc 会尝试使用 mmap()直接映射一块内存到进程内存空间。使用 mmap()直接映射的 chunk 在释放时直接解除映射,而不再属于进程的内存空间。任何对该内存的访问都会产生段错误。而在 heap 中或是 sub-heap 中分配的空间则可能会留在进程内存空间内,还可以再次引用(当然是很危险的)。

  当 ptmalloc munmap chunk 时,如果回收的 chunk 空间大小大于 mmap 分配阈值的当前值,并且小于 DEFAULT_MMAP_THRESHOLD_MAX(32 位系统默认为 512KB,64 位系统默认为 32MB),ptmalloc 会把 mmap 分配阈值调整为当前回收的 chunk 的大小,并将 mmap 收缩阈值(mmap trim threshold)设置为 mmap 分配阈值的 2 倍。这就是 ptmalloc 的对 mmap分配阈值的动态调整机制,该机制是默认开启的,当然也可以用 mallopt()关闭该机制(将在3.2.6 节介绍如何关闭该机制)。

6.4.3 内存分配步骤

6.4.3.1 分配算法概述,以 32 系统为例,64 位系统类似。

  • 小于等于 64 字节:用 pool 算法分配。
  • 64 到 512 字节之间:在最佳匹配算法分配和 pool 算法分配中取一种合适的。
  •  大于等于 512 字节:用最佳匹配算法分配。
  •  大于等于 mmap 分配阈值(默认值 128KB):根据设置的 mmap 的分配策略进行分配,

如果没有开启 mmap 分配阈值的动态调整机制,大于等于 128KB 就直接调用 mmap分配。否则,大于等于 mmap 分配阈值时才直接调用 mmap()分配。

6.4.3.2 ptmalloc 的响应用户内存分配要求的具体步骤为:

  1. 获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么 ptmalloc 会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用 mmap()创建一个 sub-heap,并设置好 top chunk。
  2. 将用户的请求大小转换为实际需要分配的 chunk 空间大小。
  3. 判断所需分配chunk的大小是否满足chunk_size <= max_fast (max_fast 默认为 64B),如果是的话,则转下一步,否则跳到第 5 步。
  4. 首先尝试在 fast bins 中取一个所需大小的 chunk 分配给用户。如果可以找到,则分配结束。否则转到下一步。
  5. 判断所需大小是否处在 small bins 中,即判断 chunk_size < 512B 是否成立。如果chunk 大小处在 small bins 中,则转下一步,否则转到第 6 步。
  6. 根据所需分配的 chunk 的大小,找到具体所在的某个 small bin,从该 bin 的尾部摘取一个恰好满足大小的 chunk。若成功,则分配结束,否则,转到下一步。
  7. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 中找不到合适的chunk。于是,ptmalloc 首先会遍历 fast bins 中的 chunk,将相邻的 chunk 进行合并,并链接到 unsorted bin 中,然后遍历 unsorted bin 中的 chunk,如果 unsorted bin 只有一个 chunk,并且这个 chunk 在上次分配时被使用过,并且所需分配的 chunk 大小属于 small bins,并且 chunk 的大小大于等于需要分配的大小,这种情况下就直接将该 chunk 进行切割,分配结束,否则将根据 chunk 的空间大小将其放入 smallbins 或是 large bins 中,遍历完成后,转入下一步。
  8. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 和 unsorted bin 中都找不到合适的 chunk,并且 fast bins 和 unsorted bin 中所有的 chunk 都清除干净了。从 large bins 中按照“smallest-first,best-fit”原则,找一个合适的 chunk,从中划分一块所需大小的 chunk,并将剩下的部分链接回到 bins 中。若操作成功,则分配结束,否则转到下一步。
  9. 如果搜索 fast bins 和 bins 都没有找到合适的 chunk,那么就需要操作 top chunk 来进行分配了。判断 top chunk 大小是否满足所需 chunk 的大小,如果是,则从 topchunk 中分出一块来。否则转到下一步。
  10. 到了这一步,说明 top chunk 也不能满足分配要求,所以,于是就有了两个选择: 如果是主分配区,调用 sbrk(),增加 top chunk 大小;如果是非主分配区,调用 mmap来分配一个新的 sub-heap,增加 top chunk 大小;或者使用 mmap()来直接分配。在这里,需要依靠 chunk 的大小来决定到底使用哪种方法。判断所需分配的 chunk
    大小是否大于等于 mmap 分配阈值,如果是的话,则转下一步,调用 mmap 分配,否则跳到第 12 步,增加 top chunk 的大小。
  11. 使用 mmap 系统调用为程序的内存空间映射一块 chunk_size align 4kB 大小的空间。然后将内存指针返回给用户。
  12. 判断是否为第一次调用 malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB 大小的空间作为初始的 heap。若已经初始化过了,主分配区则调用 sbrk()增加 heap 空间,分主分配区则在 top chunk 中切割出一个 chunk,使之满足分配需求,并将内存指针返回给用户。

  总结一下:根据用户请求分配的内存的大小,ptmalloc 有可能会在两个地方为用户分配内存空间。在第一次分配内存时,一般情况下只存在一个主分配区,但也有可能从父进程那里继承来了多个非主分配区,在这里主要讨论主分配区的情况,brk 值等于start_brk,所以实际上 heap 大小为 0,top chunk 大小也是 0。这时,如果不增加 heap大小,就不能满足任何分配要求。所以,若用户的请求的内存大小小于 mmap 分配阈值,则 ptmalloc 会初始 heap。然后在 heap 中分配空间给用户,以后的分配就基于这个 heap进行。若第一次用户的请求就大于 mmap 分配阈值,则 ptmalloc 直接使用 mmap()分配一块内存给用户,而 heap 也就没有被初始化,直到用户第一次请求小于 mmap 分配阈值的内存分配。第一次以后的分配就比较复杂了,简单说来,ptmalloc 首先会查找 fast bins,如果不能找到匹配的 chunk,则查找 small bins。若还是不行,合并 fast bins,把 chunk加入 unsorted bin,在 unsorted bin 中查找,若还是不行,把 unsorted bin 中的 chunk 全加入 large bins 中,并查找 large bins。在 fast bins 和 small bins 中的查找都需要精确匹配,而在 large bins 中查找时,则遵循“smallest-first,best-fit”的原则,不需要精确匹配。若以上方法都失败了,则 ptmalloc 会考虑使用 top chunk。若 top chunk 也不能满足分配要求。而且所需 chunk 大小大于 mmap 分配阈值,则使用 mmap 进行分配。否则增加heap,增大 top chunk。以满足分配要求。

6.4.3.2 内存回收概述

free() 函数接受一个指向分配区域的指针作为参数,释放该指针所指向的 chunk。而具
体的释放方法则看该 chunk 所处的位置和该 chunk 的大小。free()函数的工作步骤如下:

  1. free()函数同样首先需要获取分配区的锁,来保证线程安全。
  2. 判断传入的指针是否为 0,如果为 0,则什么都不做,直接 return。否则转下一步。
  3. 判断所需释放的 chunk 是否为 mmaped chunk,如果是,则调用 munmap()释放mmaped chunk,解除内存空间映射,该该空间不再有效。如果开启了 mmap 分配阈值的动态调整机制,并且当前回收的 chunk 大小大于 mmap 分配阈值,将 mmap分配阈值设置为该 chunk 的大小,将 mmap 收缩阈值设定为 mmap 分配阈值的 2倍,释放完成,否则跳到下一步。
  4. 判断 chunk 的大小和所处的位置,若 chunk_size <= max_fast,并且 chunk 并不位于heap 的顶部,也就是说并不与 top chunk 相邻,则转到下一步,否则跳到第 6 步。(因为与 top chunk 相邻的小 chunk 也和 top chunk 进行合并,所以这里不仅需要判断大小,还需要判断相邻情况)
  5. 将 chunk 放到 fast bins 中,chunk 放入到 fast bins 中时,并不修改该 chunk 使用状态位 P。也不与相邻的 chunk 进行合并。只是放进去,如此而已。这一步做完之后释放便结束了,程序从 free()函数中返回。
  6. 判断前一个 chunk 是否处在使用中,如果前一个块也是空闲块,则合并。并转下一步。
  7. 判断当前释放 chunk 的下一个块是否为 top chunk,如果是,则转第 9 步,否则转下一步。
  8. 判断下一个 chunk 是否处在使用中,如果下一个 chunk 也是空闲的,则合并,并将合并后的 chunk 放到 unsorted bin 中。注意,这里在合并的过程中,要更新 chunk的大小,以反映合并后的 chunk 的大小。并转到第 10 步。
  9. 如果执行到这一步,说明释放了一个与 top chunk 相邻的 chunk。则无论它有多大,都将它与 top chunk 合并,并更新 top chunk 的大小等信息。转下一步。
  10. 判断合并后的 chunk 的大小是否大于 FASTBIN_CONSOLIDATION_THRESHOLD(默认64KB),如果是的话,则会触发进行 fast bins 的合并操作,fast bins 中的 chunk 将被遍历,并与相邻的空闲 chunk 进行合并,合并后的 chunk 会被放到 unsorted bin 中。fast bins 将变为空,操作完成之后转下一步。
  11. 判断 top chunk 的大小是否大于 mmap 收缩阈值(默认为 128KB),如果是的话,对于主分配区,则会试图归还 top chunk 中的一部分给操作系统。但是最先分配的128KB 空间是不会归还的,ptmalloc 会一直管理这部分内存,用于响应用户的分配请求;如果为非主分配区,会进行 sub-heap 收缩,将 top chunk 的一部分返回给操作系统,如果 top chunk 为整个 sub-heap,会把整个 sub-heap 还回给操作系统。做完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前free 的 chunk 大小加上前后能合并 chunk 的大小大于 64k,并且要 top chunk 的大小要达到 mmap 收缩阈值,才有可能收缩堆。

参考
https://www.iteye.com/blog/mqzhuang-1014269

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!