CSAPP:第九章——虚拟内存(下)

九、动态内存分配

分配器将堆看做是一组大小不同的块(block)的集合,每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。

  • 显示分配器:要求应用显示释放已分配的内存块。如C中的malloc函数。
  • 隐式分配器:也叫垃圾收集器,要求分配器自己检测已分配的块何时不再被应用使用,然后将其释放。如Java语言。

9.1 malloc 和 free 函数

malloc函数返回一个指针,指向大小至少为size字节的内存块,这个块会为可能包含在块内的任何数据对象类型做对齐,malloc不会初始化返回的内存。
实际上,对齐依赖于编译代码在32位模式还是64位模式,32位返回大小为8的倍数,64位返回大小为16的倍数。

calloc函数除了具有malloc的功能外,还会将分配的内存初始化为零。

realloc函数可以改变一个已经被分配的块的大小。

free函数释放已分配的块,释放的指针必须指向一个从malloccallocrealloc获得的已分配块的起始位置。

假设4个字节对象为字,8个字节对象为双字,看下面例子:

  • a)请求4字块,malloc响应:从空闲块的前部砍一个4字块,返回指向该块的第一个字的指针p1
  • b)请求5字块,实际分配6字块,为了对齐,可能会产生碎片
  • d)free(p2)p2仍指向被释放的块,被释放后就是未分配,肯定不能用,所以最好p2 = NULL
  • e)请求2字,在已释放的内存中分配2字。
    在这里插入图片描述

9.2 为什么要使用动态内存分配

程序使用动态内存分配的最重要原因是,经常直到程序实际运行时,它们才知道某些数据结构的大小。

	// 静态内存分配,100这个值在编译时就已经确定,不会再变
    int sta_array[100];

	// 动态内存分配,n是一个变量,可以在运行时根据n的值动态分配内存
    int n = 100;
    int *dyn_array = (int *)malloc(n * sizeof(int));

9.3 分配器的约束条件和目标

显式分配器有如下约束条件:

  • 可以有任意分配、释放请求序列,只需满足释放请求的块是由分配请求获得的。
  • 立即响应请求,不允许为提高性能重新排列或缓冲请求。
  • 只使用堆。
  • 对齐块,使得他们可以保存任何类型的数据对象。
  • 不修改已分配的块,只允许操作空闲块,一旦块被分配,就不能再修改或者移动。

两个性能目标,吞吐率最大化和内存使用率最大化,而这两个目标是互相冲突的:

  • 目标1:最大化吞吐率。吞吐率定义为单位时间内完成的请求数,一次分配释放是两次请求,单位时间请求的次数越少,效率最高。
  • 目标2:最大化内存利用率,即重复利用已经分配的内存。

9.4 碎片

当有未使用的内存,但是不能满足分配的请求时,就会产生碎片。碎片有两种形式,内部碎片外部碎片

  • 内部碎片:当已分配的块比有效载荷大时产生。如9.1b,请求5字块,为了对齐实际分配6字块。内部碎片的量化为已分配的块大小和有效载荷大小之间的差值。
  • 外部碎片:当一部分空闲的内存块合计起来可以满足一个分配请求,但是这些块没有一个可以单独满足一个分配请求。如9.1e,请求不是2个字块,而是8个字块时。外部碎片难以量化,分配器采用启发式策略试图维持少量大空闲块,而不是大量小空闲块。

9.5 隐式空间链表

一个堆块由一个字的头部,有效载荷,以及可能的填充组成。如果分配器的对齐约束是双字(8字节),则块的大小是8的倍数,最低3位总是零,因此可以利用低3位来编码其他信息。如大小为24(0x18)字节的块头部:0x00000018 | 0x1 = 0x00000019

  • 头部:编码了这个块的大小(包括头部和填充),以及这个块是否分配。
  • 有效载荷:调用malloc时请求的字节数。
  • 填充:用来对付内部碎片或者满足对齐要求。
    在这里插入图片描述

将堆组织为一个连续的已分配块和空闲块的序列,这种结构就叫做隐式空闲链表。隐式空闲链表不是通过指针(next)来链接起来,而是通过头部的长度隐含地链接起来。链表的结束块,设置了已分配且大小为零的终止头部。该结构有如下优缺点:

  • 优点:简单
  • 缺点:任何操作都需要比较大的开销,如放置分配的块,要对空闲链表进行搜索,该搜索所需时间与已分配块和空闲块的总数呈线性关系O(N)
    在这里插入图片描述

系统对齐要求和分配器对块的格式选择,会对分配器最小块的大小有强制要求。如系统双字(8字节)对齐,即使申请1个字节,也会分配2个字的块。

9.6 放置已分配的块

当应用请求一个k字节的块时,分配器会搜索空闲链表,查找一个大小可以放置所请求块的空闲块。分配器使用的这种搜索方式由放置策略确定,常见的策略有首次适配(first fit)、下一次适配(next fit)和最佳适配(best fit)。

  • 首次适配:从空闲链表的头部开始搜索,选择第一个合适的块。
  • 下一次适配:从上一次查询结束的地方开始,选择第一个合适的块。
  • 最佳适配:检查所有块,选择适合所需请求大小的最小空闲块。

9.7 分割空闲块

当分配器找到一个空闲块时,就需要决策分配这个空闲块中的多少空间,两种选择:一是选择用整个空闲块,但是会造成内部碎片;另一个是将空闲块分割成两部分,一部分满足分配请求,另一部分变成一个小的新空闲块。

9.8 合并空闲块

当分配器释放一个已分配的块时,可能有其他的空闲块与新释放的空闲块相邻,这些邻接的空闲块可能会引起假碎片,就是有许多可用的空闲块合起来可用,但是被分割了之后每个空闲块都无法使用。
在这里插入图片描述
为了解决假碎片问题,分配器需要合并相邻空闲块,这个过程称为合并。合并的策略有两种,立即合并和推迟合并。

  • 立即合并:每次一个块被释放,就合并所有的相邻空闲块。简单,可以在常数时间内完成,但某些情况下,如果反复申请和释放,就会反复的合并,再分割。
  • 推迟合并:等到某个稍晚的时间再合并空闲块,如每当分配请求失败时,分配器扫描整个堆,再合并所有空闲块。

9.9 带边界标记的合并

9.10 实现一个简单分配器

9.11 显式空闲链表

9.12 分离的空闲链表

十、垃圾收集

显示分配器要求应用程序显示地调用free函数来释放已分配块,比如以下代码中在garbage函数中调用了malloc函数来分配块,但是函数返回时并没进行释放,使得p指向的分配块始终保持已分配的状态,则分配器就无权对该分配块进行操作,由于p保存在函数garbage的栈帧中,当garbage返回时也丢失了p,所以这个已分配块就变成了垃圾,无法被使用,直到程序终止。

void garbage(){
    
    
  int *p = (int *)malloc(1024);
  return;
} 

隐式分配器也叫垃圾收集器,是一种动态内存分配器,会自动释放程序不再使用的已分配块,这些块被称为垃圾。在支持垃圾收集的系统中,应用只需要显式分配堆块,但是从不显式释放他们,如Java,Perl。

10.1 垃圾收集的基本知识

垃圾收集器将内存视为一个有向可达图(Reachability Graph),包含一组根节点和一组堆节点,有向边p -> q表示块p中的某个位置指向块q中的某个位置,说明p需要q的存在。

  • 根节点:对应于不在堆中的内存位置,可以是寄存器、栈中变量或全局变量等等。int *p = (int *)malloc(1024);,p是栈中的指针变量,对应一个根节点,它指向malloc出来的堆内存。
  • 堆节点:对应于堆中的一个已分配块。
    在这里插入图片描述

当存在一条任意从根结点出发到达p的有向路径时,我们说p可达的,不可达结点对应于垃圾。我们可以从根节点出发找到所有可达的节点,则剩下的不可达的节点就是垃圾,因为不存在使用这些不可达节点的入口,应用程序无法再次访问这些不可达的已分配块。垃圾收集器就是在维护这样一个有向可达图,并释放不可达节点。

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

10.2 Mark & Sweep 垃圾收集器

Mark&Sweep垃圾收集器由标记(Mark)和清除(Sweep)两个阶段组成:

  • 标记:标记出根节点的所有可达的和已分配的后继节点。为此,需要用块头部的低3位中1位来表示其是否可达的。
  • 清除:释放所有未标记的已分配块。

看下面伪代码:
在这里插入图片描述
标记阶段为每个根节点都调用一次mark函数。首先会判断指针p是否指向已分配的块,如果是则返回指向这个块的起始位置的指针b;然后判断b是否被标记,如果没有,则对其进行标记;获取b中不包含头部的以字为单位的长度,这样就能依次遍历b中每个字是否指向其他堆节点,再递归地进行标记。这是对图进行DFS。

清除阶段会调用一次sweep函数,它会堆中的每个块上反复循环。如果堆节点b是已标记的,则消除它的标记;如果是未标记的已分配堆节点,则将其释放,然后指向b的后继节点。
在这里插入图片描述

下图示例,由六个块组成的链表,根节点指向第4块,第4块包含指向第3、6块的指针,依次类推。可以看出1、3、4、6是可达的,2、5是不可达的,所以mark扫一遍后,2、5没有被标记,然后sweep扫一遍,2、5被清除,最后将2、5与其相邻的块合并。
在这里插入图片描述

10.3 C程序的保守 Mark & Sweep

C程序想要使用Mark&Sweep垃圾收集器,在实现isPtr函数时会遇到两个挑战:

  • 进入isPtr函数时,首先需要判断输入的p是否为指针,只有p为指针,才判断p是否指向某个已分配块的有效载荷。但是在C语言不会用类型信息来标记内存位置,如p对应的是一个int类型数据,但是C误以为是指针,而将p的值作为指针变量的值,此时指针变量又正好指向某个不可达的已分配块中,分配器会误以为该分配块时可达的,造成无法对该垃圾进行回收。这也是C程序的Mark&Sweep垃圾收集器必须是保守的原因。
  • 即使我们知道p是一个指针,也没有一个明显的方式判断p是否指向已分配块中某个具体的位置,比如块的起始位置。针对这个问题可以将已分配块的集合维护成一颗平衡二叉树,如下所示,保证左子树所有的块都放在较小的地址处,右子树所有的块都放在较大的地址处。此时输入一个指针p,从该树的根节点开始,根据块头部的块大小字段来判断指针是否指向该块,如果不是,根据地址大小可跳转到左子树或右子树进行查找。
    在这里插入图片描述

十一、C程序中常见的与内存有关的错误

11.1 间接引用坏指针

进程虚拟地址空间中存在许多无意义的地址,即这些虚拟地址没有映射到物理地址上面,如果间接引用一个指向这些无意义地址的指针,操作系统则会以段错误异常终止程序。虚拟内存的某些区域是只读的,如代码区,如果试图对其进行写操作,操作系统会以保护异常终止程序。

间接引用异常的典型示例:scanf函数使用错误!

int val;
scanf("%d", &val); //正确写法
scanf("%d", val); //错误写法

错误写法会把val中的内容当做一个地址,将值写入该位置。最好的结果是程序触发异常,立即结束;最坏的结果,val中的内容对应于虚拟内存的某个合法的读/写区域,于是覆盖了这块内存,这种错误非常难顶

11.2 读未初始化的内存

虽然bss内存位置(如未初始化的全局C变量)总是被加载器初始化为零,但是堆内存不会被初始化为零。如果想要有初始化为零的效果,可以使用calloc函数。
在这里插入图片描述

11.3 允许栈缓冲区溢出

3.10.3节

11.4 假设指针和与其所指对象大小相同

下面示例代码目的是,创建一个由n个指针组成的指针数组,数组的每个指针元素指向一个包含mint元素的数组,但是误将sizeof(int*)写成sizeof(int)

这段代码只有在intint*大小相同的机器上面运行OK,但是现在的机器一般都是int*占双字。所以第7、8行的循环会出现内存越界,我们可能也不会发现,从而导致程序出错。
在这里插入图片描述

11.5 错位错误

下面示例代码第7行,i的取值范围是[0, n],正确的应该是[0, n)
在这里插入图片描述

11.6 误解指针运算

指针的算数操作是以他们指向对象的大小为单位来进行的,如int *p; p++;,实际上p每次向后滑动4个字节;在下图中,p += sizeof(int);实际上p每次向后滑动16个字节,所以错误。
在这里插入图片描述

11.7 引用不存在的变量

val是一个创建在栈上面的局部变量,等到函数退栈,val的内存就会被释放,此时内存虽然存在,但是里面的值已经不是我们想要的值。待调用其他函数,val的内存中可能存放的是一个其他函数的栈帧的条目,读取修改的也是其他函数的内容。
在这里插入图片描述

11.8 引用不存在的变量

指针x所指的内存块已经被free,但是此时又在14行中引用了x,如果将free(x)放在循环语句的后面就ok。
在这里插入图片描述

11.9 引起内存泄漏

malloc出来的指针一定要对其free,不然这块内存就会在堆中堆积,直至堆中没有空间,造成内存泄漏。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_42570601/article/details/117790824
今日推荐