内存分配
Linux提供低级的内存分配函数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);实际项目中使用动态内存分配器更加方便
虚拟内存中堆的区域被分配器视为大小不同的块的集合
块有两种状态:分配的和空闲的
分配器具有两种风格
- 显式分配器:要求程序员必须手动释放内存块
- 隐式分配器:程序员不需要手动释放内存块,分配器会自动回收不再使用的内存块
C标准库提供了malloc和free的动态内存分配器
通过mmap,munmap或者sbrk显式分配和释放堆内存
sbrk函数通过将内核指向堆顶的brk指针向上或向下移动来调整堆的大小
#include <unistd.h>
void *sbrk(intptr_t increment);#include <stdlib.h>
void *malloc(size_t size);
void free(void *ptr);隐式空闲链表
碎片
碎片会使得堆利用率下降,分为内部碎片和外部碎片
- 内部碎片:分配的块比请求的块大,导致未使用的内存
- 外部碎片:空闲块被分割成小块,无法满足较大的分配请求
结构
隐式空闲链表通过在每个块的头部储存块的大小和分配状态来管理内存,空闲块在尾部复制头部信息来实现双向遍历,同时在头部用一位标记前面的块是否被分配
获得一个指向块头部的指针,就可以通过加当前块的大小来获得下一个块的头部指针
通过前移一小块读取前一个块的大小,获取前一个块的头部指针
分配
当应用请求内存时,分配器会遍历隐式空闲链表来寻找一个足够大的空闲块来满足请求
分配器执行遍历的放置策略有以下几种
- 首次适配:从头开始遍历,找到第一个足够大的空闲块
- 下一次适配:从上次分配的位置继续遍历,找到第一个足够大的空闲块
- 最佳适配:遍历整个链表,找到最小的足够大的空闲块
分配器获得到合适的块之后可能决定整个块都分配给应用,或者将块分割成两部分,一部分分配给应用,另一部分继续作为空闲块保留
释放
当应用释放一个块时,分配器会将该块标记为空闲,并尝试与相邻的空闲块合并成一个更大的空闲块,以减少外部碎片
合并的策略有两种
- 立即合并:在释放块时立即检查前后块的状态,并进行合并
- 推迟合并:稍晚时,比如分配失败时,进行合并
合并的过程如下:
- 通过当前块的头部信息获取前一个块是否空闲,如果空闲则获取前一个块的大小并合并
- 通过当前块的头部信息获取下一个块的头部指针,如果下一个块空闲则获取下一个块的大小并合并
显式空闲链表
显式空闲链表的所有块都有头部和尾部,但是在空闲块中新增了两个指针分别指向前后的空闲块组成双向链表
有两种维护显式空闲链表的策略
- 后进先出:新释放的空闲块被插入到链表头部
- 地址顺序:链表按照块的地址顺序维护
下图展现了后进先出策略时释放一个块的过程
分离的空闲链表
分离的空闲链表为不同大小的块维护多个空闲链表,每个链表管理一个特定大小范围的块
简单分离存储
简单分离存储中,每个大小类的链表中块的大小相等,且为大小类中最大元素的大小
例如,对于大小类{17 - 32},链表中的块大小为32字节
在这种方法中块的大小固定,不会被分割成更小的块,也不会被合并成更大的块
分离适配
分离适配中,每个大小类的链表中块的大小不固定,块可以被分割成更小的块,也可以被合并成更大的块
当被分隔或者合并的块的大小超过当前大小类的范围时,块会被移动到适当的链表中
C标准库中的malloc和free函数通常使用分离适配来管理内存
伙伴系统
伙伴系统是分离适配的一种特殊实现
在伙伴系统中,内存被划分为大小为2的幂次方的块,每个大小类都是2的幂次方
请求块的大小被向上调整为下一个2的幂次方,分配器在对应大小类的链表中寻找一个空闲块来满足请求
当找到的块比请求的块大时,分配器会将该块分割成两个大小相等的伙伴块,如果伙伴块还不满足,就递归地进行分割,直到找到一个合适的块来满足请求
当释放一个块时,分配器会检查该块的伙伴块是否也空闲,如果是,则将两个块合并成一个更大的块,并继续检查新的伙伴块,直到无法再合并为止
垃圾收集器
垃圾收集器是一种自动内存管理机制,负责回收不再使用的内存块以避免内存泄漏和碎片化
垃圾收集器将内存视为一张有向可达图
- 根节点:程序中直接引用的对象,如全局变量、栈上的局部变量等
- 可达对象:从根节点出发,通过引用关系可以访问到的对象
- 不可达对象:从根节点出发无法访问到的对象,被认为是垃圾
例如寄存器保存者一个指向堆的对象的指针,则被视为根节点
如果指向的堆中有一个指针指向堆中另一个块,则被指向的块是可达的
Java类似语言的垃圾收集器可以维护精确的可达图,但是C/C++的垃圾收集器只能维护一个近似的可达图,因为指针可以被强制转换为整数,或者整数可以被强制转换为指针,这些操作使得垃圾收集器无法准确地识别哪些对象是可达的,哪些对象是不可达的
因此,C/C++的垃圾收集器通常采用保守的策略,将所有可能被解释为指针的整数都视为根节点,这可能导致一些不可达对象被错误地保留在内存中,造成内存泄漏
下面介绍一种垃圾收集器的实现方法:Mark&Sweep:
- 从根节点出发,如果根节点指向堆中一个块内部,则将该块标记为可达的
- 扫描该块内部的所有数据当作指针,如果指向堆中另一个块内部,则将该块标记为可达的
- 递归地重复上面过程,直到没有新的块被标记为可达
- 扫描整个堆,将所有未被标记为可达的块视为垃圾,并将其回收