其他分享
首页 > 其他分享> > CMU15-213学习笔记(七)Dynamic Memory Allocation

CMU15-213学习笔记(七)Dynamic Memory Allocation

作者:互联网

CMU15-213学习笔记(七)Dynamic Memory Allocation

动态内存分配

程序员通过动态内存分配(例如 malloc)来让程序在运行时得到虚拟内存。动态内存分配器会管理一个虚拟内存区域,称为堆(heap)

image-20210915200550579

动态内存分配器将堆视为一组不同大小的块(block)的集合,每个块就是一个连续的虚拟内存片(chunk),每个块具有两种状态:

在最开始进行内存映射时,堆是与匿名文件关联起来的,所以堆是一个全0的段,即处于空闲状态,它紧跟在未初始的数据段后面,向地址更大的方向延伸,且内核对每个进程都维护了brk变量来指向堆顶

动态内存分配器具有两种类型,都要求由应用程序显示分配块,但是由不同实体来负责释放已分配的块:

程序使用动态内存分配器来动态分配内存的意义在于:有些数据结构只有在程序运行时才知道大小。通过这种方式就无需通过硬编码方式来指定数组大小,而是根据需要动态分配内存

mallocfree函数

C中提供了malloc显示分配器,程序可以通过malloc函数来显式地从堆中分配块

#include <stdlib.h>
void *malloc(size_t size); // 返回一个泛型void指针,需要将它强转为int*,才能通过编译

该函数会返回一个指向大小至少为size字节的未初始化内存块的指针,且根据程序的编译时选择的字长,来确定内存地址对齐的位数,比如-m32表示32位模式,地址与8对齐,-m64表示64位模式,地址与16对齐。如果函数出现错误,则返回NULL,并设置errno。我们也可以使用calloc函数来将分配的内存块初始化为0,也可以使用realloc函数来改变已分配块的大小。

程序可以通过free函数来释放已分配的堆块

#include <stdlib.h>
void free(void *ptr);

其中ptr参数要指向通过malloccallocrealloc函数获得的堆内存。

动态内存分配器可以使用mmapmunmap函数,也可以使用sbrk函数来向内核申请堆内存空间,只有先申请获得堆内存空间后,才能尝试对块进行分配让应用程序使用

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

brk函数会将brk设置为addr指定的值。sbrk函数通过将内核的brk指针增加incr来扩展和收缩堆,如果成功返回brk的旧值,否则返回-1,并将errno设置为ENOMEM

image-20210916111238305

限制:

性能指标

吞吐量

峰值内存利用率(Peak Memory Utilization):

影响内存利用率的主要因素就是内存碎片,分为内部碎片和外部碎片两种。

由于地址对齐要求和分配器对块格式的选择,会对最小块的大小有限制,没有已分配的块和空闲块比最小块还小,如果比最小块还小,就会变成外部碎片(所以最小块越大,内部碎片程度越高)。比如这里如果对齐要求是双字8字节的,则最小块大小为双字:第一个字用来保存头部,另一个字用来满足对齐要求。

实现问题

implicit free list

每个块都需要记录大小和分配状态

image-20210916135209948

malloc返回的指针指向有效载荷的开始处,而不是指向头部。

我们通过这种数据结构来组织堆内存,通过块头部的块大小来将堆中的所有块链接起来。分配器可以遍历所有块,然后通过块头部的字段来判断该块是否空闲的,来间接遍历整个空闲块集合。我们可以通过一个大小为0的已分配块来作为终止头部(Terminating Header),来表示结束块。

堆的头部也有一个未使用块,目的是让第一块的有效载荷地址为8字节,满足对齐要求。

image-20210916140819212

**注意:**先将有效载荷加上块头部大小,然后再满足对齐要求,得出来的就是块的大小。

找到空闲块

当应用请求一个k字节的空闲块时,分配器会搜索空闲链表,并根据不同的**放置策略(Placement Policy)**来确定使用的空闲块:

在空闲块中分配

image-20210916144959364 image-20210916145127259

如果分配器找不到满足要求的空闲块,则会首先尝试将物理上相邻的两个空闲块合并起来创建一个更大的空闲块,如果还是不满足要求,则分配器会调用sbrk函数来向内核申请额外的堆内存,然后将申请到的新空间当做是一个空闲块。

释放块

只需要将指针所指向位置(header)的已分配标志位变为0即可。

 void free_block(ptr p) { *p = *p & -2 } 
image-20210916145729967

但是如果被释放的块与其他空闲块相邻,则会产生假碎片(Fault Fragmentation)现象,即许多可用的空闲块被分割为小的无法使用的空闲块。所以当我们释放块时,还需要合并与之相邻的空闲块

优秀的分配器不应该允许有连续的空闲块出现

释放块有以下的策略:

image-20210916150505000

合并相邻的空闲块时,我们可以利用header中的块大小找到下一个块的位置,但是我们却没有高效的方法找到前一个块的位置(只能从链表起始处重新遍历一次)。

为了高效合并前一个空闲块,需要使用**边界标记(Boundary Tag)**技术,使得当前块能迅速判断前一个块是否为空闲的。

在块的数据结构中,会添加一个块头部的副本得到脚部。这样当前块从起始位置向前偏移一个字长度,就能得到前一个块的脚部,通过脚部就能判断前一个快是否为空闲的。这实际上就是双向链表,允许我们反向遍历free list

image-20210916151020445

可以将所有情况分成以下几种:

img

由于引入了脚部,增加了额外开销(overhead),使得内部碎片变多了,并且最小块的大小变大导致外部碎片也变多了。

我们可以对其进行优化,有些情况是不需要边界标记的,只有在合并时才需要脚部,而我们只会在空闲块上进行合并,所以在已分配的块上可以不需要脚部,那空闲块如何判断前一个块是否为已分配的呢?可以在自己的头部的3个位中用一个位来标记前一个块是否为空闲的,如果前一个块为已分配的,则无需关心前一个块的大小,因为不会进行合并;如果前一个块为空闲的,则前一个块自己就有脚部,说明了前一个块的大小,则可以顺利进行合并操作。

explicit free list

image-20210916164405639

因为空闲块中除了头部和脚部以外都是没用的,我们可以在implicit free list的空闲块中加入一个指向前一个空闲块的pred指针,还有一个指向下一个空闲块的succ指针,将implicit free list中的空闲块组织成双向链表形式。这样implicit free list就变成了了显式空闲块列表(explicit free list)。

但是这种方法需要更大的空闲最小块,否则不够存放两个指针,这就提高了外部碎片的程度。

image-20210916170112887

对于已分配块,可以通过头部和脚部来得到地址相邻两个块的信息,而对于空闲块,可以通过头部和脚部来得到地址相邻两个块,也可以通过两个指针直接获得相邻的两个空闲块。**注意:**逻辑上看这两个空闲块是相邻的,但物理地址上不一定是相邻的。

显式空闲块链表逻辑上是有序的,但是实际上所连接的空闲块的地址可能是无序的,空闲块可以以任意顺序连接。

image-20210916170835000

比如我们这里存在以下3个空闲块的双向链表,此时想要分配中间的空闲块,且对其进行分割

image-20210916173546127

因为已分配块可以根据指针来定位,所以不需要额外进行链接。而空闲块会从中分割出合适的部分用于分配,其余部分作为新的空闲块,此时只要更新6个指针使其指向和的位置就行。

image-20210916173607972

而当我们想要释放已分配块时,它并不在空闲链表中,要将其放在空闲链表什么位置?

LIFO

summary

相比于implicit list,explicit list的分配操作与空闲块的数量成线性相关,而不是所有块的数量。因此,对于已分配的块的数量多的情况,explicit list的速度要快得多。

Segregated free lists

为了减少分配时间,可以使用分离存储(Segregrated Storage)方法,首先将所有空闲块根据块大小分成不同类别,称为大小类(Size Class),比如可以根据2幂次分成img,每个类别一条链表,按照类别将空闲链表放入数组中,类似HashMap的实现方式,由此能极大加快分配速度。

img

当我们想要释放一个块时,需要对其地址周围的空闲块进行合并,然后将其放在合适的大小类中。

分离的空闲链表是当前最好的分配器类型

Garbage Collection

void foo() {
    int *p = malloc(128);
    return; /* p block is now garbage*/
}

在隐式分配器中,分配器会释放程序不再使用的已分配块,自动对其调用free函数进行释放。则应用程序只需要显示分配自己需要的块,而回收过程由分配器自动完成

内存分配器如何知道什么时候一个内存区域可以被释放呢?

但是这就要求:

本章主要介绍Mark&Sweep算法,它建立在malloc包的基础上,使得C和C++就有垃圾收集的能力。

垃圾收集器将内存视为一个有向可达图(Reachability Graph),其中:

如果一个节点从根节点出发不可达,那么该节点就是垃级。

image-20210917171504789

对于像ML和Java语言,其对指针创建和使用有严格的要求,由此来构建十分精确的可达图,所以能回收所有垃圾。而对于像C和C++这样的语言,垃圾收集器无法维护十分精确的可达图,只能正确地标记所有可达节点,而有一些不可达节点会被错误地标记为可达的,所以会遗留部分垃圾,这种垃圾收集器称为保守的垃圾收集器(Conservative Garbage Collector)

在C中使用垃圾收集器可以有两种方法:

Mark&Sweep垃圾收集器

Mark&Sweep垃圾收集器由两个阶段组成:

image-20210917173316926

这两个阶段的伪代码如下所示:

image-20210917175649043

上面实现的困难是:

Memory-Related Perils and Pitfalls

image-20210917181712484 image-20210917181938440

处理内存bug:

标签:链表,213,int,Dynamic,Allocation,分配器,分配,空闲,指针
来源: https://blog.csdn.net/qq_45698833/article/details/120358327