配置器:负责空间配置与管理,从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的 class template。
空间配置器:整个 STL 的操作对象(所有的数值)都存放在容器之内,而容器一定需要配置空间以存放内容。
什么是 allocator?allocator 有什么用?
我们需要对 C++ 的 allocator 的堆内存接口调用顺序有个清晰的认识,如下图所示。
allocator 堆内存管理接口 STL 的容器(eg: vector、stack、deque等)有一个共同特征,就是它们的大小可以在程序运行时改变。通俗点说就是当我们想要往容器中加东西的时候,容器的内存就会自动扩充,不需要提前设定好内存的大小。这种内存分配方式称为动态内存分配,而 allocator 正是用于动态内存的分配与释放。
有必要自己实现 allocator 吗?
在我们使用容器的时候,一般不需要我们自己去实现 allocator,程序会调用默认的 std::allocator 来动态分配内存。但是有些情况需要我们自定义 allocator,比如:
- 有些嵌入式平台没有提供默认的 malloc/free 等底层内存管理函数,你需要继承 std::allocator,并封装自定义版本的 malloc/free 等更底层的堆内存管理函数。
- 使用 C++ 实现自己的数据结构,有时我们需要扩展(继承) std::allocator。
- 大部分用 C++ 写的游戏程序都有自己重新实现的 allocator。
SGI STL 空间配置器的结构
SGI STL 2.9 的配置器,其名称是 alloc 而不是 allocator,而且不接受任何参数。
SGI STL 2.9的每一个容器都已经指定其缺省的空间配置器为 alloc。
template <class T, class Alloc = alloc> // 缺省使用 alloc 为配置器
class vector {...};
vector<int, std::alloc> iv;
-
<defalloc.h>----SGI 标准的空间配置器,std::allocator 后面版本默认改成了这个
allocator 只是基层内存配置/释放行为(::operator::new 和 ::operator::delete)的一层薄薄的包装,并没有考虑到任何效率上的强化。
-
SGI 特殊的空间配置器,std::alloc
- <stl_construct.h>:定义了全局函数 construct() 和 destroy(),负责对象的构造和析构。
- <stl_alloc.h>:定义了一、二级配置器,配置器名为 alloc。
- <stl_uninitialized.h>:定义了全局函数,用来填充(fill)或复制(copy)大块内存数据。
-
构造和析构基本工具
具体看 <stl_construct.h> 源码,功能是构造和析构操作。
-
空间的配置和释放,std::alloc
- 向 system heap 要求空间
- 考虑多线程(multi-threads)状态
- 考虑内存不足时的应变措施
- 考虑过多 “小型区块” 可能造成的内存碎片问题
对象构造前的空间配置 和 对象析构后的空间释放,具体看 <stl_alloc.h>。
SGI STL 空间配置器的分析
考虑到小型区块可能造成内存碎片问题,SGI 采用两级配置器,第一级配置器直接使用 malloc() 和 free() 实现;第二级配置器使用 memory pool 内存池管理。
第二级配置器的原理:
- 当配置区块超过 128 bytes,就使用第一级配置器
- 当配置区块小于 128 bytes,使用内存池管理
enum {_ALIGN = 8}; // 小型区块的上调边界
enum {_MAX_BYTES = 128}; // 小区区块的上限
enum {_NFREELISTS = 16}; // _MAX_BYTES/_ALIGN free-list 的个数
// free-list 的节点结构,降低维护链表 list 带来的额外负担
union _Obj {
union _Obj* _M_free_list_link; // 利用联合体特点
char _M_client_data[1]; /* The client sees this. */
};
static _Obj* __STL_VOLATILE _S_free_list[_NFREELISTS]; // 注意,它是数组,每个数组元素包含若干相等的小额区块
其中 free-list 是指针数组,16 个数组元素,就是 16 个 free-list,各自管理大小分别为 8, 16, 24, 32,…128 bytes(8 的倍数)的小额区块。
小额区块的结构体 union _Obj
使用链表连接起来。
配置器负责配置,同时也负责回收。
自定义 allocator
allocator 标准接口
// 以下几种自定义类型是一种type_traits技巧,暂时不需要了解
allocator::value_type
allocator::pointer
allocator::const_pointer
allocator::reference
allocator::const_reference
allocator::size_type
allocator::difference
// 一个嵌套的(nested)class template,class rebind<U>拥有唯一成员other,那是一个typedef,代表allocator<U>
allocator::rebind
allocator::allocator() // 默认构造函数
allocator::allocator(const allocator&) // 拷贝构造函数
template <class U>allocator::allocator(const allocator<U>&) // 泛化的拷贝构造函数
allocator::~allocator() // 析构函数
// 返回某个对象的地址,a.address(x)等同于&x
pointer allocator::address(reference x) const
// 返回某个const对象的地址,a.address(x)等同于&x
const_pointer allocator::address(const_reference x) const
// 配置空间,足以存储n个T对象。第二个参数是个提示。实现上可能会利用它来增进区域性(locality),或完全忽略之
pointer allocator::allocate(size_type n, const void* = 0)
// 释放先前配置的空间
void allocator::deallocate(pointer p, size_type n)
// 返回可成功配置的最大量
size_type allocator:maxsize() const
// 调用对象的构造函数,等同于 new((void*)p) T(x)
void allocator::construct(pointer p, const T& x)
// 调用对象的析构函数,等同于 p->~T()
void allocator::destroy(pointer p)
上面的标准接口实际只需要关注最后的allocate,deallocate,construct和destroy函数的实现即可。整个allocator最重要的函数就是这四个。从功能上,这四者可以简单理解为C++的::operator new和::operator delete,构造函数和析构函数,实际上,一个最简单的allocator就可以理解为对new,delete的简单封装,以及对构造函数和析构函数的直接调用。
自定义 allocator 的难点
难点一:
STL是一个标准,只对接口进行规范,接口背后的实现可以有不同版本,所以目前流行的STL如Visual C++采用的P. J. Plauger 版本,GCC编译器采用的SGI STL版本等都是常见的STL。SGI STL是最流行的版本,我们主要关注SGI STL的allocator实现。
这是STL标准要求的allocator必要接口:
template <class T>
inline T* _allocate(ptrdiff_t size, T*) {
std::set_new_handler(0); // 分配失败,抛出std::bad_alloc
// 空间的分配实现,调用 ::operator new() 全局函数
T* tmp = (T*)(::operator new((size_t)(size * sizeof(T))));
if (tmp == 0) {
std::cerr << "out of memory" << std::endl;
exit(1);
}
return tmp;
}
我们来看看这段代码的第 3 行:
std::set_new_handler(0);
在解释 set_new_handler 函数之前,我们先来看一段标准库函数声明。
namespace std{
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
}
从声明中可以看出,new_handler 是个 typedef,定义出一个指针指向函数,该函数没有参数也没有返回值。
而 set_new_handler 是“获得一个 new_handler 并返回一个 new_handler”的函数。
这样就很清晰了,在 operator new 分配内存失败抛出一个异常之前,会先调用一个错误处理函数(即 new_handler)
难点二:
template <class T1, class T2>
inline void _construct(T1* p, const T2& value)
{
new(p) T1(value); // placement new
}
代码里的第 4 行比较少见,它是一个重载函数,叫作 placement new。
再解释之前,我们先来区分一下 new、operator new、placement 究竟有什么区别?
new operator 是一个我们熟悉的 new,不可以重载,作用是调用 operator new 申请内存,并初始化一般用户调用。
operator new 是重载函数,一般在类中进行重载。如果类中没有重载 operator new,那么调用的就是全局的 ::operator new 来完成堆的分配。
placement new 是 operator new 的一个重载版本,只是我们很少用到它。如果你想在已经分配的内存中创建一个对象,使用 new 是不行的。也就是说 placement new 允许你在一个已经分配好的内存中构造一个新的对象。
我们知道使用 new 操作符分配内存需要在堆中查找足够大的剩余空间,这个操作速度是很慢的,而且有可能出现无法分配内存的异常(空间不够)。
placement new 就可以解决这个问题。我们构造对象都是在一个预先准备好了的内存缓冲区中进行,不需要查找内存,内存分配的时间是常数;而且不会出现在程序运行中途出现内存不足的异常。
所以,placement new 非常适合那些对时间要求比较高,长时间运行不希望被打断的应用程序。
难点三:
template <class U>
struct rebind {
typedef allocator<U> other;
};
ebind 的意义就在于实现两个不同但两者互相有关的类型(比如类型 T 和 Node类型),使用同一种内存分配方法。如果抛开 rebind 不提,想要实现上述的意义,容器必须要让 allocator 是同一个模板,问题就出在容器并不关心你的 allocator 是怎么写的。
它唯一有关的就是在声明时在 template 中写 alloc=allocator,只知道模板参数名 allocator,而不知道其具体实现,导致没有办法让 T 与 U 的 allocator 是同一个。于是在 allocator 中创建一个 U 的 allocator,标准中有这样的规定:
对于 allocator 与一个类型 U,allocator 与 allocator::rebind::other 是等价的。在想使用 allocator 的时候就需要使用 allocator::rebind::other,否则就是用了一个别的 allocator了。
实现代码
#include <iostream>
#include <vector>
using namespace std;
namespace my_alloc
{
//allocate的实际实现,简单封装new, 当无法获得内存时,报错并退出
template <class T>
inline T* _allocate(size_t size, T*)
{
set_new_handler(0);
//申请 size个对象的空间
T* temp = (T*)(::operator new(size * sizeof(T)));
if (temp == nullptr)
{
cerr << "out of memory" << endl;
exit(1);
}
return temp;
}
//deallocate的实现,简单封装delete
template <class T>
inline void _deallocate(T* buffer)
{
::operator delete(buffer);
}
//construct的实现 直接调用对象的构造函数
template <class T1, class T2>
inline void _construct(T1* ptr, const T2& value)
{
new(ptr) T1(value);
}
//destroy的实际实现,直接调用对象的析构函数
template <class T>
inline void _destroy(T* ptr)
{
ptr->~T();
}
template <class T>
class allocator
{
public:
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
// 构造函数
allocator()
{
return;
}
template <class U>
allocator(const allocator<U>& c)
{
}
// rebind allocator of type U
template <class U>
struct rebind
{
typedef allocator<U> other;
};
// allocate,deallocate,construct和destroy函数均调用上面的实际实现
// hint used for locality. ref.[Austern],p189
pointer allocate(size_type n, const void* hint = 0)
{
return _allocate((size_t)n, (pointer)0);
}
void deallocate(pointer p, size_type n) { _deallocate(p); }
void construct(pointer p, const T& value) { _construct(p, value); }
void destroy(pointer p) { _destroy(p); }
pointer address(reference x) { return (pointer)&x; }
const_pointer const_address(const_reference x) { return (const_pointer)&x; }
size_type max_size() const { return size_type(UINT_MAX / sizeof(T)); }
};
}
int main()
{
std::vector<int, my_alloc::allocator<int> > v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
for (auto i : v)
{
cout << i << endl;
}
return 0;
}
vs2017 标准分配器实现
vs2017 标准库中,std::allocator 的实现如下:
template<class _Ty>
class allocator
{
//...
void deallocate(_Ty * const _Ptr, const size_t _Count)
{ // deallocate object at _Ptr
// no overflow check on the following multiply; we assume _Allocate did that check
_Deallocate<_New_alignof<_Ty>>(_Ptr, sizeof(_Ty) * _Count);
}
_NODISCARD _DECLSPEC_ALLOCATOR _Ty * allocate(_CRT_GUARDOVERFLOW const size_t _Count)
{ // allocate array of _Count elements
return (static_cast<_Ty *>(_Allocate<_New_alignof<_Ty>>(_Get_size_of_n<sizeof(_Ty)>(_Count))));
}
}
allocator 是一个模板类,_Ty 用来指定给具体类分配内存。其中最重要的当然是 deallocate 和 allocate 两个函数,用来分配内存和释放内存。
先来看看 allocate:
传入的参数为待分配内存对象的个数,而不是字节数。
底层的实现如下:
_DECLSPEC_ALLOCATOR static void * _Allocate(const size_t _Bytes)
{
return (::operator new(_Bytes));
}
可以看到,它并没有做任何内存管理的动作,只是调用了全局的 operator new 函数。
再来看看 deallocate:
传入的参数为待释放内存块的指针,以及其中的放置对象的数量。
底层实现如下:
template<size_t _Align,
enable_if_t<(!_HAS_ALIGNED_NEW || _Align <= __STDCPP_DEFAULT_NEW_ALIGNMENT__), int> = 0> inline
void _Deallocate(void * _Ptr, size_t _Bytes)
{ // deallocate storage allocated by _Allocate when !_HAS_ALIGNED_NEW || _Align <= __STDCPP_DEFAULT_NEW_ALIGNMENT__
//...
::operator delete(_Ptr, _Bytes);
}
可以看到,它也只是调用了全局的 operator delete 函数。
因此,由上可以看出:
std::allocator 并没有做任何内存管理的动作,底层只是调用了 ::operator new 和 ::operator delete,而它们又底层调用了 malloc 和 free。所以,cookie 的内存浪费仍然存在。
std::allocator 的内存分配和释放是以对象为单位,而不是字节。而且内存在释放的时候,还需要具体指定释放的内存块的大小(以对象为单位)。
__poll_alloc
SGI-STL 在G4.9 版本中的分配器底层只是将 operator new 和 operator delete 简单的进行了封装,并没有特殊处理,但是提供了一个扩展的分配器 _pool_alloc,效率更高,以下介绍这个扩展的分配器
以上的缺陷主要就是由频繁申请小块内存造成的,那么我们需要先知道什么才是小块内存,SGI-STL 中pool alloc 分配器以 128 个字节来区分小块和大块内存,将空间配置器分为两级结构,一级空间配置器处理大块内存,二级空间配置器处理小块内存
一级空间配置器
一级空间配置器的原理就是直接将 malloc 和 free 进行了封装,malloc 申请空间成功直接返回,申请失败交给 oom_malloc 处理(检查用户是否设置了申请空间失败的应对措施,如果没有就抛异常,如果有就执行应对措施,然后重新申请)
二级空间配置器
二级空间配置器专门负责处理小于 128 字节的小块内存。SGI-STL 采用了内存池的技术来提高申请空间的速度以及减少额外空间的浪费,采用哈希桶的方式来提高用户获取空间的速度
内存池
内存池就是:先申请一块比较大的内存块做备用,当需要内存时,直接到内存池中去取,当池中空间不够时,再到内存中去取,当用户不用时,直接还回内存池即可
SGI-STL中二级空间配置器设计
SGI-STL中的二级空间配置器使用了内存池技术,但没有采用链表的方式对用户已经归还的空间进行管理(因为在链表中查找空闲空间需要循环查找,效率低)而是采用了哈希桶的方式进行管理。
并且我们并不需要 128 个桶,因为用户申请的内存基本都是 4 的整数倍,干脆将用户申请的空间向上对齐,SGI-STL 才取得是向上对齐到 8 的整数倍(桶中的内存块需要记录自己内存地址,64位操作系统下一个指针为8个字节,每个桶中的链的节点至少有一个指针也就是8个字节)
实例分析
引入如下代码:
1 #include <iostream>
2 #include <memory>
3 #include <ext/pool_allocator.h>
4 using namespace std;
5 struct S_8
6 {
7 double a[1];
8 };
9 struct S_16
10 {
11 double a[2];
12 };
13 struct S_40
14 {
15 double a[5];
16 };
17 struct S_120
18 {
19 double a[15];
20 };
21 int main(){
22 __gnu_cxx::__pool_alloc<S_8> S_8_allo;
23 __gnu_cxx::__pool_alloc<S_16> S_16_allo;
24 __gnu_cxx::__pool_alloc<S_120> S_120_allo;
25 __gnu_cxx::__pool_alloc<S_40> S_40_allo;
26 auto p_S_16 = S_16_allo.allocate(1);
27 auto p_S_16_1 = S_16_allo.allocate(1);
28 std::cout << (char*) p_S_16_1 - (char *) p_S_16 << std::endl; // should output 16
29 auto p_S_8 = S_8_allo.allocate(1);
30 std::cout << (char *) p_S_8 - (char*) p_S_16 << std::endl; //should output 320
31 auto p_S_120 = S_120_allo.allocate(1);
32 std::cout << (char *) p_S_120 - (char*) p_S_16 << std::endl; // should output 480
33 auto p_S_120_1 = S_120_allo.allocate(1);
34 std::cout << (char *) p_S_120_1 - (char*) p_S_16 << std::endl; // a unpredictale value
35 auto p_S_40 = S_40_allo.allocate(1);
36 std::cout << (char *) p_S_40 - (char*) p_S_16 << std::endl; // shoud output 600
37 S_16_allo.deallocate(p_S_16, 1);
38 S_16_allo.deallocate(p_S_16_1, 1);
39 S_8_allo.deallocate(p_S_8, 1);
40 S_120_allo.deallocate(p_S_120, 1);
41 S_120_allo.deallocate(p_S_120_1, 1);
42 S_40_allo.deallocate(p_S_40, 1);
43 return 0;
}
代码中引入struct S_8,struct S_16,struct S_40和struct S_120,只是单纯的为了引入大小为8,16,40和120 byte的类型。
后续图例中
_S_heap_size 记录的是当前为内存池所分配的总的内存的大小(二级分配器拥有的总内存大小)
_S_start_free记录的是由内存池所分配的内存块中未被利用、也未被归入到某个链表中的内存的起点
_S_end_free所记录的则是这块内存的终点
它们都是父类__pool_alloc_base的static成员。
内存分配
编译并运行上述代码,当代码运行到第26行后,已经分别为这4种类型创建了各自的allocator,但是请记住它们将共享同一个内存池,此时内存池所管理的内存为零(_S_heap_size = 0, _S_start_free = 0, S_end_free = 0)。
26 auto p_S_16 = S_16_allo.allocate(1);
在第26行代码运行期间,为了响应内存分配的请求,__pool_alloc会先分配一块大小为40 * sizeof(S_16) = 640 bytes的内存,并将前16个byte返回给p_S_16,然后将前20 * sizeof(S_16) = 320 byte中扣除前16 byte的内存(304 bytes)挂载到长度为16的static数组中的对应位置,其间会将这304 bytes的内存分成19个链表中前后相连的node。图示如下:
27 auto p_S_16_1 = S_16_allo.allocate(1);
当程序运行第27行代码时,由于它发现在内存池中对应于类型大小为16 byte的链表不为空,因此它只需要从这一链表中直接取用16 byte内存(链表中第一个node),并将链表起点后移一位即可,
p_S_16和p_S_16所指的内存是连续的,因此程序第28行将会输出16。
29 auto p_S_8 = S_8_allo.allocate(1);
在程序运行第29行时,由于它发现对应于8 byte的链表为空,因此会试着从_S_start_free和_S_end_free中找出20 * 8 byte的内存给相应链表,并将链表中第一个node的内存分配给p_S_8,然后后移链表起点,此时状态如下:
如图所示,第30行代码的输出结果将是320。
31 auto p_S_120 = S_120_allo.allocate(1);
在程序运行第31行代码时,虽然介于_S_start_free和_S_end_free之间的内存小于120 * 20 byte,但是依然大于120 byte并小于2 * 120 byte,因此__pool_alloc会将其中的第一个120 byte直接赋值给p_S_120,此时的状态如下:
显然第32行代码将输出480。
第33行代码试图再次申请一块大小为120 byte的内存,但是此时介于_S_start_free和_S_end_free之间的内存并不足以满足这一要求,因此需要开辟新的内存块。不过,在分配新的内存块之前,我们应该尽可能的利用剩余的、介于_S_start_free和_S_end_free之间的这一块内存。方法就是将其挂载到适合的链表中,然后再去分配新的内存块(新内存块的尺寸将是40*120加上_S_heap_size >> 4之后向上取整到8的倍数的值,也就是:4840 bytes)。随后采入相同的方式,将新内存块中前20120 byte的内存挂载到120对应的链表下,然后将第一个node赋值给p_S_120_1,并后移链表的起点。此时的状态如下:
由于p_S_120_1的起点在新的内存块中,因此第34行代码的结果将是一个不可预知的值。
当程序执行第35行代码时,由于40byte对应的链表的不是空的,因此会直接从链表中得到所需的内存,并将链表起点后移一位(结果是使该链表变为空),此时状态如下:
由于此时p_S_40和p_S_16所指向的内存处在同一个内存块中,因此第36行代码将会输出600。
以上过程大致涵盖了__pool_alloc中各种内存分配的情况,后续新的内存申请将遵照相同的形式进行。下面来看内存回收再利用的情况。
内存回收再利用
作为使用了内存池的allocator,__pool_alloc并不会将其分配的内存释放掉,而是会将回收内内存放到与其对应的链表中。把回收的node挂到链表的最后。由于过程相对简单,此处只将其最终结果展示如下(120 byte对应的链表有两条箭头):
讨论
__pool_alloc提供了一种简单、普适且高效的内存池实现机制,能够很容易地被用于std::vector一类的容器。但是该实现并没有提供主动释放内存池中内存的接口,因此这部分内存一旦被分配,就会一致持续存在,直到程序结束。这会导致对于某些只有中期某个时间点会占用大量内存的程序,其内存占用会一直保持较高的占用率,这或许是我们所希望能够尽量避免的,但是增加能存池大小的动态调整的功能,无疑会大大增加该实现的复杂度,又会降低该实现的普适性。