【STL 源码剖析】系列二:空间配置器 allocator
前言
STL 的空间配置器,负责的主要工作是容器的空间管理,一般情况下空间都是指内存(存在将空间置于外存的配置器),所以也可称为内存管理器。
读完《STL 源码剖析》第二章内容,需要知道的是:
1.SGI 标准下将空间配置和构造析构分为两个阶段。空间配置是申请和释放内存;构造析构是对象的创建和销毁。
2.SGI 标准下的空间配置器 alloc 具有两级,第一级负责 128 字节以上的空间申请,第二级以内存池的形式管理。二者均以字节为单位管理内存,对外提供标准接口以元素为单位管理内存。
3. 对于未初始化的空间除了构造和析构的支持外,还提供复制、填充、以对象 x 填充的管理操作。
空间配置器 allocator


空间配置器的标准接口



设计一个简单的空间配置器:JJ::allocator
知识点 1:ptrdiff_t 是表示指针之间的差距的数据类型,可以是负数;size_t 是表示数组长度的类型,必须是正数。
知识点 2:**set_new_handler ()** 函数定义在头文件 new 中,当 operator new 函数申请分配内存失败时,会尝试调用错误处理函数,这个函数如果不存在就会抛出异常。使用 set_new_handler 函数可以指定这个 handler 函数。
知识点 3:placement new 指的是在用户指定的内存位置上申请空间。语法格式为:
1 | // address:placement new所指定的内存地址 |
知识点 4:作用域解析运算符::(1) 作用 1 是表示指定类中的成员或者命名空间中的成员,比如使用 A::member 表示类 A 中的成员;(2) 作用 2 是当前面没有类或者命名空间时,表示全局作用域符号,可以用于区分同名的全局和局部变量。
1 | int jmz = 2; //全局变量 |
自定义一个 allocator 类:
1 | #ifndef _JJALLOC_ |
使用 MinGW 无法通过编译,因为 SGI STL 的 allocator 并不完全符合 STL 规范,我们编写的符合规范的自然不能搭配使用。
具备层次配置能力的 SGI 空间配置器

SGI 标准的空间配置器 std::allocator
这个 allocator 部分符合 STL 标准,它在文件 defalloc.h 中实现。但是 SGI STL 的容器并不使用它,也不建议我们使用,它存在的意义仅在于为用户提供一个兼容老代码的折衷方法,其实现仅仅是对 new 和 delete 的简单包装因此效率不高。
SGI 特殊的空间配置器 std::alloc
C++ 内存配置操作和释放操作:
1 | class Foo {...}; |
new 运算符包括两给阶段的操作:(1)调用::operatro new 配置内存;(2)调用 Foo::Foo () 构造对象内容;
delete 运算符号包括两阶段的操作:(1)调用 Foo::~Foo () 将对象析构;(2)调用::operator delete 释放内存。



构造和析构的基本工具:construct () 和 destory ()
construct () 和 destory () 示意图:
下面是作者注释的 <stl_construct.h> 的部分内容,主要是为了说明 construct 和 destroy 的功能。(不同版本的 SGI STL 代码可能不同,但是设计思路是一致的。)


这两个构造和析构的函数被设计为全局函数,STL 规定配置器应该具有 construct 和 destroy 两个成员函数,但是实际上 SGI STL 中的 std::alloc 配置器并没有遵守这一规则。
关于上面的 construct 函数:接收一个指针 p 和一个初始值 value,改函数的作用就是将初始值 value 设置到指针所指的空间上。(使用 C++ placement new 完成)
关于上面的 destroy 函数:第一个版本接受一个指针,将指针所指的对象析构。第二个版本接收 first 和 last 两个迭代器,准备将 [first, last) 内的对象析构。考虑到这个范围可能很大,并且如果每个对象的析构函数都是 trivial destructor (即系统默认的析构函数),那么会降低效率。因此这里的方法是先利用 **vaule_type () 获得迭代器所指对象的类型,再利用__type_traits<T>** 判断这种类型的析构函数是否无关痛痒。如果是 (__true_type),那么什么都不做;如果否 (__false_type),以循环方式遍历每一个对象,并对于每一个对象调用第一个版本的 destroy ()。
空间配置和释放,std::alloc

为了解决小型区块可能造成的内存破碎的问题,SGI 设计采用了双层配置器,第一级配置器直接使用 malloc 和 free,第二级配置器当配置区块超过 128bytes 时,认为内存够大调用第一级配置器,否则认为过小,采用内存池(memory pool)的方式。第一级和第二级配置器的关系:

无论 alloc 被定为第一级还是第二级配置器,SGI 都为其增加一层包装为 simple_alloc。第一级配置器和第二级配置器的包装以及使用方式:

第一级配置器解析 malloc_alloc_template
第一级配置器以 malloc ()、free ()、realloc () 等 C 函数实现实际的内存配置、释放、重配置操作。
1 | void * malloc(size_t size); // 申请大小为size个字节的内存,如果分配成功返回一个void *指针,否则返回空指针 |
(第一级配置器定义为 template <int inst> class __malloc_alloc_template,在使用时会指定 inst 为 0,
1 | typedef __malloc_alloc_template<0> malloc_alloc; // 之后就可以将malloc_alloc作为第一级配置器的名称 |
这样用户在不知道这个类是什么模板的情况下无法生成其它模板。下面均是类中的成员定义)


由于作者采用的版本仍然使用 malloc 等 C 函数,所以并没有用到 C++ 中的 new-handler 机制(即当 new 申请空间失败时,在抛出 std::bad_alloc 异常之前,可以选择调用一个指定的处理函数,参考《Effective C++》第三版条款 49)。所以这个版本的 SGI 第一级配置器实现了一个类似的 set_malloc_handler:
(下面仍然是第一级配置器类内的成员函数)

(下面是在类外进行 malloc-handler 函数的初始化)


如果第一级配置器的 allocate 和 realloca 在调用 malloc 和 realloc 失败之后,改为调用 oom_malloc 和 oom_realloc,后二者都有内循环,不断调用” 内存不足处理例程 “(即 new-handler),希望能够处理好内存分配工作。但是如果未设置 new-handler(即为空指针时)则直接抛出异常。


第二级配置器解析 default_alloc_template
为什么需要第二级配置器?避免太多小额区块造成内存碎片,以及配置时的额外负担(overhead)。如果区块越小,那么额外负担所占比例就越大。
第二级配置器的做法是:如果申请区块超过 128byte,交给第一级配置器处理;否则以内存池管理,此方法又称为次层配置。
所谓内存池,就是先配置一大块内存,使用自由链表(free-list)管理,当有需要相同大小的内存需求时,从自由链表中取出一块内存使用;如果归还空间,就添加到链表上。
为了方便管理,第二级配置器会把任何区块需求量上调至 8 字节的倍数,一共维护 16 个 free-list,大小分别为 8,16,…,128。free-list 的结点结构为:
1 | union obj { |
使用 union 节省空间的巧妙之处:当没有作为用户使用的空间时,在链表上使用第一个字段指向下一个 obj;当作为用户使用的空间时,使用第二个字段指向实际区块。

下面是第二级配置器的实现内容:




空间配置函数 allocate
接口函数 **allocate () 的主要流程是:先判断区块大小,如果超过 128byte,就交给第一级配置器;小于 128 就检查对应的 free list,如果链表上有可用的区块,就直接取第一块来用,如果没有,就将区块大小上调至 8 的倍数,然后调用 refill ()** 函数为这一个 free list 填充空间。
1 | static void * allocate(size_t n) { |

空间释放函数 deallocate
接口函数 **deallocate ()** 主要流程是:如果内存块大于 128byte,调用第一级配置器释放内存;否则找到区块大小对应的 free list,将这个区块插在链表第一个位置。
1 | static void deallocate(void *p, size_t n) { |

重新填充链表函数 refill
当 allocate 发现适当位置的链表为空时,就调用 **refill ()** 为这一个链表重新填充空间。新的空间取自内存池(由 chunk_alloc 完成)。默认情况下获取 20 个新区块,如果内存池空间不足,获取区块数会小于 20.


内存池 (memory pool) 管理
chunk_allco 函数以 end_free - start_free(单位是字节数)来判断内存池中的空间。
如果空间足够满足总需求量(默认是 20 个需求大小区块),那么直接将空间分配;
如果空间不够总需求量,但是可以满足至少一个(含)以上需求区块大小,那么修改默认返回的区块大小(也就是将 20 转换为实际上能够满足的区块数量),然后将这些空间返回;
如果空间连一个需求的区块大小都不能满足,那么就需要向堆区申请内存,申请内存大小为 2 倍的总需求量加上一个随着分配次数越来越大的附加量。不过在正式向堆区申请内存之前,先将内存池中的零头分配给适当的 free list。之后正式调用 malloc 函数向堆区申请内存。如果申请成功,那么就调用自身将需求满足;如果申请失败,那么先尝试将 free list 中未使用并且足够大的(也就是空间至少大于 1 个需求区块大小)区块拿出来,补充内存池空间,然后调用自己分配内存。如果 free list 后面的均为空,那么最后尝试将任务交给第一级配置器尝试进行分配。





总结
无论是第一级还是第二级配置器,最终都被 simple_alloc 包装:

使用配置器的方法:


缺省参数 alloc 已经被 typename 为第一级配置器或者第二级配置器,SGI STL 将其设置为第二级配置器。
内存处理基本工具
STL 定义五个全局函数,用于处理未初始化空间。前两个是用于构造的 construct 和用于析构的 destroy,另外三个是 uninitialized_copy ()、uninitialized_fill ()、uninitialized_fill_n (),对应着高层次函数 copy、fill、fill_n。如果想要使用这三个此层次函数,应该包含头文件 memory,但是实际定义于 stl_uninitialized。
概述
uninitialized_copy:
1 | template <typename InputIterator, typename ForwardIterator> |
将内存的申请与对象的初始化行为分开,主要负责完成对象的初始化功能。
对于目的地 result,调用复制构造函数,利用 [first, last) 的每一个迭代器指向的对象复制一个新的对象。相当于调用了 construct (&*(result + (i - first)), *i),在相应的位置产生相应的复制品。
如果需要实现一个容器,容器的全区间构造函数(range constructor)通常以两个步骤完成:
1. 申请内存空间,足以包含范围内所有元素;
2. 使用 uninitialized_copy 在内存空间上构造元素。
C++ 标准要求具有 commit or rollback 特性,也就是要么构造出所有元素,要么一个都不构造。
uninitialized_fill:
1 | template <typename ForwardIterator, typename T> |
将内存的申请与对象的初始化行为分开,主要负责完成对象的初始化功能。
对于 [first, last) 迭代器指向的内存,均使用 x 进行初始化。也就是对于范围内的每个迭代器 i 指向的内存地址调用 construct (&*i, x)。
同样需要具有 commit or rollback 特性。
uninitialized_fill_n:
1 | template <typename ForwardIterator, typename Size, typename T> |
将内存的申请与对象的初始化行为分开,为指定范围内的所有元素设定相同的初始值。
对于 [first, first + n) 范围内的每一个迭代器指向的内存调用复制构造函数,产生 x 的复制品。
同样需要具有 commit or rollback 特性。
源代码
uninitialized_fill_n:



uninitialized_copy:




uninitialized_fill:


源代码总结
