C++内存管理(二)

接着上一讲继续,这一讲讲的是C++标准库中的分配器,也就是std::allocator中得到内存管理,区别与上一讲C++Primer中1.1和1.2的管理版本,allocator在层次面上更高,主要体现在空间和时间上的内存管理效率得到了巨大的的改进。B站中有C++大师侯捷的讲解,若是对底层机制十分感兴趣可直接去看,老师讲解的很细致确实能让人收益匪浅。

第二讲std::allocator

在这里插入图片描述
这张图显示的是malloc得到的内存块结构(VC6版本),假设我们需要申请12个字节的内存大小,这时候系统实际分配给我们的内存块大小大于12字节(block size),如上图所示,还包含上下两个cookie共8个字节,一个debug header和debug tail共4*8+4=36个字节,这样再加上12共得到56字节,pad区域相当于一个填补区,因为所有的内存分配必须起始于可被 4、8 或 16 整除(视处理器体系结构而定)的地址,VC6中是16,所以pad区域的作用就是将分配的内存大小扩大到16的倍数。其中各个区域的作用和存在的意义会在第三讲详细讲大,现在我们只需要知道我们实际的到的内存大小和结构如上所示,通过指针指向12字节的首地址。上一讲讲到在内存管理中最主要的就是减少malloc调用次数和cookie所消耗的内存大小,这一讲则主要对付cookie。对于cookie我们可能会有疑问,既然我们想在内存管理中想方设法减少cookie的浪费,那为什么在设计的时候还要设计cookie呢?实际上,对于工业级程序,我们可能需要成千上万次轻量级内存,比如说我需要进行100万次内存分配,而这100万次分配的容器的参数如大小、类型等相同,那么如果对每次分配得到的内存都加cookie,那门就会产生一种巨大冗余。所以对于必要的cookie我们还是需要的,但对于冗余来说我们可以设计一种数据结构去删除不必要的cookie浪费,这也是这一讲的主要目的。

这次我们讨论的分配器位于GUNC2.9,实际上G2.9使用的标准库中的分配器(std::allocator)并没有对内存管理做任何设计,而做到内存管理的是一个外置版本,就是G2.9容器使用的分配器,打开源码我们可以发现对于容器设计的模板中第二个参数不是class Alloc=allocator,而是class Alloc=alloc即用的是std::alloc,这个alloc版本的设计者设计了一个pool_alloc来进行内存管理,设计手法十分巧妙,使内存分配高效运行,值得读者深深体会。

设计思路
在这里插入图片描述
上一讲中我们在每个类内重写operator new,为每个类分配一条内存链表进行内存分配,在G2.9std::alloc中,我们将所有链表收集成一个有16条链表的链表free_list[16],逻辑上可以类似看作数组,每个数组中放一个链表头指针。每条链表存储的区域大小从低位到高位递增,如#0负责8字节的链表,#1负责16字节的链表,#2负责24字节的链表,…,#15负责128字节的链表。这会有几个疑问,当我们所需要的内存大小大于128字节或者不是8的倍数时,该怎么办?首先,当所需要字节数大于128时,分配器便直接调用malloc分配内存,小于128字节时,由alloc分配不带有cookie的内存,这也符合轻量级内存管理的设计思路。若是分配的字节数不是8的整数,那我们则会向上取离8最近的一个倍数。

接下来就是大致的思路,假设我们有一种容器申请32字节的大小,那么分配器则将32/8-1=3,即#3号free_list元素拉出一个指针,申请32byte202的大小,这一块大小是带cookie的大内存。接着我们将前640字节切割20块用于供应程序员对32字节的申请,还有640字节的内存则放入一个战备池中。接着我们又有一种容器申请64字节的大小,这时分配器发现战备池中还有640字节容量,则不会去malloc一块带rookie的大内存,而是会拉出一块指针,将战备池中的640字节切割十块分配第一块给该容器,剩余九块用来以备以后对64字节的申请。这时候战备池容量为0,若下次再有其他类型的容器申请内存,则需要程序malloc内存。

在这里插入图片描述

还有一个很重要的概念,在上一讲中我们为了改善1.1版本中next指针的内存消耗借用一个union前四个字节来设置next指针。在该结构中,我们将该嵌入式指针放在每一块small block中,每一块small block前四个字节为next指针,将整个链表中空区域串联起来。在内存块被分配内出去之后用户只需要在该区域写数据便可覆盖指针数据,使该指针失效,再回收时系统又自动将嵌入式指针分配到该区域从而串联起整块free_list。

最后我们详细走一遍流程,体验其中的细节。
在这里插入图片描述
假设一种容器1需要申请32字节的内存,刚开始内存池为空,则需要malloc一大块内存,具体大小为32bytes202+RoundUp()=1280,其中RoundUp()为一个上调函数,这个函数具体实现是将目前申请总量去除以16即可,将其挂在32/8-1=3#中。其中前640bytes切割20块,第一块分配,剩余19块形成free_list,后640bytes作为战备池给其他类型容器使用。
在这里插入图片描述
这时候有一种容器2需要申请 64bytes,首先在64/8-1=7#链表节点处寻找是否有对应的free_list可分配,无则查看备战池,由于备战池有容量,则将其640bytes切割成十块,第一块分配,剩余9块形成free_list,并将其挂在7#链表结点处,此时战备池容量为0。
在这里插入图片描述
同理,若有容器3申请96bytes,首先在96/8-1=11#链表节点处寻找是否有对应的free_list可分配,无则查看备战池,由于战备池也为0,所以需要重新malloc一大块内存,其方法与容器1得到申请相同。
在这里插入图片描述
同理,若再有容器4申请88bytes,首先在88/8-1=10#链表节点处寻找是否有对应的free_list可分配,无则查看备战池,由于备战池有容量,则将其2000-88*20=240,即切割成20块,第一块分配,剩余19块形成free_list,并将其挂在10#链表结点处,此时战备池容量为240bytes。

在这里插入图片描述

接下来都是同理,现在我们讨论一些细节,比如下图。若某一时刻,战备池中容量只剩80bytes,这时候有一个容器5申请104bytes,首先104/8-1=12#链表结点处无free_list可分配,查看战备池发现只有80bytes容量,无法满足104bytes大小。这时系统会将战备池中的剩余内存放在80/8-1=9#链表结点处,使战备池为空,接着就和容器1操作一样,使用malloc分配一大块内存等等。

在这里插入图片描述

若某一时刻,战备池中容量只剩168bytes,这时候有一个容器6申请48bytes,首先48/8-1=5#链表结点处无free_list可分配,查看战备池发现只有168bytes容量,则会从168bytes中取三块48bytes小区域,第一块分配,剩余2块形成free_list,并将其挂在5#链表结点处,这时战备池剩余168-48*3=24bytes。这便是该结构中碎片处理手段

在这里插入图片描述

接下来讨论malloc失败的时候的回收技术,假设某一时刻战备池为0,累计申请内存量为9688而系统堆区总大小为10000。那么我有一个容器7要申请72bytes大小,首先72/8-1=8#链表结点处无free_list可分配,查看战备池也无容量可分配。按照常规我们需要malloc一大块内存,但是此时系统已无内存可分配,导致malloc失败。但是纵观上图,我们手上还有很快free_list等待去分配,那我们就想是不是可以利用手头尚未分配的free_list分配给容器7需要的内存呢?我们的思路是将与之最接近的非空链表节点的free_list对其进行回填。比如我们需要72bytes,72/8-1=8#处无free_list,那么就从9#开始找有没有free_list,观察上上图(上图的上一张图片)我们发现9#处有一块small block(大小为80bytes)等待被分配,所以我们将其中的72bytes分给8#供其分配,剩余8btyes放入战备池中进行回填。

有了设计思路和图解,最终实现还是要落实到代码处,具体如下图。

std::alloc(G2.9)

alloc(G2.9)中分配器分为两级,上述所有的讨论其实都位于第二级分配器。第一级分配器主要作用就是在第二级alloc分配失败后会启用,用第一级分配器进行malloc,上一讲中的_callnewh()也就是set_new_handler()就是在这里实现。我们不需要具体了解,直接切入第二季分配器。

在这里插入图片描述
首先我们发现在类前设计了3个枚举类型,其实也就是常量设计。我们上面讲过将所有链表收集成一个有16条链表的链表free_list[16]。每条链表存储的区域大小从低位到高位递增,如#0负责8字节的链表,#1负责16字节的链表,#2负责24字节的链表,…,#15负责128字节的链表。这里三个常量就是small blocks的下限、上限和类型数。接着我们看源码,从类的数据成员看起,一个联合体obj就是个嵌入式指针,free_list[_NFREELISTS]存放16个链表指针的数组,start_free、end_free和heap_size就是战备池的起始、末尾指针和资源累计申请总量。再看成员函数,ROUND_UP()就是上调函数,FREELIST_INDEX()是将申请的内存调整为8的倍数,最后便是两个关键函数refill()和chunk_alloc()。前者实现free_list的分配,后者实现大内存分配。

首先看alloccate()和deallocate()源码
在这里插入图片描述
allocate中首先比较需要申请的字节数是否大于链表中small blocks的上界128字节,若是超过,则直接调用第一级分配器用malloc分配内存。接着就是判断申请的字节数位于哪一条链表中,my_free_list就是计算出指定链表节点所在的数组并返回其首节点的地址,并赋给result指针。若result为空说明该链表节点的free_list为空,则需要用refill进行内存分配,否则只需要移动嵌入式指针使其指向下一个空的small blocks。
在这里插入图片描述

deallocate中首先同理比较需要回收的区域p字节数是否大于链表中small blocks的上界128字节,接着就是判断回收的字节数位于哪一个链表节点所在数组中,再将回收区域p设置嵌入式指针使其指向链表头节点的free_list_link(next)指针,就是该条链表的第一块small block区域,接着再将链表头节点free_list_link(next)指针指向p,完成回收。
在这里插入图片描述
我们再看refill()源码
在这里插入图片描述
首先假设我们能取20块区域,并将其交给chunk_alloc函数进行获取大内存。因为nobjs是引用传值,所以当nobjs==1说明只获取到一块且大小正好为n的内存,那么这时候就不需要切割,直接分配个申请者即可。否则,我们需要切割大内存使其按照我们要求分为n块small blocks。首先观察#260,我们看到指针my_free_list指向第二个small blocks的起始地址,这是因为第一块small blocks需要被分配出去所以我们直接从第二块开始循环,循环中我们看#263,next_obj指针首先转换成字节然后加上一个small blocks字节数使其指向下一个small blocks的起始地址,再转换成obj类型指针赋给next_obj指针实现一个切割指针的移动。在循环中我们一直再给各个small blocks的头部赋值嵌入式指针,使其指向下一个small blocks的起始地址直到最后一个赋值NULL然后退出。

最后我们看最关键的chunk_alloc()函数
在这里插入图片描述
首先看局部变量,total_bytes代表的是要求的内存大小,即20*small block的大小,nobjs初始值为20。bytes_left是剩下的内存大小,即战备池大小,最初始值为malloc得到的大内存大小。首先我们比较bytes_left和total_bytes,若大于则说明该块大内存不仅可以切割20块small blocks,多余的内存还能放入战备池中,此时只需要result让其指向内存起始地址并修改bytes_left大小。若bytes_left大于一则说明申请得到的大内存无法分割出战备池,且只能切割出20块以下的small blocks,这时我们需要确认能切几块,然后再修改bytes_left大小。其余情况则说明bytes_left大小为碎片,连一块small blocks都无法满足,这时我们需要去处理碎片,让其挂在特定的链表节点数组上,这时战备池清空为0,可以为malloc一块大内存做好充足准备。
在这里插入图片描述
首先malloc一块大内存,如果分配成功,我们只需要修改参数,然后申请的内存地址。0==start_free说明分配不成功,那我们就需要利用已有的空链表是对需要的内存进行分配。思想上面有讲过,我们直接看代码,for循环从size所属于的链表节点开始,每次加8直到128去寻找那块区域有free_list能给我当前申请的内存进行分配。若找到,则用指针p指向入口地址然后通过修改嵌入式指针去分配内存和修改free_list的start_free和end_free地址并返回分配的内存。

以上便是G2.9版本alloc对内存管理的主要思想和代码,主要函数不多,数据结构也很简单,但其中的设计思想却值得我们仔细思考。

猜你喜欢

转载自blog.csdn.net/GGGGG1233/article/details/115026839
今日推荐