一、allocator介绍和环境准备
- allocator是所有STL库容器背后的空间配置器,各种STL容器都需要空间配置器用于给自身配置内存。在STL库的实现角度而言,最先应该被实现的就是空间分配器。
- GNU或者GNUC++的编码习惯(也可以称为规则):
- 在函数名、类名或者宏名的最前面是两个下划线,就代表这是GNU内建的,是非C/C++标准的;
- 缩进经常以一个空格完成,对于适应Tab键缩进的我们看STL源码时不是很习惯和方便,同时函数返回值单独占一行。
- 使用的环境介绍:操作系统环境为Ubuntu22.04、使用的编译器为g++11.2.0、g++11.2.0所带的库标准为C++17。
二、GNUC++默认空间配置器的标准接口和内存分配器实现
- 根据STL库的规范,以下是
allocator
的必要接口:
- 需要在
allocator
类中定义的类型如下:allocator::value_type
、allocator::pointer
、allocator::const_pointer
、allocator::reference
、allocator::const_reference
、allocator::size_type
、allocator::difference_type
; - 需要在
allocator
类中定义的结构体如下:allocator::rebind
,rebind是一个嵌套的模板类,该结构体唯一的成员就是一个类型定义; - 需要在
allocator
类中定义的函数如下:allocator::allocator()
默认构造函数、allocator::allocator(const allocator&)
拷贝构造函数、allocator::operator=(const allocator&) = default
默认的重载赋值运算符函数、allocator::allocator(const allocator<_Tp>&)
泛化的拷贝构造函数、allocator::~allocator()
默认析构函数。
- GNUC++默认提供的内存分配策略从底层上来看还算比较简单(比起SGI STL来说策略简单太多了),只是重载了
operator new()
和operator delete()
,GNUC++提供的allocator
类定义如下(删减了一些注释和不重要的函数修饰):
template<typename _Tp>
class allocator : public __allocator_base<_Tp>
{
public:
typedef _Tp value_type;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef _Tp* pointer;
typedef const _Tp* const_pointer;
typedef _Tp& reference;
typedef const _Tp& const_reference;
template<typename _Tp1>
struct rebind
{ typedef allocator<_Tp1> other; };
allocator() { }
allocator(const allocator& __a) : __allocator_base<_Tp>(__a) { }
template<typename _Tp1>
allocator(const allocator<_Tp1>&) { }
allocator& operator(const allocator&) = default;
firend bool operator==(const allocator&,const allocator&) { return true; }
firend bool operator!=(const allocator&,const allocator&) { return false; }
};
以上就是C++所有容器都默认使用的allocator空间分配器的完整定义和实现,我们可以看到大部分的代码都在应付C++标准提供的标准约束,而从非泛化的拷贝构造函数我们可以看出来,GNUC++采取的策略就是将大量的实现隐藏在__allocator_base类中,对外只保留干净的接口。
- 进入深层次的实现,我们可以看到__allocator_base类只是__gnu_cxx命名空间中
new_allocator
类的别名,实际上的实现都在new_allocator
类中,在new_allocator
类中我们可以看到其内部的实现,该类没有公有或者私有的成员变量,只有成员方法,在public访问权限控制符最前面的一些和对外使用的allocator
类没啥区别,最重要的是接下来,该类给出了6个公有的成员方法,也就意味着allocator
类继承了这些方法,接下来将分析这些方法中的代码:
(1) 方法address()
:该方法用于返回某个对象的地址,该函数有两个重载,一个函数传入的参数是对象的引用,返回的是对象的指针(也就是地址);另一个函数传入的参数是const 对象的引用,返回的是const 对象的指针。如果传入的变量名为x,传出的即为&x ;
(2) 方法allocate()
: 该方法用于给对象分配内存,接受分配对象占用字节数倍数的参数__n
和void
指针,返回一个value_type
类型的指针。简化的方法如下:
_Tp* allocate(size_type __n,const void* = static_cast<const void*>(0))
{
static_assert(sizeof(_Tp) != 0, "cannot allocate imcomplete types"); //1
if(__builtin_expect(__n > this->_M_max_size()), false) //2
{
if(__n > (std::size_t(-1) / sizeof(_Tp)))
std::__throw_bad_array_new_length();
std::__throw_bad_alloc();
}
if(alignof(_Tp) > __STDCPP_DEFAULT_NEW_ALIGNMENT__) //3,注意此处__STDCPP_DEFAULT_NEW_ALIGNMENT__扩展到16.
{
std::align_val_t __al = std::align_val_t(alignof(_Tp));
return static_cast<_Tp*>(::operator new(__n * sizeof(_Tp), __al));
}
return static_cast<_Tp*>(::operator new(__n * sizeof(_Tp)));
}
- 1处使用
static_assert
使用的非常巧妙,C++会给任何一个已经定义好的类分配不少于1字节的内存(即使它是个空类),如果使用sizeof
去测试一个类的大小为0,只能代表该类并未定义完整。即可使用static_assert
完成编译时期报错(static_assert介绍)。 - 2处可以替换成
if(__n > this->_M_max_size())
(在结果上和源代码相同,效率相对更低),其中_M_max_size()
函数返回的值是C++能接受一个数组的最大长度,如果返回值小于用户想要初始化的元素个数时,该函数将会进入异常处理阶段,如果__n
大于(std::size(-1) / sizeof(_Tp))
,就代表数据出现很大的问题,将会抛出bad_array_new_length
异常,否则将抛出bad_alloc
异常(__builtin_expect介绍)。 - 3处
alignof
是一个运算符,返回的值是类型(这些类型其实可以是基本类型或者类)中最大成员需要分配内存字节数,如果超出了16字节,就需要调用C++17新增的已经重载了的align new
运算符手动对类型进行配齐。如果没有超出,就使用C++重载的new
运算符(重载new运算符)。
其实可以看到GNUSTL默认内存分配时只是薄薄的封装了一下new
运算符,并没有在此之上做很多优化工作。你也可以使用采用了其他策略的空间配置器,GNUSTL已经帮助你写好了这些基于其他策略的内存分配器,这些内存分配器都在ext
文件夹下,例如你自己实现的vector类想要不使用new_allocator
(基于new的空间分配器),想要使用malloc_allocator
(基于malloc函数的空间分配器),可以像下面我这样实现:
#include <ext/malloc_allocator.h>
template<typename _Tp,typename _Alloc = __gnu_cxx::malloc_allocator<_Tp>>
//注意: malloc_allocator是GNUC++对于STL标准的扩展,所以并不在std命名空间下,在GNU专属的命名空间__gnu_cxx中。
class vector
{
public:
typedef _Tp value_type;
//... 其他实现
};//采用GNUC++提供的其他allocator与这种模式大差不差。
~~~~
(3)方法deallocate
: 实现和allocate
差不多,也是检测一遍内存对齐,如果超出,自己配对齐。
~~~~
(4)方法max_size
:返回值为计算机系统允许给该类型分配的最大数量,是size_t类型,内部实现是调用该类唯一的私有方法_M_max_size
。
~~~~
(5)方法construct
: 采用placement new
来构造对象(重载new运算符)。以下是该函数的实现:
template<typename _Up,typename... _Args>
void construct(_Up* __p,_Args&&... __args) noexpect(std::is_nothrow_constructible<_Up,_Args...>::value)
{
::new((void *)__p) _Up(std::forward<_Args>(__args)...);
}
~~~~
(6)方法destory
: 直接调用对象的析构函数析构对象。
new_allocator
的各种函数我们介绍完了,可以看到,很多需要被优化的地方实际上并没有被优化。如果现在需要分配给很多个小对象很小的内存,同时需要进行很多的构造析构工作,这就会造成大量的内存碎片。久而久之,就会出现没有大块的内存供给大对象,可能会造成程序崩溃。幸好,GNUSTL拓展了空间配置器的实现,提供了可以应对这种场景的空间配置器的实现(其实就是SGI STL的二级配置器修改版本)。
三、GNUSTL扩展的其他空间配置器 – __pool_alloc
- GNUSTL扩展了其他样式的空间配置器,定义在
ext
文件夹的其他头文件中,我们接下来介绍一种线程安全的空间配置器__pool_alloc
,该空间配置器是一个专门为小空间的容器设计的配置器,内含一个内存池,如果一个对象足够小,将由内存池交给这个对象内存空间,使用完成之后,将内存返还内存池,以减少出现的内存碎片,而当需要配置对象的大小超过128byte时,将直接使用重载的new运算符函数给新对象分配内存。GNUSTL目前正在使用的__pool_alloc
和SGI STL中的__default_alloc_template
实现技术上大差不差,但是具体细节上有一些差异。我们先探讨一下该类使用的内存池是如何实现,再对代码中涉及线程安全的地方进行 __pool_alloc
类内存池采用了链表数组来存储那些被申请好的内存,我们在看源码之前,可以思考一下如果我们想要实现一个内存池应该如何设计?其实这个内存池本质上就是一个个链表,每个链表上存储的数据就是我们之前申请好的内存,于是我们可以给出第一版的实现:
struct memory_node
{
struct memory_node* next;
char* clientdata;
};
我们可以像上文一样设计链表节点,有一个指针用于存储内存,另一个指针用于指向链表的下一个节点。但是我们这么做会造成一个问题,就是空间的浪费,这个结构体中还需要维护一个指向下一个节点的指针,那有没有一种办法来解决空间浪费的问题呢,有,使用union
,当该节点仍然在链表上的时候,维护指向下一个节点的指针,当被使用时,就可以转换成char指针,来给新对象分配内存。使用这种方案可以用来将无用的内存消耗减到零。实现如下:
union _Obj
{
union _Obj* _M_free_list_link;
char _M_client_data[1];
};
上文的实现其实就是GNU提供内存池的链表节点,也是__pool_alloc
实现中最核心的代码。接下来就是考虑一下整个内存池应该如何工作。
__pool_alloc
类提供了C++空间配置器的标准接口函数allocate
,通过该函数的实现我们可以看到这个空间配置器是如何实现的:
template<typename _Tp>
_Tp* __pool_alloc<_Tp>::allocate(size_type __n, const void*)
{
using std::size_t;
pointer __ret = 0; // 返回给客户端已经分配好内存的指针
if(__n != 0) // 查看是否真的需要分配内存
{
if(__n > this->max_size()) // 如果请求内存的大小超出了系统最大内存,就抛出bad_alloc异常
std::__throw_bad_alloc();
const size_t __bytes = __n * sizeof(_Tp); // 计算实际上需要申请的内存大小
_Obj* volatile* __free_list = _M_get_free_list(__bytes); // _M_get_free_list可以用来获取最合适的一个链表
_Obj* __result = *__free_list;
if(__result == 0)
__ret = static_cast<_Tp*>(_M_refill(_M_round_up(__bytes))); // 分配新的空间并返回
else
{
*__free_list = __result->_M_free_list_link; // 将链表头节点指向下一个节点,返回头节点,内存分配完成
__ret = reinterpret_cast<_Tp*>(__result);
}
if(__ret == 0) // 如果__ret依然为空,抛出bad_alloc异常。
std::__throw_bad_alloc();
return __ret;
}
}
上文的代码是删除了优化和多线程的核心逻辑代码。我们可能感到迷惑的是_M_refill
函数是如何分配新内存并返回的呢,接下来我们重新回过头来讨论函数_M_refill
:
void* __pool_alloc_base::_M_refill(size_t __n)
{
int __nobjs = 20;
char* __chunk = _M_allocate_chunk(__n, __nobjs); // 通过_M_allocate_chunk函数申请内存,传入的__nobjs是一个引用数据类型,该变量是能够分配下来的链表节点数量
_Obj* volatite* __free_list;
_Obj* __result;
_Obj* __current_obj;
_Obj* __next_obj;
if(__nobjs == 1) // 如果节点数为1,直接返回
return __chunk;
__free_list = _M_get_free_list(__n);// 寻找最合适的链表
__result = (_Obj*)(void*)__chunk; // 设定好了返回的节点位置
*__free_list = __next_obj = (_Obj*)(void*)(__chunk + __n); // 链表指向逻辑上的下一个节点
for(int __i = 1; ; __i ++ ) // 这个循环用于将分配好的内存空间使用链表连接起来
{
__current_obj = __next_obj;
__next_obj = (_Obj*)(void*)((char*)__next_obj + __n); // 指向逻辑上的下一个节点
if(__nobjs - 1 == __i) // 循环遍历到了链表逻辑上的最后一个节点上,将最后一个节点指向下一个结点的指针指向空(单链表的常规操作)。
{
__current_obj->_M_free_list_link = 0;
break;
}
else
__current_obj->_M_free_list_link = __next_obj; // 将该节点的_M_free_list_link(其实就是next指针)指针指向空
}
return __result;
}
似乎还没有走到最后,继续查看函数_M_allocate_chunk
实现:
char *__pool_alloc_base::_M_allocate_chunk(size_t __n, int& __nobjs)
{
char* __result;
size_t __total_bytes = __n * __nobjs;
size_t __bytes_left = _S_end_free - _S_start_free;
if(__bytes_left >= __total_bytes) // 如果内存池中剩余的内存大小大于需要的内存大小,直接分配即可
{
__result = _S_start_free;
_S_start_free += __total_bytes;
return __result;
}
else if(__bytes_left >= n) // 如果内存池中剩余的内存大小只供一块以上二十块以下的节点使用
{
__nobjs = (int)(__bytes_left / __n); // 重新
__total_bytes = __n * __nobjs;
__result = _S_start_free;
_S_start_free += __total_bytes;
return __result;
}
else
{
if(__bytes_left > 0)
{
_Obj* volatile* __free_list = _M_get_free_list(__bytes_left);
((_Obj*)(void*)_S_start_free)->_M_free_list_link = *__free_list;
*__free_list = (_Obj*)(void*)_S_start_free;
}
size_t __bytes_to_get = (2 * __total_bytes + _M_round_up(_S_heap_size >> 4));
__try
{
_S_start_free = static_cast<char*>(::operator new(__bytes_to_get));
}
__catch(const std::bad_alloc&)
{
size_t __i = __n;
for(; __i <= (size_t)_S_max_bytes; __i += (size_t)_S_align)
{
}
}
}
}
四、上文提到的一些知识扩展
static_assert
静态断言:C++11引入的该断言,该断言用于编译期间,因此叫做静态断言。
- 语法: static_assert(常量表达式,提示字符串),如果第一个常量表达式为假,就会产生一条编译错误,错误位置就是该
static_assert
所在行,错误提示就是第二个参数提示字符串。 - 使用
static_assert
,我们可以在编译期间发现更多的错误,用编译器来强制保证一些契约,并帮助我们改善编译信息的可读性,尤其是在写库的时候。
__builtin_expect
函数:GCC v2.96版本引入,用于帮助优化if语句。
- 函数声明:
long __builtin_expect(long exp,long c)
,其中参数exp
是一个整形表达式,c
必须是编译器常量,其返回值就等于第一个参数exp
; - 使用方法: 与关键字
if
一起使用,其实if(value)
等价于if(__builtin_expect(value,x))
,与x的值无关。
new
运算符的重载:
- 我们都知道构造一个对象实例需要经历两步: (1)向系统申请空间;(2)在申请好的空间上进行初始化操作。C++STL将构造分为两个函数来管理,一个就是函数
allocate
来开辟一个内存空间来存储对象,还有一个函数construct
进行初始化操作。我们在这里先不讨论函数construct
,可以看到函数allocate
在new_allocator
类中的实现都是在使用各种被重载了之后的new
操作符函数,而这些重载声明的函数被定义在了<new>
文件夹下,打开这个文件夹,我们就可以看到各种被重载的new
运算符函数。 - C++中
new
运算符的函数实现有很多,以下是我从<new>
文件下截下来重载声明好了的new
函数(去除了一些对分析无用的修饰):
void* operator new(std::size_t);
void* operator new[](std::size_t);
void* operator new(std::size_t,void* __p); //1,placement new
void* operator new[](std::size_t,void *__p); //1,placement new
void* operator new(std::size_t, std::align_val_t); //2,aligned new
void* operator new[](std::size_t, std::align_val_t); //2,aligned new
void* operator new(std::size_t, const std::nothrow_t&); //3,no throw
void* operator new[](std::size_t, const std::nothrow_t&);
void* operator new(std::size_t, std::align_val_t, const std::nothrow_t&);
void* operator new[](std::size_t, std::align_val_t, const std::nothrow_t&);
可以看到,C++允许我们在类中重载实现的new
函数有很多,篇幅限制,在此不就一一介绍了,接下来就只介绍上文三种类型new
函数
- 1处就是大名鼎鼎的
placement new
,使用这个重载函数我们可以在一个已经分配的内存上直接构造对象,已经分配好的内存在这个函数种就是__p指针。新对象的首地址就是指针__p的首地址,我们可以使用placement new
提前向系统申请一大块内存,在程序运行的时候,将这些内存分配给需要空间的新对象,节省了向系统申请内存耗费的时间,也方便了对内存的管理。我们可以在给对象分配内存时使用这样的方式:
struct TextStruct {};
int *__p = new(int);
TextStruct *str = new(__p) TextStruct({}); //placement new的使用
- 2处是C++17新增的
aligned new
,随之一起新添加入C++17标准的有alignof
关键字和align_val_t
类型,这些新特性的加入都是为了帮助C++程序员对于C++类进行更好的内存对齐工作,所以aligned new
的作用也很明显,就是用于在构造对象时的内存对齐操作。3处是即使内存分配失败,但是也不抛出异常,有利于加快C++的运行速度。