STL剖析之空间配置器的实现

STL有六大组件:容器、适配器、空间配置器、仿函数、迭代器、算法,这篇博客我主要对空间配置器的简要解析。

空间配置器:空间配置器就像是默默工作在其他组件(更准确的说是容器)的背后,一般通常情况下呢?这种默默付出的东西才是人们最应该注意到的东西,才是需要我们要去深入底层去剖析的。我们都知道,整个STL的操作对象都存放在容器之中,而容器一定需要足够的空间去存储这些对象/数据,所以在整个STL的学习中,最先探索的就是空间配置器的原理,才能为后续剖析其他组件的时候铺下垫脚石。

先来思考这样一个问题,为什么要存在空间配置器??我们想要存储数据/对象直接去malloc/free就可以了呀!是的,没毛病,但是这样做会带来两个问题:

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

 单从分配的角度来看。由于频繁分配、释放小块内存容易在堆中造成外碎片(极端情况下就是堆中空闲的内存总量满足一个请求,但是这些空闲的块都不连续,导致任何一个单独的空闲的块都无法满足这个请求)

比如:黄色箭头表示尚未分配出去的内存,蓝色箭头表示已经分配出去的内存块,现在总共剩余20KB的资源,我们想再去申请15KB,很显然,我们是申请不到的。

 2.效率问题

由于小块内存的频繁申请与释放,带来的内存外碎片的问题,一旦出现这些碎片,如上面的例子中明明剩余空间是够15KB的,但是这两块空间不是连续的, 在开辟空间的时候,分配器会去找一块空闲块给用户,找空闲块也是需要时间的,尤其是在外碎片比较多的情况下。如果分配器其找不到,就要考虑处理假碎片现象(释放的小块空间没有合并),这时候就要将这些已经释放的的空闲块进行合并,这也是需要时间的。另外 malloc在开辟空间的时候,这些空间会带有一些附加的信息,这样的话也就造成了空间的利用率有所降低,尤其是在频繁申请小块内存的时候。

基于上述两种原因,就出现了我们的空间配置器,SGI版的特殊的空间配置器(std::alloc),一般而言,我们在写C++程序的时候,内存的分配和释放是这样的:

class A{...};
A* p=new A;//先去申请内存,再调用类A的构造函数
delete p;//先调用类A的析构函数,再取释放内存

在我前面的一篇博客中解析了new和delete的底层机制,申请内存先去调用了operator new,operator new这个函数实际上是malloc的一层封装,delete在释放内存的时候调用的是operator delete,这个函数本质上是free的一个封装。

那在我们的SGI的allocator中,为了精密分工,它将这两阶段的操作分离了开来,内存的申请有alloc::allocate()函数来完成,内存释放由alloc::deallocate()负责,对象的构造由construct()完成,对象的析构由destroy()函数负责。

STL标准告诉我们,配置器定义于<memory>中,SGI<memory>内包含两个文件:

#include<stl_alloc.h>      //负责内存空间的配置和释放
#include<std_construct.h>  //负责对象的构造与析构

关于对象的构造和析构是具体怎样实现的这里我不做过多说明,比较简单(可参考STL源码剖析P50);

了解了内存配置后的对象构造行为和内存释放前的对象析构行为,我们现在来看一下内存是怎样配置的以及是怎样释放的?

这部分实现定义在stl_alloc中,SGI的设计思想如下:

                                                                           1.向系统堆来申请空间

                                                                           2.实现内存池,考虑多线程情况,避免出现线程不安全的情况

                                                                           3.考虑到了内存不足时候的应变措施

                                                                            4.考虑到了频繁申请释放内存带来的内存外碎片问题

为了解决内存碎片问题,SGI设计了双层级的配置器,第一级配置器直接使用malloc()和free(),第二级配置器视情况有对应的策略,当要申请的内存大于128bytes时候,认为足够大,便去调用第一级配置器,当申请的内存小于128bytes时候,认为比较小,为了降低额外的频繁申请释放内存带来的问题,引入了内存池(memory pool)来进行管理,而不再调用一级配置器。

在SGI::alloc的实现中,究竟是只开放一级配置器还是一二级同时开放,取决于__USE_MALLOC这个宏是否被定义,

实际上呢?这个宏并没有被定义出来。所以当我们使用空间配置器的时候,默认情况下使用的都是第二级空间配置器(__default_alloc_template)。

先来了解一下一级空间配置器,一级空间配置器很简单,直接封装了malloc和free处理,增加_malloc_alloc_oom_handle处理机制。一级空间配置器的工作流程如下:

在stl_alloc.h中,它仅仅只是声明了这个函数,并未实现

_malloc_alloc_oom_handle这个函数是自定义函数,处理“内存不足的处理方法”,这个函数的设计个人认为难度还是很大的,这个函数主要是在释放一些没什么用的内存来达到期望再一次申请的时候有可用内存,它模仿了C++中的new-handler机制,又提到了一个新的概念new-handler,那么C++中的new-handler机制是什么意思呢?其实就是在申请内存的时候剩余内存无法满足需求,这个时候就可以调用一个自定义的函数,换句话说,一旦operator new无法完成任务,在抛出std::bad_alloc之前,会先去调用这个自定义的函数,但是它不能直接运用C++中的new-handler机制,因为SGI是以malloc而非operator new()来配置内存的,另一个原因是C++没有提供realloc()这样的内存分配操作,因此SGI不能直接使用set_new_handler(),只能去模拟实现一个类似的函数。

一级空间配置器的模拟实现如下:

template <int inst> // 预留参数 instance
class __MallocAllocTemplate {
private:

	static HANDLE_FUNC __malloc_alloc_oom_handler;
 
	static void* OOM_Malloc(size_t n)
	{
		while (1)//内循环不断来调用“内存不足处理函数”,期望在某次调用后,可以成功返回
		{
			if (__malloc_alloc_oom_handler == 0)//没定义这个函数,就抛出异常
			{
				throw bad_alloc();
			}
			else
			{
				__malloc_alloc_oom_handler();
				void* ret = malloc(n);
				if (ret)
					return ret;
			}
		}
	}
public:
    //开辟内存
	static void* Allocate(size_t n)
	{
		void *result = malloc(n);
		if (0 == result)
			result = OOM_Malloc(n);

		return result;
	}
    //释放内存
	static void Deallocate(void *p, size_t /* n */)
	{
		free(p);
	}
    //内存不足处理
	static HANDLE_FUNC SetMallocHandler(HANDLE_FUNC f)
	{
		HANDLE_FUNC old = f;
		__malloc_alloc_oom_handler = f;
		return old;
	}
};

template<int inst>
//自定义内存不足处理函数,这里并未实现,能力有限
HANDLE_FUNC __MallocAllocTemplate<inst>::__malloc_alloc_oom_handler = 0;

void FreeMemory()
{
	cout<<"释放内存"<<endl;
}

void Test_Alloc1()
{
	__MallocAllocTemplate<0>::SetMallocHandler(FreeMemory);
    //尝试去申请一块巨大的空间必然会崩溃
	void* p = __MallocAllocTemplate<0>::Allocate(0x7fffffff);
	__MallocAllocTemplate<0>::Deallocate(p, 40);
}

引用侯捷老师的一句话:记住,设计“内存不足处理例程”是客端的责任,设定“内存不足处理例程”也是客端的责任,“内存不足处理例程”解决问题有着特定的模式,具体参考[Meyers98]条款7。

二级空间配置器

  二级空间配置器使用内存池+自由链表的形式避免了小块内存带来的碎片化,提高了分配的效率,提高了利用率。SGI的做法是先判断要开辟的大小是不是大于128,如果大于128则就认为是大块内存,调用一级空间配置器直接分配。否则的话就通过内存池来分配,假设要分配8个字节大小的空间,那么它就会去内存池中分配多个8个字节大小的内存块(默认为20个),将多余的挂在自由链表上,下一次再需要8个字节时就去自由链表上取就可以了,如果回收这8个字节的话,直接将它挂在对应位置的自由链表上就可以了。

 为了便于管理,二级空间配置器在分配的时候都是以8的倍数对齐(为什么是8,不是2,3,7,12类似于这样数据呢?要知道自由链表里面你至少得放一个指针将这些节点连接起来吧,所以至少得容得下一个指针的大小,为了同时适应32位系统和64位系统,就以8个字节为上调边界),也就是说二级配置器会将任何小块内存的需求上调到8的倍数处(例如:要7个字节,会给你分配8个字节。要9个字节,会给你16个字节),尽管这样做有内碎片的问题,但是对于我们管理来说却简单了不少。因为这样的话只要维护16个free_list就可以了,free_list这16个结点分别管理大小为8,16,24,32,40,48,56,64,72,80,88,86,96,104,112,120,128bytes大小的内存块就行了。

free-lists的节点结构如下:

union obj{
    union obj* free_list_link;//自由链表的指针,可指向下一个obj*
    char client_data[1];      //指向实际内存区块
};

这里巧妙的用到了联合体,达到了一物二用的效果,不会为了维护链表所必须的指针而造成的内存的浪费。

下面来看一下内存池的结构:

自由链表结构如下:

free_list是一个指针数组,类似于哈希桶的存储,数组的每一个元素里面都存放了一个obj*的指针指向下一个节点把这些obj对象链接起来。

二级空间配置器内存申请的逻辑步骤如下:假如现在申请n个字节:

第一步:判断n是否大于128,如果大于128则直接调用一级空间配置器。如果小于128,则将n上调至8的整数倍数处,然后再去自由链表中相应的结点下面找,如果该结点下面挂有未使用的内存,则摘下来直接返回这块空间。否则的话我们就要调用refill(size_t n)函数去内存池中申请。

第二步:STL默认一次申请nobjs=20个,将剩余的19个挂在自由链表上,这样能够提高效率,方便下一次申请的时候直接在自由链表下拿就可以了。(这里可能我们会思考一个点,直接去内存池让_start向后移动n个字节再返回这块内存就好了呀,为什么要一次申请那么多,再把他们挂在对应位置下面,岂不是很浪费时间。是很浪费时间,但是我们的SGI采用后者是考虑得更严谨了,考虑到了线程安全的问题,防止后一个线程申请资源进行写操作的时候覆盖了前一个线程写进去的内容) 进入refill函数后,先调chunk_alloc(size_t n,size_t& nobjs)函数去内存池中申请,如果申请成功的话,再回到refill函数。接下来就有两种情况,如果nobjs=1的话则表示内存池只够分配一个,这时候只需要返回这个地址就可以了。否则就表示nobjs大于1,则将多余的内存块挂到自由链表上。如果chunk_alloc失败的话,在他内部有处理机制。

第三步:进入chunk_alloc(size_t n,size_t& nobjs )向内存池申请空间的话有三种情况:

     1.内存池剩余的空间足够nobjs*n这么大的空间,则直接分配好返回就可以了。

     2.内存池剩余的空间leftAlloc的范围是n<=left_alloc<nobjs*n,大于1个对象的大小但是小于想要内存的大小,则这时候就分配           nobjs=(left_alloc)/n这么多个的空间返回,有多少算多少。

     3、内存池中剩余的空间连一个对象大小都不够了,这时候就要向系统堆申请内存,不过在申请之前先要将内存池中剩余的内              存挂到自由链表上。

         (1)如果申请成功的话,则就再调一次chunk_alloc重新分配。

         (2)如果不成功的话,这时候再去自由链表中看看有没有比n大的空间,如果有就将这块空间还给内存池,然后再调一次                       chunk_alloc重新分配。

        (3)如果没有的话,则就调用一级空间配置器分配,看看内存不足处理机制能否处理。

总的来概括申请的次序如下:

                     1.先去它对应的位置下面进行申请
                      2.去提前申请好的内存区域(内存池)申请
                      3.去系统申请malloc
                      4.去它后面更大的自由链表去申请
                      5.一级空间配置器去申请

流程图如下:

二级空间配置器代码实现如下:

///////////////////////////////////////////////////////////////////////
//二级空间配置器
template <bool threads, int inst>
class __DefaultAllocTemplate
{
public:
	//找到申请内存块对应的是哪个自由链表
    //
	static size_t FREELIST_INDEX(size_t n)
	{
		return ((n + __ALIGN-1)/__ALIGN - 1);
	}

	//上调到8的整数倍
    //我们本来可以使用((bytes+__ALIGN-1)/__ALIGN)*ALIGN这种方式,但是除法运算对于CPU来说还是很耗时间的,对于CPU密 集型情况(比如频繁的申请内存)来说,效率比较低,使用位运算可以提高效率
  //&~(__ALOGN-1),刚好去掉了被ALIGN除的余数
	static size_t ROUND_UP(size_t bytes)
	{
		return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1));
	}

	static void* ChunkAlloc(size_t size, size_t& nobjs)
	{
		size_t totalbytes = nobjs*size;
		size_t leftbytes = _endfree - _startfree;

		if (leftbytes >= totalbytes)
		{
			__TRACE_DEBUG("内存池有足够%u个对象的内存块\n", nobjs);

			void* ret = _startfree;
			_startfree += totalbytes;
			return ret;
		}
		else if (leftbytes > size)
		{
			nobjs = leftbytes/size;
			totalbytes = size*nobjs;
			__TRACE_DEBUG("内存池只有%u个对象的内存快\n", nobjs);

			void* ret = _startfree;
			_startfree += totalbytes;
			return ret;
		}
		else
		{
			// 先处理掉剩余的小块内存
			if(leftbytes > 0)
			{
				size_t index = FREELIST_INDEX(leftbytes);
				((Obj*)_startfree)->_freelistlink = _freelist[index];
				_freelist[index] = (Obj*)_startfree;
			}

			// 堆上申请,_heapsize表示已经申请了多少内存
			size_t bytesToGet = totalbytes*2 + ROUND_UP(_heapsize>>4);
			_startfree = (char*)malloc(bytesToGet);

			__TRACE_DEBUG("内存池没有内存,到系统申请%ubytes\n", bytesToGet);

			if (_startfree == NULL)
			{
				// 到更大的自由链表中找
				size_t index = FREELIST_INDEX(size);
				for (; index < __NFREELISTS; ++index)
				{
					if (_freelist[index])
					{
						Obj* obj = _freelist[index];
						_freelist[index] = obj->_freelistlink;
                        //这里找到的话肯定大于1个对象了,进行递归下一次肯定走的是else if语句,
                         //然后向下走再一次处理剩余的内存
						return ChunkAlloc(size, nobjs);
					}
				}

				// 调用一级空间配置器,这里没判断是否成功,因为一级空间配置器里面有自定义的
               //set_new_handler()函数,没有定义就抛出异常
				_startfree = (char*)__MallocAllocTemplate<0>::Allocate(bytesToGet);
			}
           
			_heapsize += bytesToGet;
			_endfree = _startfree + bytesToGet;
			return ChunkAlloc(size, nobjs);
		}
	}
	
	static void* Refill(size_t bytes)
	{
		size_t nobjs = 20;
		char* chunk = (char*)ChunkAlloc(bytes, nobjs);
		if (nobjs == 1)
			return chunk;

		size_t index = FREELIST_INDEX(bytes);
		__TRACE_DEBUG("返回一个对象,将剩余%u个对象挂到freelist[%u]下面\n", nobjs-1, index);

		Obj* cur = (Obj*)(chunk + bytes);
		_freelist[index] = cur;
		for (size_t i = 0; i < nobjs-2; ++i)
		{
			Obj* next = (Obj*)((char*)cur + bytes);
			cur->_freelistlink = next;

			cur = next;
		}

		cur->_freelistlink = NULL;

		return chunk;
	}

	static void* Allocate(size_t n)
	{
		__TRACE_DEBUG("二级空间配置器申请%u bytes\n", n);
         //要申请的字节大于128,认为是大块,直接调用一级空间配置器
		if (n > __MAX_BYTES)
		{
			return __MallocAllocTemplate<0>::Allocate(n); 
		}
         //先去对应位置的自由链表去拿
		size_t index = FREELIST_INDEX(n);
		if (_freelist[index] == NULL)
		{
			__TRACE_DEBUG(" freelist[%u]下面没有内存块对象,需要到内存池申请\n", index);

			return Refill(ROUND_UP(n));
		}
		else
		{
			__TRACE_DEBUG(" freelist[%u]取一个对象返回\n", index);

			Obj* ret = _freelist[index];
			_freelist[index] = ret->_freelistlink;
			return ret;
		}
	}

	static void Deallocate(void* p, size_t n)
	{
		if (n > __MAX_BYTES)
		{
			__MallocAllocTemplate<0>::Deallocate(p, n);
		}
		else
		{
			size_t index = FREELIST_INDEX(n);
			__TRACE_DEBUG("二级空间配置器释放对象,挂到freelist[%u]下\n", index);

			((Obj*)p)->_freelistlink = _freelist[index];
			_freelist[index] = (Obj*)p;
		}
	}

private:
	enum {__ALIGN = 8};
	enum {__MAX_BYTES = 128};
	enum {__NFREELISTS = __MAX_BYTES/__ALIGN};

	union Obj
	{
		union Obj* _freelistlink;
		char client_data[1];    /* The client sees this.        */
	};

	// 自由链表
	static Obj* _freelist[__NFREELISTS];

	// 内存池
	static char* _startfree;
	static char* _endfree;
	static size_t _heapsize;
};

template <bool threads, int inst>
typename __DefaultAllocTemplate<threads, inst>::Obj*
__DefaultAllocTemplate<threads, inst>::_freelist[__NFREELISTS] = {0};

// 内存池
template <bool threads, int inst>
char* __DefaultAllocTemplate<threads, inst>::_startfree = NULL;

template <bool threads, int inst>
char* __DefaultAllocTemplate<threads, inst>::_endfree = NULL;

template <bool threads, int inst>
size_t __DefaultAllocTemplate<threads, inst>::_heapsize = 0;

void Test_Alloc2()
{
	for(size_t i = 0; i < 20; ++i)
	{
		__DefaultAllocTemplate<false, 0>::Allocate(6);
	}

	__DefaultAllocTemplate<false, 0>::Allocate(6);
}

#ifdef __USE_MALLOC
	typedef __MallocAllocTemplate<0> alloc;
#else
	typedef __DefaultAllocTemplate<false, 0> alloc;
#endif

关于二级空间配置器有以下几点需要说明:

1、在空间配置器中所有的函数和变量都是静态的,所以他们在程序结束的时候才会被释放。二级空间配置器中没有将申请的内存还给操作系统,只是将他们挂在自由链表上。所以说只有当你的程序结束了之后才会将开辟的内存还给操作系统。

2、由于它没有将内存还给操作系统,所以就会出现两种极端的情况。

  (1)假如我不断的开辟小块内存,最后将整个heap上的内存都挂在了自由链表上,但是都没有用这些空间,再想要开辟一个大块内存的话会开辟失败。

  (2)再比如我不断申请8个字节,最后将整个heap内存全挂在了自由链表的第一个结点的后面,这时候我再想开辟一个16个字节的        内 存,也会失败。

 3、二级空间配置器会造成内存内碎片问题,极端的情况下一直申请八个字节,但是平均来说,二级空间配置器的效率还是不错的。

我突然想到了一个点,就比如前面提到每次申请8个字节,把堆上资源的干干净净,最后只有自由链表第一个元素下面挂了几个8字节的内存块,现在我想申请16字节,这个时候是不是可以把第一个元素下面的内存块拆下来放在内存池进行合并,解决一时之需呢?

猜你喜欢

转载自blog.csdn.net/qq_39344902/article/details/81624709