内存详解(分配和回收
《深入理解计算机系统》(P580)
从Code Segment到Stack的内存地址均位于用户空间中,其地址空间由低到高。其中:
-
Code Segment(代码段或Text Segment)中存放着程序的机器码和只读数据,可执行指令就是从这里取得的。如果可能,系统会安排相同程序的多个运行实体共享这些实例代码。这个段在内存中一般被标记为只读,任何对该区的写操作都会导致段错误(Segmentation Fault)。
-
Data Segment中存放已初始化的全局或静态变量。
-
BSS中存放未初始化的全局或静态变量。
-
Heap(堆),堆的大小并不固定,可动态扩张或缩减。其分配由malloc()、new()等这类实时内存分配函数来实现(brk函数也是从这里分配内存)。
-
Stack(栈),用来存储函数调用时的临时信息,如函数调用所传递的参数、函数的返回地址、函数的局部变量等。 在程序运行时由编译器在需要的时候分配,在不需要的时候自动清除。栈内存的申请和释放遵循LIFO(先进后出)。
-
在C中,凡是在任何代码块之外声明的变量总是存储于静态内存中,也就是不属于堆栈的内存,这类量称为静态(static)变量。——C和指针(p43)
-
目标文件、可执行程序及其他二进制文件以ELF格式存储在磁盘中,该文件有两个重要的段(section),即代码段和数据段。
内存申请
malloc()/free()和new/delete异同
为什么C++不淘汰malloc?
对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。 对于内部数据类型的“对象”没有构造与析构过程,对它们而言,malloc/free和new/delete是等价的。为什么C++不把malloc /free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。
malloc 的系统过程?
https://cloud.tencent.com/developer/article/1004428
https://cloud.tencent.com/developer/article/1004429
怎么避免内存碎片化?
-
内部碎片
主要是分配器分配模式,和实际内存大小之间的差异导致的。 -
外部碎片
因为空间内存的大小小于实际要分配的大小所产生的。
内存碎片化产生的原因是,小于128k的内存分配方式导致的,多个小内存被分配之后,只要堆顶指针没有释放,即使地地址空间已经 free,该段内存区域依然没有还给OS,所以产生碎片化。
我们写程序时,不能完全依赖 glibc 的 malloc 和 free 的实现。更好方式是建立属于进程的内存池,即一次分配 (malloc) 大块内存,小内存从内存池中获得,当进程结束或该块内存不可用时,一次释放 (free) ,可大大减少碎片的产生。
动态内存管理
图 1 动态分配过程中的内存状态
但是当某些用户运行结束,所占用的内存区域就变成了空闲块,如图 2 所示:
图 2 动态分配过程中的内存变化
- 系统内存的分配方式有两种:
- .不管内存前边是否存在内存块,继续向后(高地址内存)查找空闲块分配,直到没有空闲块可以再分配为止,此时系统才回收之前产生的空闲块,重新组织内存;
- 当某块内存被释放之后,立马进行回收操作,当有新的用户请求分配内存时,从空闲块中找出最合适的空闲块分配给用户;这里说的最合适的空闲块就原来已经分配过的块,但是已经被释放了,这些块中存在一个与正在请求的内存大小几乎一致;
第二种方式,需要简历所有空闲块的信息表,可以用目录表或者链表;
内存分配三种形式
根据用户请求内存分配的大小的不同,可以分为三种形式:
-
每次分配的内存大小相等。运行初期,可以预先将整个内存按照所需大小分配,然后建立链表关系。当用户申请内存的时候,直接从链表中取出空闲节点使用,用完之后在将节点连在链表上;
-
每次分配的固定数量/大小已知的内存块。【比如,10个10K,20个4M,20个100M】那么就建立3种链表,没中链表节点的大小固定,当用户申请对应大小的内存,就从对应的链表中取出节点使用,用完归还链表;【此时会出现一种情况,那就是用户请求的内存,没有对应的大小,这就需要选择比需要申请的内存大的节点,从这个节点中选取所需要的内存使用,这部分内存中剩余的部分插入到相对应的链表中。如果没有节点可以使用,那就需要重新组织内存】
-
每次分配的内存大小未知。
用户申请的内存的大小不固定,所以造成系统分配的内存块的大小也不确定,回收时,链接到可利用空间表中每个结点的大小也各不一样。
内存分配的方式
-
首次拟合:从头遍历可利用空间表,找出第一个不小于申请空间的的节点分配给用户,剩余空间仍保留在链表中;回收时将空闲块插入链表头;
-
最佳拟合:遍历链表找到最节点所申请空间的节点分配给用户,【为了实现这一点,需要将链表节点的存储从小到大排序,遍历链表找到第一个大于申请空间的节点即可】,回收的时候需要根据空闲块的大小插入链表合适的位置;
-
最差拟合:和最佳拟合相反,在不小于申请空间的所有节点中选择最大的节点,将该节点空间分配给用户,【为了实现这一点,需要将链表从大到小排序】回收空间与2相同;
评价
- 首次拟合每次都是随机分配,在不清楚用户申请空间大小的规律下,使用该方法;
- 最佳拟合,由于每次分配的空间都极其接近申请空间的大小,所以会导致存在很多极小的节点存在,这些节点经常可能无法使用。在真个过程中,会出现有节点的大小极大和极小两极分化。该方法使用与申请内存大小范围较广的系统;
- 最差拟合,由于每次都是分配存储空间最大的节点分配,所以节点大小不会起伏很大。使用与申请内存空间大小起伏较小的系统;
空间分配与回收所产生的问题
无论是那种分配方式,最终内存都会成为一个个特别小的空间,对于后来的用户需求,单独拿出来哪一个节点,都不能满足内存申请的需求。此时整体看起来内存是足够的,但是就是碎片化严重,而不能被用户使用,在这种情况下,就需要进行内存紧缩。
分配算法
就是上述的三种分配方法
系统可以采用 3 种分配方法中的任何一种。但在不断地分配的过程中,会产生一些容量极小以至无法利用的空闲块,这些不断生成的小内存块就会减慢遍历分配的速度。
解决这种遍历速度的方法,就是选择一个常量 e, 如果内存分配过程中,空闲块大小 - 申请空间 < e, 则将整个空间都分配给用户。
边界标识法
采用边界标识管理的内存块节点可以表示为
C++
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;
回收算法
什么是回收空闲内存块?在多次分配之后,内存中处出现很多很小而无法使用的空闲块,将这些无法使用的空闲块重新组织成较大的块,即是回收空闲块。
为什么回收内存呢?减少无法使用的空闲块的数量,提高内存使用率。
怎么回收呢?内存块被用户释放之后,如果可利用空间表中的空闲块地址是相邻的,回收算法就是将这些相邻的空闲块,合并为新的空闲块。
合并空闲块的方法三种:
-
当前空闲块左边有相邻的空闲块;
-
当前空闲块右边有相邻的空闲块;
-
当前空闲块左右两边都有空闲块;
判断当前空闲块左右两侧是否存在空闲块的方法是:对于当前空闲块p, p-1就是相邻的低地址的 foot 域,如果foot 结构体中的 tag 为0,表明其为空闲块;p+p->size 就是高地址块的 head 域,如果head 域内的 tag 为0,表明其为空闲块。
如果左右两侧都不是空闲块,则将当前空闲块插入可利用空间链表中。
伙伴系统动态管理
header 域表示为头部结点,由 4 部分构成:
- llink 和 rlink 为结点类型的指针域,分别用于指向直接前驱和直接后继结点。
- tag 值:用于标记内存块的状态,是占用块(用 1 表示)还是空闲块(用 0 表示)
- kval :记录该存储块的容量。由于系统中各存储块都是 2 的 m 幂次方,所以 kval 记录 m 的值。
C++
typedef struct WORD_b{
struct WORD_b *llink;//指向直接前驱
int tag;//记录该块是占用块还是空闲块
int kval;//记录该存储块容量大小为2的多少次幂
struct WORD_b *rlink;//指向直接后继
OtherType other;//记录结点的其它信息
}WORD_b,head;
分配算法
用户向系统申请大小为 n 的存储空间,若 2k-1 < n <= 2k,此时就需要查看可利用空间表中大小为 2k 的链表中有没有可利用的空间结点:
•如果该链表不为 NULL,可以直接采用头插法从头部取出一个结点,提供给用户使用;
•如果大小为 2k 的链表为 NULL,就需要依次查看比 2k 大的链表,找到后从链表中删除,截取相应大小的空间给用户使用,剩余的空间,根据大小插入到相应的链表中。
评价
- 由于伙伴系统中大小不同的空间快处于不同的链表中,所以遍历速度更快,分配速度就更快,算法相对简单;
- 缺点是伙伴系统对于空闲块的合并,不是取决于相邻位置是否是空闲块,而是取决于伙伴块,也就是说,即使是乡里块,但只要不是伙伴块,就不合并,所以内存碎片更容易产生。
垃圾回收机制
无用单元回收
什么是无用单元?简单来讲,无用单元是一块用户不再使用,但是系统无法回收的存储空间。例如在C语言中,用户可以通过 malloc 和 free 两个功能函数来动态申请和释放存储空间。当用户使用 malloc 申请的空间使用完成后,没有使用 free 函数进行释放,那么该空间就会成为无用单元。
悬挂访问也很好理解:假设使用 malloc 申请了一块存储空间,有多个指针同时指向这块空间,当其中一个指针完成使命后,私自将该存储空间使用 free 释放掉,导致其他指针处于悬空状态,如果释放掉的空间被再分配后,再通过之前的指针访问,就会造成错误。数据结构中称这种访问为悬挂访问。
解决上述问题的方法:
1.被申请内存增加一个计数域,有一个指针指向该快空间,就加1,当计数为0的时候,再释放该快内存;
2.在程序运行时,所有的存储空间无论是处于使用还是空闲的状态,一律不回收,当系统中的可利用空间表为空时,将程序中断,对当前不在使用状态的存储空间一律回收,全部链接成一个新的可利用空间表后,程序继续执行。
内存紧缩(避免内存碎片化)
存储紧缩有两种做法:其一是一旦用户释放所占空间就立即进行回收紧缩;另外一种是在程序执行过程中不立即回收用户释放的存储块,而是等到可利用空间不够分配或者堆指针指向了可利用存储区的最高地址时才进行存储紧缩。
具体的实现过程是:
1.计算占用块的新地址。设立两个指针随巡查向前移动,分别用于指示占用块在紧缩之前和之后的原地址和新地址。因此,在每个占用块的第一个存储单位中,除了存储该占用块的大小和标志域之外,还需要新增一个新地址域,用于存储占用块在紧缩后应有的新地址,即建立一张新、旧地址的对照表。
2.修改用户的初始变量表,保证在进行存储紧缩后,用户还能找到自己的占用块。
3.检查每个占用块中存储的数据。如果有指向其它存储块的指针,则需作相应修改。
4.将所有占用块迁移到新地址去,即进行数据的传递。
最后,还要将堆指针赋以新的值。
评价
- 存储紧缩较之无用单元收集更为复杂,是一个系统的操作,如果不是非不得已不建议使用。
- 内存紧缩防止内存碎片只是一部分作用,另一部分的作用是通过整理内存,将仍存活的且经常被使用的对象连续排布有利于缓存命中从而极大地提升运行性能。
声明
本文是个人学习和总结的笔记和感想,内容涉及网络资料、相关书籍摘录、个人总结和感悟。在这之中也必有疏漏未加标注者,如有侵权请联系删除。