大海啊,你全是水。
骏马啊,你四条腿。
--------------题记
在程序设计过程中,我们往往需要借助于大量的存储来完成计算。由于受应用程序地址空间以及机器本身存储容量限制,静态申请内存应该尽量避免。而动态申请内存更加灵活,在一定程度上可以节省内存空间。
动态申请内存的方法主要有三种:
1. 栈分配:在函数内部定义的变量都是存储在栈上的。(static变量除外)。
2. 堆分配:一般是通过malloc申请(c++中的new 本质上也是从堆上分配内存)。
3. 地址映射:mmap()函数从当前的进程地址空间中申请内存块。
栈分配一般用于较小的存储申请,并且一旦退出函数,空间变自动被释放(其实是ESP寄存器变了)。而mmap通常用于较大内存块的分配,它也是进程间共享内存的有效方法。
在程序实现过程中,除非应用于特定场合且对效率有较高要求,我们一般都会采用glibc库中的malloc方法申请内存。了解malloc的实现机制可以使得我们更加有效地使用它。本文将从另外一个视角对其进行分析。
抛开现有的解决方案,只凭借我们自己的经验知识和逻辑思维,自己来设计一个这样的内存管理算法:
1. 在查找合适的内存块时,只应在空闲内存块中查找。因此需要设计数据结构来保存空闲内存块。链表是个不错的选择,在Linux内核中这样的思路也是比比皆是。
2. 此时我们还不知道当前块是否能够满足申请的需求,因此我们需要记录空闲块内存的大小。如果当前块大小大于申请大小,则能满足条件,我们就把这块空闲块拆成两块,一块用于满足申请,剩余块再插入到空闲块链表中。
3. 当释放空闲块时,我们直接将空闲块插入到空闲链表中。
程序的伪代码如下:
//----------------------------------------
struct Chuck
{
Chunk * next;
size_t size;
};
Chunk * header = ....(初始堆的地址);
void * malloc(size_t size)
{
Chunk * victim = header;
while(victim && victim->size < size)
{
victim = victim->next;
}
if (victim)
{
split(victim); //拆分当前空闲块,一个用于返回,另一个再插入到空闲链表。
return victim + 1;
}
else
return NULL;
}
void free(void * add)
{
Chunk * idle = ((Chunk *)add) - 1;
insert(idle); // 将释放的内存块插入到空闲链表中。
}
//----------------------------------------
哈哈,如此一来,我们的内存管理系统 MM1.0 就大功告成了。
MM1.0 中可能会存在哪些问题呢?
如果系统中请求的块具有相同的大小,那么MM1.0 完全可以满足用户的需求。但是在实际的应用中,这中情况是极少的,一般会混杂大小不同的内存请求。这就会产生两个问题:
1. 在链表中查找满足请求大小的空闲块可能花费大量的时间。
2. 当前程序只有分割,没有合并。如果程序运行时间较长,容易产生内存碎片。这时即使内存中有足够大的连续空闲内存,但是系统中记录的是零碎的空闲内存块,从而无法满足请求。
针对以上问题,我们应该如何加以改进呢?
1. 如何能够快速检索出具有特定大小的空闲内存块呢?
极端的方案是针对每一个块大小都建立一个空闲链表。如果要查找相应大小的空闲块,只要到相应的空闲链表中查找就好了。如果该大小的空闲链表为空,则到更大块对应的空闲链表中查找,直到满足条件未知。
2. 如何能够合并相邻的空闲内存块呢?
为了能够合并相邻内存块,有两个问题需要解决:
1. 如何能够检索到相邻内存块? 2. 如何知道相邻内存块是否空闲?
由于Chunk是连续分布的,并且Chunk描述符总是位于内存块之前,因此只要知道了前面内存块的大小即可知道前面块的Chunk描述符的地址了。--à 问题1解决。
每一个Chunk可以增加一个Flag来记录当前Chunk是否为空闲块。--à 问题2解决。
改进后的伪代码如下所示:
//----------------------------------------
struct Chuck
{
Chunk * next;
size_t size;
size_t preSize;
int flag;
};
Chunk * bin[*****]; // 针对每种大小都要建立一个空闲链表
void * malloc(size_t size)
{
for ( size_t start = size; ; start++)
{
Chunk * victim = bin[start];
if (victim)
{
erase(victim); //将当前空闲内存块从空闲链表中删除
return victim + 1;
}
}
}
void free(void * add)
{
Chunk * idle = ((Chunk *)add) - 1;
Chunk * pre = (Chunk * )((((char *)idle) + idle->preSize)) - 1;
Chunk * next = (Chunk * )((((char *)idle) + idle->size)) + 1;
if ( pre->flag & CHUNK_IDLE)
{
erase(pre);
idle = merge(pre, idle);
}
if ( next & CHUNK_IDLE)
{
erase(next);
idle = merge(idle, next);
}
insert(idle); //根据idle的大小将其插入到合适的空闲链表中
}
//----------------------------------------
哈哈,如此一来,我们的MM2.0 也已经OK了。
相对于MM1.0 它的功能更加强大:
1. 更快地检索空闲块。
2. 在一定程度上避免了内存碎片。
但是MM2.0 中也存在一定的问题,例如建立了太多的空闲链表等等。
这些问题我们又应该如何解决呢?