【C++】站在巨人的肩膀上剖析STL空间配置器

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_41035588/article/details/86446527


以STL的运用角度而言,它是不需要介绍的,但是作为一名C++Coder,剖析并熟练使用它是非常有必要的。今天我不是在造轮子,而是站在它的肩膀上探索空间配置器的 设计原理和实现细节。与其说它是一个C++的标准库,不如称之为一门艺术。

一、STL六大组件之间的关系

面对扮演轮子角色的这些STL组件,不论是为了重温数据结构和算法,或是为了扮演轮子角色,或是想要进一步扩张别人的轮子,都可因此获得深厚扎实的基础,天下大事,必作于细。

① 空间配置器(allocator)

是一种管理空间的机制,进行空间的分配与回收,之所以不叫它内存配置器,是因为空间不一定是内存,空间也可以是磁盘或其他辅助存储介质

② 迭代器(iterator)

提供一种方法,使之能够依序巡访某个聚合物(容器)所含的各个元素,而又无需暴露该聚合物的内部表达方式。你可以认为它是算法与数据容器的粘合剂,将两者有机的统一起来。

③ 容器(container)

任何特定的数据结构都是为了实现某种特定的算法,STL则将运用最广泛的一些数据结构实现出来。它是STL的核心之一,几乎所有的组件都围绕容器展开,比如:迭代器提供访问方式空间配置器提供容器的空间分配算法对容器中的数据进行处理仿函数为算法提供具体的策略类型萃取实现对自定义类型内置类型的提取

④ 类型萃取(typetraits)

它是基于泛型编程的内部类型解析,通过typename可以获取迭代器内部类型。它依靠的是模板的特化,模板的特化又分为全特化与偏特化,对于编译器来说,如果你对某一功能有更好的实现,那么就应该使用你的特化版本。

⑤ 仿函数(functors)

在C语言中,它是一种类似于函数指针的可回调机制,用于算法中的决策处理。在C++中它使得一个类看上去像一个函数,提供在类中重载operator(),这个类就有了类似于函数的行为

⑥ 适配器(adapters)

一个类的接口转换为另一个类的接口,使得原本因接口不兼容而不能合作的类可以一起运作。比如STL中的stack、queue,通过deque双端队列适配实现map、set通过RBTree适配实现。
下图较好的说明了STL六大组件之间的关系:
在这里插入图片描述当然,面对PJ、SGI、HP等众多版本的STL而言,我所要剖析的是易读性最强的SGI版本。而今天只谈空间配置器,嗯,更加准确的说应该称为内存配置器,,SGI对此的设计哲学是:

1.向system heap要求空间
2.考虑多线程(multi_threads)状态 
3.考虑内存不足时的应变措施 
4.考虑过多小型区块可能造成的内存碎片(fragment)问题

二、STL产生空间配置器的原因?

1、小块内存带来的内存碎片问题

当然这里指的是外碎片,由于频繁分配、释放小块内存,导致堆中空闲的内存总量虽然足够,但是这些空闲的内存块都不连续,导致任何一个单独空间的内存都无法满足这一要求。

内碎片:是由于内存对齐、访问效率而产生的,也是空间配置器的历史遗留问题。比如操作系统的分页机制默认一页是4K,如果用户申请了100字节,依旧会分配4K字节,未使用的空间就构成了内碎片

下图模拟了一个内碎片与外碎片的产生原因:
在这里插入图片描述
2、小块内存频繁申请释放带来的性能问题

额外负担是永远无法避免的,毕竟系统要靠这多出来的空间管理内存,但是空间越小,额外负担所占的比例越大,越显得浪费,如下图所示:
在这里插入图片描述
频繁向系统申请内存会引起性能上的消耗,毕竟寻找空闲内存块也是需要时间的。此外,依据计算机的局部性原理,当你申请了某一大小的内存块时,很可能接下来又要申请同样大小的内存,这在B树与B树的变种也应用很多。

三、STL一级空间配置器

在STL中,默认如果分配的内存大于128字节,则认为是大块内存调用一级空间配置器向系统申请内存。当然,这是STL十几年前的标准,你可以把这道分水岭调节的更适合。

一级空间配置器的内存申请、释放只是简单了封装了malloc()和free()。但是,很重要的一点是一级空间配置器实现了类似于C++ new-handler机制,它不能直接运用C++new-handler机制,因为它并非使用::operator new来配置内存

所谓的C++ new-handler机制是,你可以要求系统在内存配置需求无法满足时,调用一个你所指定的函数。也就是万一内存开辟失败,在抛出异常之前,会先调用由客户端指定的处理例程,没错,这一切都是由客户自己设定。在SGI第一次空间配置器的allacate()和realloc()都是在调用malloc()和realloc()不成功后,改调用oom_malloc()和oom_realloc(),而后两者都存在内循环,如果客户设定的处理例程足够失败,就陷入了死循环的危险。

一级空间配置器流程图:
在这里插入图片描述

typedef void(*HandlerFunc)(); //将void(*)()重命名成HandlerFunc

//一级空间配置器
template<int inst>   //预留参数
class _MallocAllocTemplate
{
public:
	//分配size字节的空间
	static void* Allocate(size_t size)
	{
		__TRACE_DEBUG("调用一级空间配置器申请[%ubytes]\n", size);

		void *ret = malloc(size); //一级配置器直接使用malloc()开辟空间
		//若malloc失败,则调用OOM_Malloc
		if (ret == 0)
		{
			ret = OOM_Malloc(size);
		}
		return ret;
	}

	//malloc失败时调用的处理函数
	static void* OOM_Malloc(size_t size)
	{
		//一直尝试释放、配置,直到malloc成功
		for (;;)
		{
			//未设置内存不足处理机制,则抛出异常
			if(_handler == NULL)   
				throw bad_alloc();
			_handler();  //调用处理历程,企图释放内存
	
			void* ret = malloc(size);
			//开辟成功,返回这块内存的地址
			if (ret)
				return ret;
		}
	}

	//直接调用free释放空间
	static void Deallocate(void* p, size_t n)
	{
		__TRACE_DEBUG("一级空间配置器释放内存[%p]\n", p);
		free(p);
	}

	//通过以下这个函数设置文件句柄,间接实现set_new_hanlder
	//参数与返回值均是函数指针
	static HandlerFunc SetMallocHandler(HandlerFunc f)
	{
		HandlerFunc old = f;
		_handler = f;   //将内存分配失败的句柄设置为f,让操作系统去释放其他的空间
		return old;
	}

private:
	static HandlerFunc _handler;//函数指针,用于内存不足时的处理机制
};
template<int inst>
HandlerFunc _MallocAllocTemplate<inst>::_handler = 0; //初始化,默认不设置内存处理机制

四、STL二级空间配置器

第二级空间配置器才是空间配置器的精华所在,当申请的内存小于128字节时,就调用二级。它的巧妙就在于使用狭义内存池+自由链表的形式来避免小块内存带来的碎片化,如下图所示:
在这里插入图片描述

每次配置一大块内存,并维护与之对应的自由链表,下次若在有相同的需求,则直接可以在自由链表中取,释放时链接在自由链表中。为了方便管理,二级空间配置器主动将任何小额区块的内存需求上调至8的倍数并维护16个free_list:它的结构如下:

//自由链表(赋值管理分配出去的内存块)
	union Obj
	{
		union Obj* _freeListLink;//指向下一个自由链表节点的指针
		char client_data[1]; // The client sees this.
	};

使用union结构的好处在于避免了维护链表而需要额外的指针,因为union的第一个字段就可试之为指针,指向第一块内存。

我先对几个重要的函数进行介绍,然后在放出二级配置器的流程图:

✔ 空间配置函数Allocate()

此函数首先判断内存块大小大于128字节就调用第一级空间配置器小于128字节就检查对应的free_list,如果free_list之内可用的内存块,就直接拿来使用,否则将内存块上调至8的倍数,调用Refilll()来填充free_list
在这里插入图片描述

//开辟nbytes的空间
	static void* Allocate(size_t n)
	{
		__TRACE_DEBUG("调用二级空间配置器申请[%ubytes]\n", n);
		
		//大于128字节调用一级空间配置器
		if (n > (size_t)__MAX_BYTES)
		{
			return _MallocAllocTemplate<0>::Allocate(n);
		}

		//寻找16个free_list中适当的一个
		size_t index = FREELIST_INDEX(n);
		//free_list中存在对应的内存块时,将第一块返回给用户
		if (_freeList[index])
		{
			__TRACE_DEBUG("在自由链表第[%d]个位置取一个内存块\n", index);
			Obj* ret = _freeList[index];
			_freeList[index] = ret->_freeListLink;//指向第二个内存块
			return ret;
		}
		else
		{
			return Refill(ROUND_UP(n)); //调用Refill()从内存池中申请空间来填充free_list
		}

	}

✔ 空间释放函数Deallocate()

该函数首先判断内存块的大小,大于128字节就调用一级配置器,小于128字节就找到对应的free_list,利用头插法将内存块挂接在对应位置。
在这里插入图片描述

//释放空间
	static void Deallocate(void *ptr, size_t n)
	{
		//大于128字节调用一级空间配置器释放
		if (n > __MAX_BYTES)
		{
			_MallocAllocTemplate<0>::Deallocate(ptr, n);
		}
		else
		{
			//将释放的内存块挂接在自由链表的对应位置
			size_t index = FREELIST_INDEX(n);

			__TRACE_DEBUG("将释放的[%ubytes]的对象挂在free_list[%u]下\n", n, index);
			((Obj*)ptr)->_freeListLink = _freeList[index];//头插法
			_freeList[index] = (Obj*)ptr; //更新freelist的指向
		}
	}

✔ 重新填充函数Refill()

free_list中没有可用内存块时,就调用Refill()为free_list重新填充空间,新的空间将取自内存池,由ChunkAlloc完成,缺省取得20个新内存块,但有些时候并不能如愿以偿。

//为自由链表重新填充空间
	static char* Refill(size_t n)
	{
		size_t nobjs = 20;  //缺省取得20个新区块
		char* chunk = ChunkAlloc(n, nobjs);//调用ChunkAlloc进行内存分配

		__TRACE_DEBUG("到内存池中申请[%u]个的对象,\
一个返回用户使用,剩下挂在自由链表下面\n", nobjs);
		
		//只申请到一个内存块时,直接返回给用户使用,free_list无新节点
		if (nobjs == 1)
			return chunk;

		//将剩余的nobjs-1块内存挂在自由链表的对应位置下面
		size_t index = FREELIST_INDEX(n);
		_freeList[index] = (Obj*)(chunk + n);//将第二个对象的地址放在free_list中
		Obj* cur = _freeList[index];

		//将剩下的块依次挂在自由链表下面
		for (size_t i = 2; i < nobjs; ++i)
		{
			Obj* next = (Obj*)((char*)cur + n);
			cur->_freeListLink = next;
			cur = next;
		}
		cur->_freeListLink = NULL;//free_list的最后一个内存块指向NULL

		return chunk;//返回第一个内存块
	}

✔ 填充free_list函数ChunkAlloc()

ChunkAlloc()从内存池中取空间给free_list使用,但是存在下述三种情况

1.内存池中的可用空间足够开辟20个内存块时,将第一块返回给用户,剩余的19块挂接在free_list上。

2.内存池中的可用空间只能开辟[1,20)个时,返回至多能分配的内存块,第一块返回给用户,剩下的挂在free_list上。

3.内存池中的可用空间连一个内存块都无法开辟时,向操作系统申请空间。

上述第三步又衍生出两种情况:

1.如果向操作系统申请空间成功,则再次调用ChunkAlloc重新分配。

2.如果申请失败,则查看free_list上是否存在比n大的空闲。

上述第二步又衍生出两种情况

1.若存在,则将这个内存块返回给内存池,再次调用ChunkAlloc()来重新分配。

2.不存在,调用一级空间配置器开辟空间。

上述第二步又衍生出两种情况

1.调用一级配置器成功,更新heapSize和endFree,再次调用ChunkAlloc重新分配。

2.调用一级配置器失败,抛出异常。
//从内存池中取空间填充自由链表,每个对象大小为size,申请nobjs个
	static char* ChunkAlloc(size_t size, size_t& nobjs)
	{
		__TRACE_DEBUG("到内存池中期望取得[%u]个[%ubytes]对象\n", nobjs, size);

		size_t totalBytes = size*nobjs; //总共请求的内存大小
		size_t leftBytes = _endFree - _startFree;//内存池剩余空间大小

		//1.有足够nobjs个对象
		if (leftBytes >= totalBytes)
		{
			__TRACE_DEBUG("内存池拥有足够[%u]个对象的内存\n", nobjs);
			char *ret = _startFree;
			_startFree += totalBytes;//更新内存池的起始地址
			return ret;
		}

		//2.只够分配[1,size)个nobjs对象
		else if (leftBytes > size)
		{
			nobjs = leftBytes / size; //至多分配的对象个数
			__TRACE_DEBUG("内存池只有[%u]个对象的内存\n", nobjs);
			totalBytes = size*nobjs;
			char *ret = _startFree;
			_startFree += totalBytes; //更新内存池的起始位置
			return ret;
		}
		
		//3.一个对象都不能分配
		else
		{
			__TRACE_DEBUG("内存池连一个[%ubytes]的对象都没有\n", size);

			//处理残余的内存,挂在自由链表的对应位置
			if (leftBytes > 0)
			{
				size_t index = FREELIST_INDEX(leftBytes);
				((Obj*)_startFree)->_freeListLink = _freeList[index];//头插法
				_freeList[index] = (Obj*)_startFree;
			}

			size_t newBytes = 2 * totalBytes + ROUND_UP(_heapSize >> 4);
			__TRACE_DEBUG("向系统申请[%ubytes]给内存池\n", newBytes);
			_startFree = (char*)malloc(newBytes);//开辟新空间
			if (_startFree == NULL) //开辟失败
			{
				__TRACE_DEBUG("到系统申请内存失败\n");

				//内存已经吃紧,需要到更大的自由链表去取
				size_t i = FREELIST_INDEX(size);
				//在自由链表中找到一个更大的内存块
				for (size_t i = 0; i < __NFREELISTS; ++i)
				{
					if (_freeList[i])
					{
						//重新调整free_list
						Obj* ret = _freeList[i];
						_freeList[i] = ret->_freeListLink;
						_startFree = (char*)ret;
						_endFree = _startFree + (i + 1)*__ALIGN;
						return ChunkAlloc(size, nobjs); //开辟成功后,再次调用ChunkAllc分配内存
					}
				}

				//free_list没可用内存块时,调用一级配置器
				_startFree = (char*)_MallocAllocTemplate<0>::Allocate(newBytes);

			}

			//向系统申请内存成功
			//重新调整_heapSize与_endFree
			_heapSize += newBytes;
			_endFree  = _startFree + newBytes;
			return ChunkAlloc(size, nobjs);//递归调用ChunkAlloc,为了修正nobjs

		}
	}

二级空间配置器的流程图:
在这里插入图片描述
源代码见GitHub:STL 空间配置器

五、STL空间配置器的遗留问题

⑴ 二级空间配置器从头到尾都没有看到它释放内存,究竟是否释放,何时释放?

首先应该明确一点,二级配置器并没有将申请的空间释放,而是将它们挂在了自由链表上。空间配置器的所有方法,成员都是静态的,那么它们就存放在静态区,因此释放的时机也必定是程序结束时

⑵ 自由链表释放空间的连续性问题

真正在程序中就归还空间的只有自由链表中的未使用值,由于用户申请空间、释放空间顺序的不可控性,这些空间并不一定是连续的,而释放空间必须保证其连续性。保证连续的方案可以是:跟踪分配释放过程、记录节点信息,释放时,仅释放连续的大块空间。

⑶ 二级空间配置器的效率问题

二级配置器虽然解决了外碎片的问题,但同时也造成了内存片

  • 假如用户频繁的申请char类型的空间,而配置器默认对其到8的倍数,那么剩下的7/8的空间就会浪费。

  • 假如用户频繁申请8字节的空间,甚至将堆中的可用空间全部挂在了自由链表的第一个节点,这时如果申请一个16字节的内存,也会失败。这也是二级配置器的弊端所在,设置一个释放内存的函数是很有必要的

六、结语

黎明前的黑夜最让人胆战心惊!

猜你喜欢

转载自blog.csdn.net/qq_41035588/article/details/86446527
今日推荐