malloc原理
以前不理解malloc()实际上是被两样不同的东西迷惑了:用户层面的内存分配和内核层面的内存分配.在看资料的时候两个方面的内容交叉看,导致了混乱.
以下内容取自csapp(深入理解计算机系统)第十章第九节.最好去看这本书的内容,网上的许多讲解都是copy这本书上的内容但是又漏了或者错了一些地方.下面是我读了之后对malloc()的理解.
用户层面的堆上内存分配
malloc()层面,或者说成编译器层面,本身维护一个内存池.一开始时,用户已经拥有了一定大小的堆,当申请的内存大小能在这个内存池中被满足时,就从这个内存池里分配出去.
此时的内存分配策略有:
- 首次适应(first fit):空闲块按照地址递增的次序链接在一起,每次分配从头依次遍历,选择第一个满足要求的块
- 循环首次适应(next fit):空闲块按照地址递增的次序链接在一起,从上次分配后的位置遍历,选择第一个满足要求的块
- 最佳适应(best fit):空闲块按照空闲大小递增的次序链接在一起,每次分配从头依次遍历,选择第一个满足要求的块
- 最坏适应(worst fit):空闲块按照空闲大小递减的次序链接在一起,每次分配从头依次遍历,选择第一个满足要求的块
查找速度、释放速度、空闲区利用这三个方面:
- 查找(搜索)速度:最先适应算法最佳
- 释放(回收)速度:最先适应算法最佳
- 空闲区利用:最佳适应算法最佳
从搜索速度上看最先适应算法拥有最佳性能,回收过程最先适应算法也是最佳,最先适应算法的另一个优点是尽可能的利用了低地址空间从而保证高地址有较大的空闲区来放置要求内存较多的进程或作业。
最坏适应算法是基于不留下碎片空闲区出发,选择最大空闲区满足用户需求,按如上方法分配后的剩余部分仍能再分配。
内存池的组织方式:
- 隐式链表
- 显式链表
隐式链表
任何实际的分配器都需要一些数据结构来区分块边界,类似于元数据,并区分已分配块和空闲块.大多数分配器将这些信息嵌在块本身中,一个简单的方法是:

在这个格式中,低的三位作为属性位,最低位作为属性中的已分配/空闲标志.
每个块的大小一定是8字节对齐的(或者按照系统要求可以是其他大小对齐),即8的倍数,因此大小低三位一定是0,因此低三位可以作为属性位.
这种结构之所以叫做隐式链表,是因为块是通过头部中的大小字段隐含地连接在一起的.下一个块的位置可以根据当前块的地址,加上当前块的大小得到,而没有额外通过next指针指向下一个节点.这种方式要求:块与块一定是紧邻的,也就是需要连续的一片内存.
另外,这种结构也要求有最小块大小,即如果只申请一个字节的内存,而系统要求8字节对齐,头部是4字节那么实际上需要耗费8字节:4字节头部,1字节有效载荷,3字节填充.因此对于大量小块分配不友好,内存利用率不高.
隐式空闲链表的优点是简单,显著缺点是任何操作的开销,比如放置分配的块(找到将要分配的内存的位置),其搜索合适的空闲块的开销与总的块数量(已分配块的数量+空闲块的数量)呈线性关系(因为最坏情况需要遍历完所有块,从头遍历到尾,如果只有两个大的空闲块和两个已分配块,则只需要遍历4次,而如果总共有100个块,则需要遍历100次).
值得一提的是,mit6.828课程实验中的lab2实现的简单内存管理,不把内存的元数据储存在空闲内存本身,而是额外分配一个数组,数组中每个元素对应了一个页(4k)的内存块,以此来粗粒度管理内存.而这个数组本身所在的页s始终被标记为已分配.
/*
* Page descriptor structures, mapped at UPAGES.
* Read/write to the kernel, read-only to user programs.
*
* Each struct PageInfo stores metadata for one physical page.
* Is it NOT the physical page itself, but there is a one-to-one
* correspondence between physical pages and struct PageInfo's.
* You can map a struct PageInfo * to the corresponding physical address
* with page2pa() in kern/pmap.h.
*/
struct PageInfo {
// Next page on the free list.
struct PageInfo *pp_link;
// pp_ref is the count of pointers (usually in page table entries)
// to this page, for pages allocated using page_alloc.
// Pages allocated at boot time using pmap.c's
// boot_alloc do not have valid reference count fields.
uint16_t pp_ref;
};
每次malloc()会返回头部之后的有效载荷的指针,如果有对齐要求,会在有效载荷后填充几个字节,即多分配几个字节,使得下个块的起始地址满足对齐条件.在大多数情况下对齐是要求4/8/16字节,因此这个头部的块大小不会影响对齐.需要注意的是,这个对齐是对于真正返回的指针而言的,也就是说,块的头部地址并不对齐,而是有效载荷的首地址需要对齐.
free()函数可以根据这个头部信息来释放相应的内存.(这就是为什么free()可以知道你申请了多大的内存).这个时候如果你强行访问malloc()返回指针的前面几个字节的内容或后面填充字节的内容,编译器不会报错,操作系统也不会报错,因为它不会产生一般保护性异常(因为没有相关设施来检测这个异常,mmu可以检测页权限但是检测不到这个),但是运行过程中会出现意想不到的错误.
重复free()同一个内存出问题的原因也是这个,如果free()之后,这块内存没被改动,那么再free()之后,free()发现这个内存已经是空闲的了,要么它可以报错,要么当无事发生,这要看具体实现;如果第一次free()之后,这块内存被合并了,那么再次free()的时候会发现找不到这个头了,或者头里面内容有问题,这个时候也可以报错或者当作无事发生,也要看具体实现;如果第一次free()之后,这块内存被使用了,那么如果再去free(),如果free()发现有问题,要么报错,要么正常修改,但是修改后有可能发生意想不到的错误,这也要看具体实现了.
但是基本上重复free()的风险比内存泄漏大得多,所以基本是所有的实现都会把重复free的程序给终止掉.
放置分配的块
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的,可以放置所请求的块的空闲块.分配器执行这种搜索的方式是由放置策略确定的.这些策略有首次适配,循环首次适配,最佳适配等.
对于隐式链表来说,最佳适配要求搜索完全部块再确定被选中的块,而某些分离式空闲链表组织,把空闲块按照块大小递增的顺序连接,则不需要彻底搜索空闲块,它的首次适配即为最佳适配.但释放和合并的开销更大.
分割空闲块
当分配器找到匹配的空闲块之后,需要做另一个策略决定,那就是分配这个空闲空间中的多少空间.
一个选择是用整个空闲块,这十分快捷但是内碎片会更多,利用率更低.
另一个选择是把这个空闲块分割为两个部分,第一部分变为分配块,剩下的部分变成新的空闲块.
获取额外的堆内存
如果分配器在当前内存池中无法找到合适的空闲块,那么有两种选择.
- 第一个选择是通过合并那些相邻的空闲块来生成一些更大的空闲块.
- 如果第一个选择不行,那么分配器将向内核请求额外的堆内存,要么通过
mmap()函数,要么通过sbrk()函数.然后把得到的内存转换为一个足够大的空闲块,并把它插入链表中.
合并空闲块
当分配器释放一个已分配块时,有可能有其他空闲块与这个新释放的空闲块相邻.此时可以合并这些相邻的空闲块.
一个重要的策略抉择是何时合并这些空闲块.
- 立即合并:在每次释放一个块时就合并所有相邻的块.
- 推迟合并:等到某个稍晚的时候再合并空闲块,例如当某次分配请求失败时(即找不到足够大的空闲块时).
立即合并可以在常数时间内执行完成,因为一般不会产生连锁反应,只需要检查前一个块和后一个块就可以了,最多合并这三个块.但是在某些情况下,立即合并会产生一种形式的抖动:块会反复地合并,然后马上分割(比如先释放k字节的块,然后这个块后面有空闲的块,会产生合并,然后马上又申请k字节的块,很可能会又重新分配同一个位置,又要分割,如果这个释放分配交替反复,则会不必要的分割和合并,从而出现抖动).
现实中,快速的分配器通常会选择某种形式的推迟合并.
带边界标记的合并
在合并中有一个问题,如何知道前一个块的状态是空闲还是以分配,因为隐式链表相当于只有next指针没有last指针,因此如果想知道前一个块的状态就需要遍历整个隐式链表.
为了解决这个问题Knuth提出了一种聪明而通用的技术叫边界标记,以运行在常数的时间内对前一个块进行合并.
边界标记通过在每一个块的结尾添加一个头部的副本,称为脚部,来让下一个块得知自己的状态和起始位置(当前块脚部与下一个块的头部相连,下一个块通过指针运算就可以访问当前块的脚部了).

但是通过增加脚部很明显降低了内存使用率,增大了块的最小大小.
巧妙的是,有一种非常聪明的边界标记的优化方法,可以使得已分配块中不再需要脚部:
- 把前一个块的已分配/空闲状态储存在下一个块的头部多出来的低位中.
采用这种方式的启发想法是:只有前面的块是空闲的时候,才会需要他的脚部信息.因此可以只在空闲的块中设置脚部,而已分配的不需要设置.
通过这种方式,使得已分配的块不需要脚部,而空闲块中仍有脚部,但空闲块本来也是空闲的,不用白不用,对内存利用率没有影响.
即,当要合并当前块时,通过查看当前块的头部信息,判断前一个块是否空闲:
- 如果不空闲,则不用合并,也不需要前一个块的大小/地址信息,即脚部.
- 如果空闲,则前一个块会有脚部,也就可以得到前一个快的起始地址.
内核层面的堆内存分配
当通过malloc()申请的内存大小不能在malloc()维护的内存池中找到时,需要向内核申请新的内存映射以获得内存(写时拷贝/分配).此时需要通过系统调用brk()或者sbrk().当内核收到这两个系统调用的时候,在内核的内存池中寻找满足条件的内存块,通过页表映射到用户的虚拟地址空间中.(分配时机?)
内核的内存池分配策略也和上面相同.
伙伴系统.slab?

浙公网安备 33010602011771号