动态内存分配
前面了解了虚拟内存的相关知识,这一节我们来看看动态内存分配的基本概念,相信这之后就知道诸如 malloc 和 new 这类方法是怎么做的了。
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟存储器的区域,但是mmap是静态的,是在编写代码就要确定好大小,而大多数C程序还是会在运行时需要额外虚拟存储器。这样就需要使用一种动态存储器分配器(dynamic memory allocator)。
程序员通过动态内存分配(例如 malloc)来让程序在运行时得到虚拟内存。动态内存分配器会管理一个虚拟内存区域,称为堆(heap)。
分配器以块为单位来维护堆,可以进行分配或释放。有两种类型的分配器:
- 显式分配器:应用分配并且回收空间(C 语言中的 malloc 和 free)
- 隐式分配器:应用只负责分配,但是不负责回收(Java 中的垃圾收集)
先来看看一个简单的使用 malloc 和 free 的例子
#include <stdio.h>
#include <stdlib.h>
void foo(int n) {
int i, *p;
/* Allocate a block of n ints */
p = (int *) malloc(n * sizeof(int));
if (p == NULL) {
perror("malloc");
exit(0);
}
/* Initialize allocated block */
for (i=0; i<n; i++)
p[i] = i;
/* Return allocated block to the heap */
free(p);
}
为了讲述方便,我们做如下假设:
- 内存地址按照字来编码
- 每个字的大小和整型一致
例如:
程序可以用任意的顺序发送 malloc 和 free 请求,free 请求必须作用与已被分配的 block。
分配器有如下的限制:
- 不能控制已分配块的数量和大小
- 必须立即响应 malloc 请求(不能缓存或者给请求重新排序)
- 必须在未分配的内存中分配
- 不同的块需要对齐(32 位中 8 byte,64 位中 16 byte)
- 只能操作和修改未分配的内存
- 不能移动已分配的块
性能指标
现在我们可以来看看如何去评测具体的分配算法了。假设给定一个 malloc 和 free 的请求的序列:
吞吐量是在单位时间内完成的请求数量。假设在 10 秒中之内进行了 5000 次 malloc 和 5000 次 free 调用,那么吞吐量是 1000 operations/second
另外一个目标是 Peak Memory Utilization,就是最大的内存利用率。
影响内存利用率的主要因素就是『内存碎片』,分为内部碎片和外部碎片两种。
内部碎片
内部碎片指的是对于给定的块,如果需要存储的数据(payload)小于块大小,就会因为对齐和维护堆所需的数据结构的缘故而出现无法利用的空间,例如:
内部碎片只依赖于上一个请求的具体模式,所以比较容易测量。
外部碎片
指的是内存中没有足够的连续空间,如下图所示,内存中有足够的空间,但是空间不连续,所以成为了碎片:
实现细节
我们已经知道了原理,现在就来看看怎么样能够实现一个高效的内存分配算法吧!在具体实现之前,需要考虑以下问题:
- 给定一个指针,我们如何知道需要释放多少内存?
- 如何记录未分配的块?
- 实际需要的空间比未分配的空间要小的时候,剩下的空间怎么办?
- 如果有多个区域满足条件,如何选择?
- 释放空间之后如何进行记录?
具体这部分书中提到了四种方法:
- 隐式空闲列表 Implicit List
- 显式空闲列表 Explicit List
- 分离的空闲列表 Segregated Free List
- 按照大小对块进行排序 Blocks Sorted by Size
因为涉及的细节比较多,建议是详读书本的对应章节(第二版和第三版均为第九章第九节),这里不再赘述(如果需要的话之后我在另起一篇做详细介绍)
这里提一点,就是如何确定哪部分空间合适,有三种方法:
- First Fit: 每次都从头进行搜索,找到第一个合适的块,线性查找
- Next Fit: 每次从上次搜索结束的位置继续搜索,速度较快,但可能会有更多碎片
- Best Fit: 每次遍历列表,找到最合适的块,碎片较少,但是速度最慢
垃圾回收
所谓垃圾回收,就是我们不再需要显式释放所申请内存空间了,例如:
void foo() {
int *p = malloc(128);
return; /* p block is now garbage*/
}
这种机制在许多动态语言中都有实现:Python, Ruby, Java, Perl, ML, Lisp, Mathematica。C 和 C++ 中也有类似的变种,但是需要注意的是,是不可能回收所有的垃圾的。
我们如何知道什么东西才是『垃圾』呢?简单!只要没有任何指针指向的地方,不管有没有用,因为都不可能被使用,当然可以直接清理掉啦。不过这其实是需要一些前提条件的:
- 我们可以知道哪里是指针,哪里不是指针
- 每个指针都指向 block 的开头
- 指针不能被隐藏(by coercing them to an int, and then back again)
相关的算法如下:
- Mark-and-sweep collection (McCarthy, 1960)
- Reference counting (Collins, 1960)
- Copying collection (Minsky, 1963)
- Generational Collectors(Lieberman and Hewitt, 1983)
内存陷阱
关于内存的使用需要注意避免以下问题:
- 解引用错误指针
- 读取未初始化的内存
- 覆盖内存
- 引用不存在的变量
- 多次释放同一个块
- 引用已释放的块
- 释放块失败
Dereferencing Bad Pointers
这是非常常见的例子,没有引用对应的地址,少了 &
int val;
...
scanf("%d", val);
Reading Uninitialized Memory
假设堆中的数据会自动初始化为 0,下面的代码就会出现奇怪的问题
/* return y = Ax */
int *matvec(int **A, int *x) {
int *y = malloc(N * sizeof(int));
int i, j;
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
y[i] += A[i][j] * x[j];
return y;
}
Overwriting Memory
这里有挺多问题,第一种是分配了错误的大小,下面的例子中,一开始不能用 sizeof(int),因为指针的长度不一定和 int 一样。
int **p;
p = malloc(N * sizeof(int));
for (i = 0; i < N; i++)
p[i] = malloc(M * sizeof(int));
第二个问题是超出了分配的空间,下面代码的 for 循环中,因为使用了 <=,会写入到其他位置
int **p;
p = malloc(N * sizeof (int *));
for (i = 0; i <= N; i++)
p[i] = malloc(M * sizeof(int));
第三种是因为没有检查字符串的长度,超出部分就写到其他地方去了(经典的缓冲区溢出攻击也是利用相同的机制)
char s[8];
int i;
gets(s); /* reads "123456789" from stdin */
第四种是没有正确理解指针的大小以及对应的操作,应该使用 sizeof(int *)
int *search(int *p, int val) {
while (*p && *p != null)
p += sizeof(int);
return p;
}
第五种是引用了指针,而不是其指向的对象,下面的例子中,*size-- 一句因为 – 的优先级比较高,所以实际上是对指针进行了操作,正确的应该是 (*size)–
int *BinheapDelete(int **binheap, int *size) {
int *packet;
packet = binheap[0];
binheap[0] = binheap[*size - 1];
*size--;
Heapify(binheap, *size, 0);
return (packet);
}
Referencing Nonexistent Variables
下面的情况中,没有注意到局部变量会在函数返回的时候失效(所以对应的指针也会无效),这是传引用和返回引用需要注意的,传值的话则不用担心
int *foo() {
int val;
return &val;
}
Freeing Blocks Multiple Times
这个不用多说,不能重复搞两次
x = malloc(N * sizeof(int));
// <manipulate x>
free(x);
y = malloc(M * sizeof(int));
// <manipulate y>
free(x);
Referencing Freed Blocks
同样是很明显的错误,不要犯
x = malloc(N * sizeof(int));
// <manipulate x>
free(x);
// ....
y = malloc(M * sizeof(int));
for (i = 0; i < M; i++)
y[i] = x[i]++;
Memory Leaks
用完没有释放,就是内存泄露啦
foo() {
int *x = malloc(N * sizeof(int));
// ...
return ;
}
或者只释放了数据结构的一部分:
struct list {
int val;
struct list *next;
};
foo() {
struct list *head = malloc(sizeof(struct list));
head->val = 0;
head->next = NULL;
//...
free(head);
return;
}
总结
有了前面的基础,简要介绍了动态内存分配的基本概念和管理动态内存分配的三种算法。最后提及了垃圾回收的基本原理和内存使用中常见的错误。