文章首发于:My Blog 欢迎大佬们前来逛逛
1. SGI空间配置器
SGI STL的空间配置器是 alloc而非allocator,并且不接受任何参数:
1 | vector<int,std::alloc> vec |
我们通常使用缺省的空间配置器:
1 | template <typename T,typename Alloc=alloc>//指定其默认空间配置器为 alloc |
1.1 std::allocator
SGI的 allocator空间配置器 只是对 new 和delete 做了一层包装,效率及其不佳,不建议使用它
1 | template <class T> |
allocate:包装了 new
deallocate:包装了 delete
因此 SGI的 allocator 是基层内存配置释放的行为,只有包装,没有效率的优势
1.2 std::alloc
SGI 重要的空间配置器:std::alloc
我们常写的c++语法:
1 | class foo{}; |
使用new创建对象与使用delete销毁对象。
使用new时的完整操作:
- 首先调用 ::operator new配置内存。
- 然后调用 构造函数 初始化对象。
同理delete的完整操作:
- 首先调用 析构函数 销毁对象。
- 然后调用 ::operator delete 释放内存。
因此alloc把new和delete 以及他们各自的两部分分离开:
- alloc:allocate:配置内存
- alloc:deallocate:释放内存
- ::construct:构造对象
- ::destroy:销毁对象
他们分别存在于这两个头文件中
1 | #include <stl_alloc.h> //对象的配置与释放 |
1.3 对象构造与析构:construct和destroy
来看 < stl_construct> 头文件中:
1 |
最基本的两大函数:
1 | template <class T> |
destroy直接负责某个对象的析构,即 ~T() 操作。
construct直接负责某个对象的创建,但是请注意这个new 是一个 placement new操作
形如: new (ptr) T(value) 是一个 placement new操作
括号里的参数ptr是一个指针,它指向一个内存缓冲器,placement new将在这个缓冲器上分配一个对象。
Placement new的返回值是这个被构造对象的地址(比如括号中的传递参数)。
placement new主要适用于:在对时间要求非常高的应用程序中,因为这些程序分配的时间是确定的;长时间运行而不被打断的程序;以及执行一个垃圾收集器 (garbage collector)。比如:
int n=2;
new (&n) int(10);
std::cout<<n; // n = 10
其中这两个函数还具有其他的版本:
1 | //接受两个迭代器,并设法找出元素的类型 |
destroy的第二个版本:接受两个迭代器,然后 value_type会自动推导出 此迭代器的所指的元素的类型
value_type 属于__type_traits的成员,会自动推导出迭代器所指元素的类型,在往后的 第二章 我们会学到
然后根据推导出的第三个参数的类型选择 适当的 __destroy函数。
1 | template <class ForwardIterator, class T> |
思考一下:destroy有两个版本,第一个版本是一个对象的析构;第二个版本是 [first,last]范围 的析构
对于整个范围的析构:如果我们不知道这个范围有多大,则无论它是什么类型,我们都需要对范围的每一个元素执行析构函数,对于自定义类型:student,xxx等 具有我们显式定义的析构函数,我们必须这样做。
但是对于 int,char,long等内置类型,我们有必要对他们每次都调用析构函数(系统自带的)???
如果我们对 这些内置类型 进行一次又一次的析构,则是效率的巨大牺牲,因此有没有一种办法可以推断出即将析构的是内置类型还是自定义类型;
- 内置类型 _true_type类型 :直接什么也不做即可。
- 非内置类型 _false_type 类型:执行每个元素的析构函数。
因此上面的destroy执行的就是这样的操作。
对于判断它是什么类型的 value_type 的实现方法,我们在 迭代器一节 会给出方法。
如果是 内置类型 即_true_type类型:直接跳过即可
1 | //无需析构 |
如果是 非内置类型 即_false_type 类型:执行析构函数
1 | //需要析构destory |
在false_type版本中执行的这个destroy 就是destroy的第一个版本,即直接调用
1 | pointer->~T(); // 析构 |
1.4 空间配置与释放:allocate和deallocate
C++的内存配置与释放:new和delete
这两个函数相当于C的malloc和free。
因此SGI的空间配置与释放就是依据 malloc和free进行的
SGI 的双层级配置器:
- 第一级配置器用于 配置大小超过 128 字节的空间。
- 第二级配置器用于 配置大小小于 128 字节的空间。
第一级配置器:__malloc_alloc_template
1 | __malloc_alloc_tetemplate <int inst> //inst完全没用到 |
第二级配置器:__default_alloc_template
1 | template <bool threads, int inst> |
无论是第一级还是第二级,SGI提供了一个接口,使得配置器的接口得以抽象:
1 | template<class T, class Alloc> |
注意:这个接口非常重要!
成员函数allocate和deallocate是可以选择调用 第一级还是第二级配置器 的实现方法的。
几乎所有的SGI STL容器全部使用这个接口:像是 vector,list等全部使用这个接口来处理空间的配置。
1 | template <typename T,typename Alloc=alloc> |
deallocate函数 调用 data_allocator ,从而调用 deallocate函数,实际上就是 simple_alloc 的两个deallocate函数,然后再根据是第一级配置器还是第二级配置器选择哪个版本的 deallocate函数。


1.4.1 第一级空间配置器
我们从__malloc_alloc_template类 中 一层一层的看:
1 | static void * oom_malloc(size_t); |
oom:out of memmory 表示内存不足。
__malloc_alloc_oom_handler是一个函数指针,可以处理内存不足的情况
创建n个大小的空间,allocate会直接 malloc这个大小的空间,如果分派失败,则调用内存不足的处理方法
1 | static void * allocate(size_t n) |
释放某个对象的空间,直接使用 deallocate 的free
1 | static void deallocate(void *p, size_t /* n */) |
扩容,使用 包装的ralloc即可,同样会处理内存不足的处理情况
1 | static void * reallocate(void *p, size_t /* old_sz */, size_t new_sz) |
发生内存不足的错误时的函数指针的处理:
可以指定 f 为 你任意的处理错误的方法,然后赋予给 函数指针。
1 | //可以通过它指定你自己的out of memory 处理方法 |
当出现内存不足时的处理方法:
oom_malloc 和 oom_realloc 会尝试在例程中压缩出空间,如果挤出了一点内存则返回此内存,如果一点内存都挤不出来,则出发 失败的异常。
1 | template <int inst> |
1 |
失败直接退出
第一级空间配置器 利用 malloc free和ralloc实现出了对内存的分配与释放。
并且也实现了C++的 new handler机制
c++ 的new_handler机制: 当无法分配足够的内存时,在丢出std::bad_alloc之前,客端会调用一些指定的处理例程,称为 new_handler,然后尽全力帮你挤出内存,如果实在挤不出来则只能抛出异常。
但是 第一级空间配置器使用的是 malloc,而不是 new,所以无法实现 C++纯正的 set_new_handler机制,因此必须手动模拟一个 set_new_handler
正如上面的 oom_malloc 和 oom_ralloc 函数的实现
第一级空间配置器使用如下的别名:malloc_alloc
1 | //malloc_alloc 为第一级空间配置器 |
1.4.2 第二级空间配置器
__default_alloc_template 作为第二级配置器用来处理避免太多小额区块造成的运行负担。
区块越小,额外负担所占用的比例就越大,就越浪费
第二级配置器的做法:
- 如果区块大于 128 字节,则移交第一级配置器实现
- 如果区块小于 128 字节,则交由内存池管理,这种方法又叫做 次级配置,每次分配一大块内存,就会产生并且维护一个自由链表。
- 如果下次再有相同大小的区块需要分配,则直接从 free-list中取出
- 如果释放,则直接 回收 free-list 中的某一段
配置会自动将任何区块的内存需求量设置为 8 的倍数:30 -> 32
并且维护16块 free-list,每个块为8字节,每个块都是一个 free-list,free-list总大小即为128字节,每个free-list各自管理自己对应的 8个字节的小额区块
1 | /* |
使用 union 来节省内存空间
free-list 指向 还没有被分配的内存空间的地址,如果已经分配,则不再指向他们
预定义 区块及free-list的个数
1 | enum {__ALIGN = 8};//上调边界 |
此函数表示将区块的内存需求量都上升为 8 的倍数(每块总共有8个字节,表示成能分多少块)
1 | static size_t ROUND_UP(size_t bytes) { |
free-list的定义 : 是一个数组,数组的每一个元素都是一个指针。
根据区块大小,决定使用第几块 free-list
1 | static obj * __VOLATILE free_list[__NFREELISTS]; //链表:指针数组 |
数据成员:
1 | // Chunk allocation state. |
1.4.2.1 空间配置函数 allocate
allocate的执行过程:
- 如果需要分配的空间 n超过了128个字节的大小,则就转到第一级空间配置器的实现上。
- 然后再从16个 free-list中选择适当的一个。
- 如果free-list中有可用的区块,则直接拿来用;否则就把区块的大小上调至 8 的倍数,然后再refill重新填充 free-list。
然后调整 free-list 为选出第 n 个区块后的下一个free-list。
最后取出 result 指向的第 n 个区块的空间起始地址
1 | /* |
1.4.2.2 空间释放函数 deallocate
回收区块:
- 如果回收大于 128 个字节,则转到第一级空间配置器
- 寻找对应的 free-list区块
- 把 p 指向的某一个区块插入到free-list中:
- 首先q的next地址为free-list对应的下一个区块
- free-list的当前区块转为 q区块
- 其实就是完成了链表的中间插入。
1 | //释放空间 |
1.4.2.3 重新填充 refill
前面讲到从free-list中取出第 n 个区块的区块,万一free-list中已经没有了可以用的区块,则需要重新填充 free-list 自由链表
- 从内存池中取出nobjs个区块,作为free-list的新节点,nobjs是引用的方式传递的。
- 如果只获得了一个区块,则直接分配给调用者用,free-list无需新的区块,通过返回 chunk 给到allocate的返回值;否则准备调整free-list,纳入新的节点。
- 然后在 chunk 空间内建立 free-list
1 | /* |
1.4.2.4 内存池 chunk_alloc
- 我们的内存池一定会尝试拥有一个 大小为单个节点空间的 20倍的一个内存空间(所以才叫做内存池)
- 如果内存池的剩余空间大于这20倍的空间,则内存池容量充足,直接全部保存下来
- 如果内存池的剩余空间只能容纳小于20个,但是大于1个空间。则内存池会计算出最大的块数与最大能用的空间,把这剩余的空间全部保存下来
- 如果一个节点空间也容纳不了。则尝试从heap堆中取得空间,则需要从堆中申请 2倍的 20倍的节点的空间+堆的额外空间
- 最后调整堆空间,递归调用该函数,直到获取到了空间或者抛出异常为止。
1 | //内存池 |
1.4.2.5 操作举例
我们直接拿第二级空间配置器alloc来举一个例子
1 | alloc all; |
我们从最上面开始执行,它会如何操作呢?
首先执行:int b = (int)all.allocate(1);进入allocate函数:
要分配一个元素的空间,但是初始的时候肯定自由链表是NULL的,所以首先一定会直接进入 refill 函数里,填充free-list
进入 refill函数:refill函数作用:取出内存并且填充free-list空间
refill直接进入 memory内存池中 尝试取得内存空间,进入 chunk_alloc函数:
- 总的需要分配的大小为所需要的单个节点的20倍(单个节点的对齐为8),因此total_bytes=160,但是v第一次内存池的空间为0,所以会进入else里面从heap中尝试开辟空间。
- heap中需要开辟的空间为total_bytes的两倍再加上堆的额外空间,使用malloc分配内存,赋值给start_free
- 分配成功,分配给heap空间,调整start_free与end_free,然后进入递归。
- 再次回到 chunk_alloc 函数中,此时内存池的空间为 320,足够所需要的160,因此赋值result为当前可用空间的地址,返回result。
回到refill 中,chunk表示内存池的首地址。首先result表示地址空间的首地址,即取出一块8字节(内存对齐规则),表示我们将要取出的,然后再把剩下的19块填充到 自由链表 中
- 然后从第二块开始进行块的尾插入链表,我们之前取出的result直接返回即可。
- 回到 allocate函数里,返回的 r 就是我们的空间的首地址,返回即可。
然后我们准备销毁b的空间:
- 进入deallocate函数,寻找对应的free-list块为第0块
- 然后把q块头插入为自由链表的头,相当于完成了q所指的块空间的回收,自由链表又有了20个块。
然后我们打算对c分配空间:
- 进入allocate函数,发现自由链表存在空余的块空间,因此无需进入refill重新填充
- result获得当前能够使用的块空间的首地址,直接返回即可。
- 然后自由链表失去了这个块,因此跳过这个块,重新调整自由链表的指向。
同理我们销毁c的空间:直接进入deallocate函数,然后把c所指向的块回收,然后自由链表重新获得了此块的空间。
最后我们delete整个b,因为这个内存池是由b创建的,因此我们销毁delete b之后的,b所指向的空间就会被销毁,恰好b的空间为第一个自由链表的所有空间。
1.4.2.6 关于内存池的一些问题
STL内存配置器有没有内存泄漏?
看了源码后很多人疑惑为什么在该Allocator的实现里只有对内存池malloc的代码,没看到类似free这样释放内存的代码,甚至该Allocator类都没有析构函数,这样不是会存在内存泄漏吗?
其实不然。
对于由链表维护的内存,其内存的释放工作应该是上一层调用者负责,比如容器Vector在析构函数中就将其申请的所有capacity大小的内存释放。相反内存池的中的内存将会一直保留直到程序退出。有的同学可能会认为“这不就是内存泄漏吗?比如创建了一个Vector变量,到Vector析构了之后再内存中竟然有一块内存没有被系统回收,这不是memory leak吗”。其实不然:
- 申请的内存没有被及时释放 不等于 内存泄漏
在单线程中,由于该Allocator中记录内存池起始的指针是static静态类型,所以只要是你在同一个线程中,无论你创建多少个Allocator,记录内存池的变量都是同一个,换句话说,当下次再创建Vector时,还是使用上一次使用的那个。也就是说他的存在时有意义的,这也是cache或memory pool的意义所在!
- 该内存池不会疯狂野生长
这个内存池的空间其实是很小的,因为大于128Byte的内存申请都直接转调了malloc,从源码中也可以看出,内存池每次重新灌水的新容量是2*total_size + round_up(heap_size >> 4)。
内存池的存在是为了避免大量内存碎片的产生,代价是管理内存所需要多付出的时间和空间消耗。
以上就是内存池一种存在直至程序退出的原因。
1.5 内存基本操作函数
STL有五个非常重要的全局函数:
- 用于构造的construct函数
- 用于析构的destroy函数
- uninitialized_copy :copy
- uninitialized_fill:fill
- uninitialized_fill_n:fill_n
前两个已经在对象的构造与析构中讲过了
另外的三个函数将在本节讲解。
1.5.1 uninitialized_copy
uninitialized_copy函数能够使得内存的配置和对象的构造分离开。
1 | template <class InputIterator, class ForwardIterator> |
将 first到 last 迭代器范围的元素拷贝到 result所指向的位置。
即 该函数将 [first,last)范围内的元素 复制一份,并把这些 复制品放入 result的空间里。
针对输入范围的每一个迭代器 i ,construct 都会执行如下操作:
- new (ptr) T(val)
- constuct(&(result+(i-first)),\i),产生 *i 的复制品,放置于输出位置 result后的相对位置中
在实现任何一个容器的构造函数的时候,通常会有两个步骤:
- 配置内存空间的大小
- 使用 uninitialized_copy来在该内存区块上构造元素
uninitialized_copy 中调用的函数:
该函数 使用__type_traits 操作来推断出迭代器所指的元素类型是不是POD类型
- 如果是 POD __true_type:则拷贝时执行最简单的内存拷贝
- 如果是 非POD __false_type:则需要调用相应的构造函数来逐一拷贝
1 | template <class InputIterator, class ForwardIterator, class T> |
1 | template <class InputIterator, class ForwardIterator> |
1.5.2 uninitialized_fill
他的作用是为指定范围的元素赋予一个值:[first,last) 赋予初始值 val
与 uninitialized_copy类似,如果迭代器范围的每一个元素都指向一个未知的区域,那么会对迭代器范围的每一个元素都调用一次: construct(&*i,val),然后在这个位置上产生一个值val
同样使用value_type推断出迭代器所指元素的类型,然后传入__uninitialized_fill。
1 | template <class ForwardIterator, class T> |
在uninitialized_fill函数内部会使用 \type_traits推断类型是否为POD类型。
- 如果是POD类型,则直接采用最简单的fill 填充的方式(STL算法)
- 如果不是POD类型,则对每一个元素调用其构造函数。
使用 first!= last ,first++ 作为for循环的参数
1 | template <class ForwardIterator, class T> |
另外 fill 的实现非常简单:其实就是往迭代器的范围赋值而已
1 | template <class ForwardIterator, class T> |
1.5.3 uninitialized_fill_n
该函数接受三个参数,一个 first 表示起始位置;一个 n,表示操作的数量;一个val。
从first迭代器处开始,操作 n 个地址空间,把val赋值给这些位置。
其实与上面的uninitialized_fill是一样的,只不过上面表示范围,这个表示一个数量
1 | template <class ForwardIterator, class Size, class T> |
同理使用 value_type 推断出迭代器的元素类型,然后再判断其是否为POD类型;
- 如果是,则直接 fill_n 即可
- 如果不是,则需要调用其 构造函数
使用 n— ,当n=0时停止,这个for来作为循环。
注意:上面的三个函数都具有一个特性:commit or rallback
即要么对范围的操作全部成功(全部赋值成功),要么全部不成功
如果不成功,会 抛出异常,然后调用destroy,将全部的成功的元素析构掉,因为他已经失败了,不允许他一半成功,一半失败
1 | destroy(result, cur);//uninitialized_copy |
destroy 接受一个范围,然后就是我们上面所讨论的destroy的内容了
