虚拟内存(下)
九、动态内存分配
分配器将堆看做是一组大小不同的块(block)的集合,每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。
- 显示分配器:要求应用显示释放已分配的内存块。如
C
中的malloc
函数。 - 隐式分配器:也叫垃圾收集器,要求分配器自己检测已分配的块何时不再被应用使用,然后将其释放。如
Java
语言。
9.1 malloc 和 free 函数
malloc
函数返回一个指针,指向大小至少为size
字节的内存块,这个块会为可能包含在块内的任何数据对象类型做对齐,malloc
不会初始化返回的内存。
实际上,对齐依赖于编译代码在32
位模式还是64
位模式,32
位返回大小为8
的倍数,64
位返回大小为16
的倍数。
calloc
函数除了具有malloc
的功能外,还会将分配的内存初始化为零。
realloc
函数可以改变一个已经被分配的块的大小。
free
函数释放已分配的块,释放的指针必须指向一个从malloc
、calloc
、realloc
获得的已分配块的起始位置。
假设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 实现问题
可以想象出的最简单的分配器会把堆组织成一个大的字节数组,还有一个指针p
,初始指向这个数组的第一个字节。为了分配size
个字节, malloc
将p
的当前值保存在栈里,将p
增加size
,并将p
的旧值返回到调用函数。free
只是简单地返回到调用函数,而不做其他任何事情。
这个简单的分配器是设计中的一种极端情况。因为每个malloc
和free
只执行很少量的指令,吞吐率会极好。然而,因为分配器从不重复使用任何块,内存利用率将极差。一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题:
- 空闲块组织:我们如何记录空闲块?
- 放置:我们如何选择一个合适的空闲块来放置一个新分配的块?
- 分割:在将一个新分配的块放置到某个空闲块之后,我们如何处理这个空闲块中的剩余部分?
- 合并:我们如何处理一个刚刚被释放的块?
9.6 隐式空间链表
一个堆块由一个字的头部,有效载荷,以及可能的填充组成。如果分配器的对齐约束是双字(8
字节),则块的大小是8
的倍数,最低3
位总是零,因此可以利用低3
位来编码其他信息。如大小为24
(0x18
)字节的块头部:0x00000018 | 0x1 = 0x00000019
- 头部:编码了这个块的大小(包括头部、有效载荷和填充),以及这个块是否分配。
- 有效载荷:调用
malloc
时请求的字节数。 - 填充:用来对付内部碎片或者满足对齐要求。
将堆组织为一个连续的已分配块和空闲块的序列,这种结构就叫做隐式空闲链表。隐式空闲链表不是通过指针(next)来链接起来,而是通过头部的长度隐含地链接起来。链表的结束块,设置了已分配且大小为零的终止头部。该结构有如下优缺点:
- 优点:简单
- 缺点:任何操作都需要比较大的开销,如放置分配的块,要对空闲链表进行搜索,该搜索所需时间与已分配块和空闲块的总数呈线性关系
O(N)
。
系统对齐要求和分配器对块的格式选择,会对分配器最小块的大小有强制要求。如系统双字(8
字节)对齐,即使申请1
个字节,也会分配2
个字的块。
9.7 放置已分配的块
当应用请求一个k
字节的块时,分配器会搜索空闲链表,查找一个大小可以放置所请求块的空闲块。分配器使用的这种搜索方式由放置策略确定,常见的策略有首次适配(first fit)、下一次适配(next fit)和最佳适配(best fit)。
- 首次适配:从空闲链表的头部开始搜索,选择第一个合适的块。
- 下一次适配:从上一次查询结束的地方开始,选择第一个合适的块。
- 最佳适配:检查所有块,选择适合所需请求大小的最小空闲块。
9.8 分割空闲块
当分配器找到一个空闲块时,就需要决策分配这个空闲块中的多少空间,两种选择:一是选择用整个空闲块,但是会造成内部碎片;另一个是将空闲块分割成两部分,一部分满足分配请求,另一部分变成一个小的新空闲块。
下图展示了分配器分割图9-36
中8
个字的空闲块,来满足一个3
字的分配请求。
9.9 获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块将发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会通过调用sbrk
函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
9.10 合并空闲块
当分配器释放一个已分配的块时,可能有其他的空闲块与新释放的空闲块相邻,这些邻接的空闲块可能会引起假碎片,就是有许多可用的空闲块合起来可用,但是被分割了之后每个空闲块都无法使用。
下图展示了释放图9-37
中分配的3
个字的块后的结果,需要与后面的4个字的块合并,合并后效果与图9-36
一样。
为了解决假碎片问题,分配器需要合并相邻空闲块,这个过程称为合并。合并的策略有两种,立即合并和推迟合并。
- 立即合并:每次一个块被释放,就合并所有的相邻空闲块。简单,可以在常数时间内完成,但某些情况下,如果反复申请和释放,就会反复的合并,再分割。
- 推迟合并:等到某个稍晚的时间再合并空闲块,如每当分配请求失败时,分配器扫描整个堆,再合并所有空闲块。
9.11 带边界标记的合并
9.12 实现一个简单分配器
9.13 显式空闲链表
9.14 分离的空闲链表
十、垃圾收集
显示分配器要求应用程序显示地调用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
个指针组成的指针数组,数组的每个指针元素指向一个包含m
个int
元素的数组,但是误将sizeof(int*)
写成sizeof(int)
。
这段代码只有在int
和int*
大小相同的机器上面运行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 引用已经free的堆中数据
指针x
所指的内存块已经被free
,但是此时又在14
行中引用了x
,如果将free(x)
放在循环语句的后面就ok。
11.9 引起内存泄漏
malloc
出来的指针一定要对其free
,不然这块内存就会在堆中堆积,直至堆中没有空间,造成内存泄漏。