数据结构之动态内存管理机制
通过前面的学习,介绍很多具体的数据结构的存储以及遍历的方式,过程中只是很表面地介绍了数据的存储,而没有涉及到更底层的有关的存储空间的分配与回收,从本节开始将做更深入地介绍。
在使用早期的计算机上编写程序时,有关数据存储在什么位置等这样的问题都是需要程序员自己来给数据分配内存。而现在的高级语言,大大的减少了程序员的工作,不需要直接和存储空间打交道,程序在编译时由编译程序去合理地分配空间。
本章重点解决的问题是:
- 对于用户向系统提出的申请空间的请求,系统如何分配内存?
- 当用户不在使用之前申请的内存空间后,系统又如何回收?
这里的用户,不是普通意义上的用户,可能是一个普通的变量,一个应用程序,一个命令等等。只要是向系统发出内存申请的,都可以称之为用户。
占用块和空闲块
对于计算机中的内存来说,称已经分配给用户的的内存区统称为“占用块”;还未分配出去的内存区统称为“空闲块”或者“可利用空间块”。
系统的内存管理
对于初始状态下的内存来说,整个空间都是一个空闲块(在编译程序中称为“堆”)。但是随着不同的用户不断地提出存储请求,系统依次分配。
整个内存区就会分割成两个大部分:低地址区域会产生很多占用块;高地址区域还是空闲块。 如图 1 所示:
图 1 动态分配过程中的内存状态
但是当某些用户运行结束,所占用的内存区域就变成了空闲块,如图 2 所示:
图 2 动态分配过程中的内存变化
此时,就形成了占用块和空闲块犬牙交错的状态。当后续用户请求分配内存时,系统有两种分配方式:
- 系统继续利用高地址区域的连续空闲块分配给用户,不去理会之前分配给用户的内存区域的状态。直到分配无法进行,也就是高地址的空闲块不能满足用户的需求时,系统才会去回收之前的空闲块,重新组织继续分配;
- 当用户运行一结束,系统马上将其所占空间进行回收。当有新的用户请求分配内存时,系统遍历所有的空闲块,从中找出一个合适的空闲块分配给用户。
合适的空闲块指的是能够满足用户要求的空闲块,具体的查找方式有多种,后续会介绍。
可利用空间表
当采用第 2 种方式时,系统需要建立一张记录所有空闲块信息的表。表的形式有两种:目录表和链表。各自的结构如图 3 所示:
图 3 目录表和链表
目录表:表中每一行代表一个空闲块,由三部分组成:
- 初始地址:记录每个空闲块的起始地址。
- 空闲块大小:记录每个空闲块的内存大小。
- 使用情况:记录每个空闲块是否存储被占用的状态。
链表:表中每个结点代表一个空闲块,每个结点中需要记录空闲块的使用情况、大小和连接下一个空闲块的指针域。
由于链表中有指针的存在,所以结点中不需要记录各内存块的起始地址。
系统在不同的环境中运行,根据用户申请空间的不同,存储空闲块的可利用空间表有以下不同的结构:
- 如果每次用户请求的存储空间大小相同,对于此类系统中的内存来说,在用户运行初期就将整个内存存储块按照所需大小进行分割,然后通过链表链接。当用户申请空间时,从链表中摘除一个结点归其使用;用完后再链接到可利用空间表上。
- 每次如果用户申请的都是若干种大小规格的存储空间,针对这种情况可以建立若干个可利用空间表,每一个链表中的结点大小相同。当用户申请某一规格大小的存储空间时,就从对应的链表中摘除一个结点供其使用;用完后链接到相同规格大小的链表中。
- 用户申请的内存的大小不固定,所以造成系统分配的内存块的大小也不确定,回收时,链接到可利用空间表中每个结点的大小也各不一样。
第 2 种情况下容易面临的问题是:如果同用户申请空间大小相同的链表中没有结点时,就需要找结点更大的链表,从中取出一个结点,一部分给用户使用,剩余部分插入到相应大小的链表中;回收时,将释放的空闲块插入到大小相同的链表中去。如果没有比用户申请的内存空间相等甚至更大的结点时,就需要系统重新组织一些小的连续空间,然后给用户使用。
分配存储空间的方式
通常情况下系统中的可利用空间表是第 3 种情况。如图 3(C) 所示。由于链表中各结点的大小不一,在用户申请内存空间时,就需要从可利用空间表中找出一个合适的结点,有三种查找的方法:
- 首次拟合法:在可利用空间表中从头开始依次遍历,将找到的第一个内存不小于用户申请空间的结点分配给用户,剩余空间仍留在链表中;回收时只要将释放的空闲块插入在链表的表头即可。
- 最佳拟合法:和首次拟合法不同,最佳拟合法是选择一块内存空间不小于用户申请空间,但是却最接近的一个结点分配给用户。为了实现这个方法,首先要将链表中的各个结点按照存储空间的大小进行从小到大排序,由此,在遍历的过程中只需要找到第一块大于用户申请空间的结点即可进行分配;用户运行完成后,需要将空闲块根据其自身的大小插入到链表的相应位置。
- 最差拟合法:和最佳拟合法正好相反,该方法是在不小于用户申请空间的所有结点中,筛选出存储空间最大的结点,从该结点的内存空间中提取出相应的空间给用户使用。为了实现这一方法,可以在开始前先将可利用空间表中的结点按照存储空间大小从大到小进行排序,第一个结点自然就是最大的结点。回收空间时,同样将释放的空闲块插入到相应的位置上。
以上三种方法各有所长:
- 最佳拟合法由于每次分配相差不大的结点给用户使用,所以会生成很多存储空间特别小的结点,以至于根本无法使用,使用过程中,链表中的结点存储大小发生两极分化,大的很大,小的很小。该方法适用于申请内存大小范围较广的系统
- 最差拟合法,由于每次都是从存储空间最大的结点中分配给用户空间,所以链表中的结点大小不会起伏太大。依次适用于申请分配内存空间较窄的系统。
- 首次拟合法每次都是随机分配。在不清楚用户申请空间大小的情况下,使用该方法分配空间。
同时,三种方法中,最佳拟合法相比于其它两种方式,无论是分配过程还是回收过程,都需要遍历链表,所有最费时间。
空间分配与回收过程产生的问题
无论使用以上三种分配方式中的哪一种,最终内存空间都会成为一个一个特别小的内存空间,对于用户申请的空间的需求,单独拿出任何一个结点都不能够满足。
但是并不是说整个内存空间就不够用户使用。在这种情况下,就需要系统在回收的过程考虑将地址相邻的空闲块合并。
边界标识法管理动态内存
在使用边界标识法的系统管理内存时,可利用空间表中的结点的构成如图 1:
图 1 结构构成
每个结点中包含 3 个区域,head 域、foot 域 和 space 域:
- space 域表示为该内存块的大小,它的大小通过 head 域中的 size 值表示。
- head 域中包含有 4 部分:llink 和 rlink 分别表示指向当前内存块结点的直接前驱和直接后继。tag 值用于标记当前内存块的状态,是占用块(用 1 表示)还是空闲块(用 0 表示)。size 用于记录该内存块的存储大小。
- foot 域中包含有 2 部分:uplink 是指针域,用于指向内存块本身,通过 uplink 就可以获取该内存块所在内存的首地址。tag 同 head 域中的 tag 相同,都是记录内存块状态的。
注意:head 域和 foot 域在本节中都假设只占用当前存储块的 1 个存储单位的空间,对于该结点整个存储空间来说,可以忽略不计。也就是说,在可利用空间表中,知道下一个结点的首地址,该值减 1 就可以找到当前结点的 foot 域。
使用边界标识法的可利用空间表本身是双向循环链表,每个内存块结点都有指向前驱和后继结点的指针域。
所以,边界标识法管理的内存块结点的代码表示为:
typedef struct WORD{ union{ struct WORD *llink;//指向直接前驱 struct WORD *uplink;//指向结点本身 }; int tag;//标记域,0表示为空闲块;1表示为占用块 int size;//记录内存块的存储大小 struct WORD *rlink;//指向直接后继 OtherType other;//内存块可能包含的其它的部分 }WORD,head,foot,*Space;
通过以上介绍的结点结构构建的可利用空间表中,任何一个结点都可以作为该链表的头结点(用 pav 表示头结点),当头结点为 NULL 时,即可利用空间表为空,无法继续分配空间。
分配算法
当用户申请空间时,系统可以采用 3 种分配方法中的任何一种。但在不断地分配的过程中,会产生一些容量极小以至无法利用的空闲块,这些不断生成的小内存块就会减慢遍历分配的速度。
3 种分配方法分别为:首部拟合法、最佳拟合法和最差拟合法。
针对这种情况,解决的措施是:
- 选定一个常量 e,每次分配空间时,判断当前内存块向用户分配空间后,如果剩余部分的容量比 e 小,则将整个内存块全部分配给用户。
- 采用头部拟合法进行分配时,如果每次都从 pav 指向的结点开始遍历,在若干次后,会出现存储量小的结点密集地分布在 pav 结点附近的情况,严重影响遍历的时间。解决办法就是:在每次分配空间后,让 pav 指针指向该分配空间结点的后继结点,然后从新的 pav 指向的结点开始下一次的分配。
分配算法的具体实现代码为(采用首部拟合法)
Space AllocBoundTag(Space *pav,int n){ Space p,f; int e=10;//设定常亮 e 的值 //如果在遍历过程,当前空闲块的在存储容量比用户申请空间 n 值小,在该空闲块有右孩子的情况下直接跳过 for (p=(*pav); p&&p->size<n&&p->rlink!=(*pav); p=p->rlink); //跳出循环,首先排除p为空和p指向的空闲块容量小于 n 的情况 if (!p ||p->size<n) { return NULL; }else{ //指针f指向p空闲块的foot域 f=FootLoc(p); //调整pav指针的位置,为下次分配做准备 (*pav)=p->rlink; //如果该空闲块的存储大小比 n 大,比 n+e 小,负责第一种情况,将 p 指向的空闲块全部分配给用户 if (p->size-n <= e) { if ((*pav)==p) { pav=NULL; } else{ //全部分配用户,即从可利用空间表中删除 p 空闲块 (*pav)->llink=p->llink; p->llink->rlink=(*pav); } //同时调整head域和foot域中的tag值 p->tag=f->tag=1; } //否则,从p空闲块中拿出 大小为 n 的连续空间分配给用户,同时更新p剩余存储块中的信息。 else{ //更改分配块foot域的信息 f->tag=1; p->size-=n; //f指针指向剩余空闲块 p 的底部 f=FootLoc(p); f->tag=0; f->uplink=p; p=f+1;//p指向的是分配给用户的块的head域,也就是该块的首地址 p->tag=1; p->size=n; } return p; } }
回收算法
在用户活动完成,系统需要立即回收被用户占用的存储空间,以备新的用户使用。回收算法中需要解决的问题是:在若干次分配操作后,可利用空间块中会产生很多存储空间很小以致无法使用的空闲块。但是经过回收用户释放的空间后,可利用空间表中可能含有地址相邻的空闲块,回收算法需要将这些地址相邻的空闲块合并为大的空闲块供新的用户使用。
合并空闲块有 3 种情况:
- 该空闲块的左边有相邻的空闲块可以进行合并;
- 该空闲块的右边用相邻的空闲块可以进行合并;
- 该空闲块的左右两侧都有相邻的空闲块可以进行合并;
判断当前空闲块左右两侧是否为空闲块的方法是:对于当前空闲块 p ,p-1 就是相邻的低地址处的空闲块的 foot 域,如果 foot 域中的 tag 值为 0 ,表明其为空闲块; p+p->size 表示的是高地址处的块的 head 域,如果 head 域中的 tag 值为 0,表明其为空闲块。
如果当前空闲块的左右两侧都不是空闲块,而是占用块,此种情况下只需要将新的空闲块按照相应的规则(头部拟合法随意插入,其它两种方法在对应位置插入)插入到可利用空间表中即可。实现代码为:
p->tag=0; //f指针指向p空闲块的foot域 Space f=FootLoc(p); f->uplink=p; f->tag=0; //如果pav指针不存在,证明可利用空间表为空,此时设置p为头指针,并重新建立双向循环链表 if (!pav) { pav=p->llink=p->rlink=p; }else{ //否则,在p空闲块插入到pav指向的空闲块的左侧 Space q=pav->llink; p->rlink=pav; p->llink=q; q->rlink=pav->llink=p; pav=p; }
如果该空闲块的左侧相邻的块为空闲块,右侧为占用块,处理的方法是:只需要更改左侧空闲块中的 size 的大小,并重新设置左侧空闲块的 foot 域即可(如图 2)。
图 2 空闲块合并(当前块,左侧内存块)
实现代码为:
//常量 n 表示当前空闲块的存储大小 int n=p->size; Space s=(p-1)->uplink;//p-1 为当前块的左侧块的foot域,foot域中的uplink指向的就是左侧块的首地址,s指针代表的是当前块的左侧存储块 s->size+=n;//设置左侧存储块的存储容量 Space f=p+n-1;//f指针指向的是空闲块 p 的foot域 f->uplink=s;//这是foot域的uplink指针重新指向合并后的存储空间的首地址 f->tag=0;//设置foot域的tag标记为空闲块
数据结构之动态内存管理机制(中):