GNU STL源码分析(一) -- 空间配置器

一、allocator介绍和环境准备

  1. allocator是所有STL库容器背后的空间配置器,各种STL容器都需要空间配置器用于给自身配置内存。在STL库的实现角度而言,最先应该被实现的就是空间分配器。
  2. GNU或者GNUC++的编码习惯(也可以称为规则):
  • 在函数名、类名或者宏名的最前面是两个下划线,就代表这是GNU内建的,是非C/C++标准的;
  • 缩进经常以一个空格完成,对于适应Tab键缩进的我们看STL源码时不是很习惯和方便,同时函数返回值单独占一行。
  1. 使用的环境介绍:操作系统环境为Ubuntu22.04、使用的编译器为g++11.2.0、g++11.2.0所带的库标准为C++17。

二、GNUC++默认空间配置器的标准接口和内存分配器实现

  1. 根据STL库的规范,以下是allocator的必要接口:
  • 需要在allocator类中定义的类型如下:allocator::value_typeallocator::pointerallocator::const_pointerallocator::referenceallocator::const_referenceallocator::size_typeallocator::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()默认析构函数。
  1. 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类中,对外只保留干净的接口。

  1. 进入深层次的实现,我们可以看到__allocator_base类只是__gnu_cxx命名空间中new_allocator类的别名,实际上的实现都在new_allocator类中,在new_allocator类中我们可以看到其内部的实现,该类没有公有或者私有的成员变量,只有成员方法,在public访问权限控制符最前面的一些和对外使用的allocator类没啥区别,最重要的是接下来,该类给出了6个公有的成员方法,也就意味着allocator类继承了这些方法,接下来将分析这些方法中的代码:

    (1) 方法address():该方法用于返回某个对象的地址,该函数有两个重载,一个函数传入的参数是对象的引用,返回的是对象的指针(也就是地址);另一个函数传入的参数是const 对象的引用,返回的是const 对象的指针。如果传入的变量名为x,传出的即为&x ;

    (2) 方法allocate(): 该方法用于给对象分配内存,接受分配对象占用字节数倍数的参数__nvoid指针,返回一个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: 直接调用对象的析构函数析构对象。

  1. new_allocator的各种函数我们介绍完了,可以看到,很多需要被优化的地方实际上并没有被优化。如果现在需要分配给很多个小对象很小的内存,同时需要进行很多的构造析构工作,这就会造成大量的内存碎片。久而久之,就会出现没有大块的内存供给大对象,可能会造成程序崩溃。幸好,GNUSTL拓展了空间配置器的实现,提供了可以应对这种场景的空间配置器的实现(其实就是SGI STL的二级配置器修改版本)。

三、GNUSTL扩展的其他空间配置器 – __pool_alloc

  1. GNUSTL扩展了其他样式的空间配置器,定义在ext文件夹的其他头文件中,我们接下来介绍一种线程安全的空间配置器__pool_alloc,该空间配置器是一个专门为小空间的容器设计的配置器,内含一个内存池,如果一个对象足够小,将由内存池交给这个对象内存空间,使用完成之后,将内存返还内存池,以减少出现的内存碎片,而当需要配置对象的大小超过128byte时,将直接使用重载的new运算符函数给新对象分配内存。GNUSTL目前正在使用的__pool_allocSGI STL中的__default_alloc_template实现技术上大差不差,但是具体细节上有一些差异。我们先探讨一下该类使用的内存池是如何实现,再对代码中涉及线程安全的地方进行
  2. __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实现中最核心的代码。接下来就是考虑一下整个内存池应该如何工作。

  1. __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)
			{
				
			}
		}
	}
}

四、上文提到的一些知识扩展

  1. static_assert静态断言:C++11引入的该断言,该断言用于编译期间,因此叫做静态断言。
  • 语法: static_assert(常量表达式,提示字符串),如果第一个常量表达式为假,就会产生一条编译错误,错误位置就是该static_assert所在行,错误提示就是第二个参数提示字符串。
  • 使用static_assert,我们可以在编译期间发现更多的错误,用编译器来强制保证一些契约,并帮助我们改善编译信息的可读性,尤其是在写库的时候。
  1. __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的值无关。
  1. new运算符的重载:
  • 我们都知道构造一个对象实例需要经历两步: (1)向系统申请空间;(2)在申请好的空间上进行初始化操作。C++STL将构造分为两个函数来管理,一个就是函数allocate来开辟一个内存空间来存储对象,还有一个函数construct进行初始化操作。我们在这里先不讨论函数construct,可以看到函数allocatenew_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++的运行速度。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值