allocator的基本概念

在C++中所有STL容器的空间分配其实都是使用的std::allocator

std::allocator是可以感知类型的空间分配器,将空间的申请与对象的构建、以及空间的回收与对象的销毁严格分离。

以前我们知道使用new创建单个对象的时候,空间的申请与对象的构造实际也是分开的(可以回顾new表达式的工作步骤)。

那么为什么要将空间的申请与对象的构建分开呢?

  1. 减少不必要的对象构造和析构:在容器的使用过程中,有时只是需要预留一些空间,而并不需要立即在这些空间上构造对象。例如,std::vectorreserve 函数,它的作用是为容器预留足够的内存空间,但不会构造任何对象。如果不将空间申请和对象构建分开,每次预留空间时都会构造对象,之后又可能因为不需要这些对象而进行析构,这会带来不必要的性能开销。

  2. 方便实现复杂的容器操作:在实现容器的一些操作,如插入、删除元素时,将空间申请和对象构建分开可以更方便地处理各种情况。例如,在插入元素时,如果当前空间不足,容器可以先申请新的空间,然后将原有元素移动到新空间,最后在合适的位置构造新的对象,这样的操作更加灵活和高效。(比如vector的动态扩容机制背后就使用了空间配置器)

常用成员函数

底层实现

原理

空间配置器会分为两级:

一级空间配置器使用类模板malloc_alloc_template ,其底层使用的是malloc/free进行空间的申请与释放;

二级空间配置器使用类模板default_alloc_template(默认空间配置器),其底层根据申请空间大小又分为两个分支,第一分支是当申请的空间大于128字节的时候,还是走malloc_alloc_template ,当申请的空间小于128字节的使用,使用内存池 + 自由链表的方法申请空间。

注意:这里提到的自由链表实际是一个容量为16的指针数组,在源码中使用了_S_free_list这个名字,直译为自由链表

有一个容量为16的指针数组,每个元素(指针)指向一个链表 ,用于管理不同大小的内存块。这 16 个指针所指向的链表,分别对应 8、16、24、32、40、48、52、64、72、80、88、96、104、112、120、128 字节的内存块。

每当程序申请小于 128 字节的内存时,二级空间配置器会先将申请的内存大小上调为 8 的倍数,再根据上调后的大小(8的整数倍)找到对应的链表。

比如:

数组中下标为3的指针,代表着会按照8 * (3 + 1) = 32字节为基本单位申请空间。

第一次申请32个字节空间的时候,一次性申请很大一片空间(比如32 * 20 = 640个字节),然后按照32字节为一个等分,分成多个等分,然后挂接在下标为3的指针下面,形成链表形式。

以后需要32字节的时候,直接在下标为3的下面取出一个节点即可,就是32个字节的内存空间。


如果申请64字节空间,第一次申请时也申请很大一片空间,同样按照64字节为一个等分,分成多个等分,挂载在数组中下标为7的指针之下。

以后如果需要申请64字节时,就在下标为7的下面取出一个节点即可。

其他下标的处理方式完全一致。

自由链表的工作示意图如下,第一次申请空间时比较麻烦,后续再此申请同等大小的空间可以以O(1)的时间复杂度完成申请。

二级空间配置器原理

这么干有什么意义呢,为什么不每次直接申请相应大小的空间?

如果频繁申请小片空间,可能会出现内存碎片的问题,这样导致空间的利用率低;

会在内核态与用户态之间进行频繁的切换,时间消耗也比较大。

源码

由头文件<memory>找到<stl_alloc.h>

继续查看类型别名原本的定义

这就是一级空间配置器


回到typedef alloc _Alloc; 查找alloc时还有第二个分支

查看__default_alloc_template的定义(下面对源码进行了一定的简化)

也就是二级空间配置器

对于_S_free_list,最终其类型为_obj*[16]

对于_S_freelist_index(__n)

作用:根据申请内存的大小取其对应于指针数组(自由链表)中的下标

刚才的allocate函数中申请空间大小小于128的分支就可以这样来进行理解

_S_round_up函数

作用:这个函数实现的就是向上取整得到8的整数倍

_S_refill函数

作用:将调用_S_chunk_alloc所返回的640字节以32字节为一个等分,分成20等分,第一等分交给__result直接作为返回值,剩下的19个32字节会以链表的形式挂接在数组中下标为3的元素之下。

_S_chunk_alloc函数

再回过头来看allocate函数,如果是第二次申请32字节空间,逻辑是怎样的。

函数的调用关系图

allocate函数调用图

现在对自由链表已经有了初步的了解,那么内存池又是什么?

从名字上进行推理,线程池就是先创建一堆的线程备用,内存池应该也是先申请好一片空间备用。回到刚才的_S_chunk_alloc函数,这个函数是真正去分配空间的函数。刚才申请32字节空间时,根据源码逻辑是申请了1280个字节堆空间的,但只拿出了640个字节挂接在数组中下标为4的链表中。还有640个字节的空间干什么用?当然是备用的。

根据源码分析,如果接下来又要申请64字节空间会怎样。

自由链表分配

如果接着还要申请64字节(或者57-64范围内的大小),就再从数组中下标7位置对应的链表中再拿一个内存块出来,非常方便。

如果再要申请96个字节,操作方式跟第一次申请32字节一样,在数组下标11的位置下面挂载一些96字节的内存块。

假如接下来某一次要申请72字节,但是内存池和堆空间都没有连续的72字节了。想要使用相同的逻辑再申请空间不会成功,那么会往上借,也就是往数组下标9的位置去找,再没有就往下标10、下标11去找。下标11下面挂载着内存块,从中去除96字节的块,进行切割,获取72字节,剩24字节作为内存池中的内存。

自由链表分配2

自由链表分配3

目前为止,只是解决了allocate的问题,再来看看deallocate函数

如果是一级空间配置器,其背后就是调用free释放空间

如果是二级空间配置器,对应找到这个函数

通过deallocate的逻辑我们也能理解一件事情 —— 为什么刚才需要72字节空间时要往上借到96字节,而不是往下借(比如更近的2 * 64,借两块)

因为这些链表中的内存块节点并不一定是连续的。虽然刚开始申请时是连续空间,但是用了一些内存块,又还了一些内存块后,链表连接的两块内存块就不一定连续了。

自由链表分配4

再看看构建对象的函数和销毁对象的函数

总结函数的功能:

  1. _S_freelist_index函数的作用在自由链表中取下标

  2. _S_round_up函数的作用向上取整,得到8的整数倍

  3. allocate对外是申请空间的函数,但是底层会调用_S_refill,但是_S_refill也不会申请空间,该函数会调用_S_chunk_alloc函数。

  4. _S_chunk_alloc函数才是真正的申请空间的函数,并且该函数有可能会造成调用,会将其中的一部分返回给_S_refill,另外一部分会交给内存中的两个指针_S_start_free_S_end_free,以备下一次申请的时候使用

  5. _S_refill函数会将_S_chunk_alloc函数返回的空间进行切割,会切割成多个等分,并接在对应的自由链表的下面。