咱们在平常编写C++程序时,经常会用到咱们的STL标准库来帮助咱们解决问题,这当中咱们用得最多估计就是它里面的vector、list容器了,它们带来的便利不用多说(毕竟OJ、刷题什么的,基本全是它们的身影),而在平常学习中咱们对STL中另外一大组件—空间配置器 了解可能就相对较少了。不过它也是个有用的东西,之因此这么说,主要就在于它解决了在内存分配过程当中出现的内存碎片问题,具体就是
git
如上,对于一块从堆上分配的内存,因为对该块内存的释放一般是不肯定的,由于取决于用户,对于刚释放完的那32字节,虽归还给了os,但因为中间都是碎片化的内存,因此此时想利用那32字节再从os申请20字节内存便没法完成。
而在多线程环境下,这种内存碎片问题带来的影响就更大了,多个线程频繁的进行内存申请和释放,同时申请、释放的内存块有大有小;程序执行过程中这些碎片的内存就有可能间接形成内存浪费,再一个os要对这样频繁的操做管理,势必会影响到它的效率。github
STL中配置器老是隐藏在一切组间(具体地说是container)的背后,默默工做。但站在STL实现角度来看,咱们第一个须要搞清楚的就是空间配置器,由于咱们操做全部STL对象基本都会存放容器当中,而容器必定须要配置空间来置放资料的,不弄清它的原理,一定会影响之后对STL的深刻学习。
而在SGI STL中,std::alloc 为默认的空间配置器:
例如,vector<int, std::alloc> v
是的,它的写法好像并非标准的写法(标准写法应该是allocator),并且它也不接受参数!但这并不会给咱们带来困扰,由于它是默认的,不多须要咱们自行指定配置器名称。(至于为何不用allocator这个更标准的写法,这源于它的效率问题。具体能够参考STL源码剖析),今天主要来看看alloc版本配置器实现原理,加深咱们关于空间分配的理解。
配置器要完成的其实就是对象构造前的空间配置和对象析构后的空间释放。参考SGI中作法配置器对此设计要考虑:windows
基于此,alloc实现中设计了双层级配置器模型。一级配置器直接使用malloc和free,二级配置器则视状况采起不一样的策略,具体来说就是:当需求的内存块超过128字节时,就将其视为大块内存需求,便直接调用一级配置器来分配;当须要内存块< 128字节,便交由二级配置器来管理(这当中可能还联合一级配置器一块儿使用,具体缘由在后面)。数组
首先,一级配置器STL默认名一般是__malloc_alloc_template<0>
.在STL实现中将它typedef为了alloc
。再一个值得注意的则是:源于__USE_MALLOC
一般未定义,因此一级配置器并非STL中默认的配置器。
一级配置器模拟实现:安全
#pragma once #include <iostream> #include <windows.h> using namespace std; //一级空间配置器 typedef void(*HANDLE_FUNC)(); template <int inst> // inst为预留参数,方便之后扩展 class __MallocAllocTemplate { private: /*定义函数指针类型成员,方便回调执行用户 自定义的内存释放函数,该成员默认设置不执行*/ static HANDLE_FUNC __malloc_alloc_oom_handler; static void* OOM_Malloc(size_t n){ while (1){ if (0 == __malloc_alloc_oom_handler){ throw bad_alloc(); }else{ __malloc_alloc_oom_handler(); //释放内存 Sleep(200); void* ret = malloc(n); if (ret) return ret; } } } public: static void* Allocate(size_t n){ void *result = malloc(n); //malloc申请失败,执行OOM_Malloc再请求申请内存 if (0 == result) result = OOM_Malloc(n); cout<<"申请成功!"<<endl; return result; } static void Deallocate(void *p, size_t /* n */){ free(p); } /*设置oom_malloc句柄函数,*/ 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; //自定义的内存释放函数 static void FreeMemory(){ cout<<"执行用户自定义函数,开始释放内存..."<<endl; } void Test_Alloc1(); void Test_Alloc2();
当中的内存分配Allocate和释放Dellocate都是简单封装malloc和free,同时该类的成员函数中都是用static修饰的静态成员函数多线程
实现了一个static void* OOM_Malloc(size_t ) 函数 。这一般是在一次malloc调用失败后,再去调用它来抛出bad_alloc异常。但这里设计考虑它的扩展性。并发
终于实现完了一级配置器,惋惜的是咱们从前面就不难发现:这个单纯封装malloc、free的一级配置器貌似效率并不高吧~
其实,下面所述的二级配置器才是STL中真正具备设计哲学一个做品。函数
首先,当调用方需求的内存小于128字节时,此时便要利用二级配置器来分配内存了,固然不只仅如此,这个二级配置器还要进行内存回收工做。整个空间配置器正是由于它才能达到真正的迅速分配内存。至于原因则还要从它的组成结构开始提及
它的组成结构有两个:高并发
注意到有两个指针startFree、endfree,它们就至关于水位线的一种东西,它表示了内存池的大小。
自由链表中实际上是一个大小为16的指针数组,间隔为8的倍数。各自管理大小分别为8,16,24 . . . 120,128 字节的小额区块。在每一个下标下挂着一个链表,把一样大小的内存块连接在一块儿。(这貌似就是哈希桶吧!)
首先,当咱们的容器向配置器申请<128小块内存时,先就要从对应的链表中取得一块。具体就是:拿着申请内存大小进行近似除8的方法算得在这个指针数组中下标,紧接着就能够从链表中取出第一块内存返回。当一块内存用完,用户释放时,进行一样的操做,接着计算对于的下标再将该块内存头插到对应链表中。
(固然实际计算这些对应下标时,采用两个更准确、高效的函数,见后面,这里只是简单分析)
看看链表结点结构和连接
二级配置器中有一个这样结构
union Obj{ union Obj* _freelistlink; char client_data[1]; /* The client sees this. 用来调试用的*/ };
好了,咱们到这讨论的还处在一个大前提上——freelist下面挂有连接起来的小区块。当freelist上的某个位置下面没有挂上这些小区块呢?因此,这就是下面Refill,chunkAlloc这两个函数要干的事情了。
#pragma once #include "Allocator.h" /////////////////////////////////////////////////////////////////////// //二级空间配置器 template <bool threads, int inst> class __DefaultAllocTemplate { public: // 65 72 -> index=8 // 72 79 static size_t FREELIST_INDEX(size_t n){ return ((n + __ALIGN-1)/__ALIGN - 1); } // 65 72 -> 72 // 72 79 static size_t ROUND_UP(size_t bytes) { return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1)); } static void* ChunkAlloc(size_t size, size_t& nobjs);//获取大块内存 static void* Refill(size_t bytes); //填充自由链表 static void* Allocate(size_t n); //分配返回小内存块 static void Deallocate(void* p, size_t n); //管理回收内存 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; }; //__DefaultAllocTemplate成员初始化 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;
前面说了,当咱们需求的内存块在所对自由链表的下标处没挂有内存块时,咱们就必须调用refill去填充自由链表了。申请时通常一次性申请20个内存块大小的内存(可参加STL实现源码)。
那又从那里找呢?——固然内存池啦!分配这么大块内存到二级配置器就是如今来用的。能够经过移动startFree指针快速地从内存池内给“切割”出来这一段内存,而后按照大小切成小块挂在自由链表下面。在这个过程当中能够直接将第一小块内存块返回给用户,其他的再挂在自由链表下,方便下次分配了。
基于这样思路就能够将refill实现以下:
void* __DefaultAllocTemplate<threads, inst>::Refill(size_t bytes) { size_t nobjs = 20; /*默认从内存池取20块对象,填充*/ //从内存池中拿到一大块内存 char* chunk = (char*)ChunkAlloc(bytes, nobjs); if (nobjs == 1) /*只取到了一块*/ return chunk; size_t index = FREELIST_INDEX(bytes); printf("返回一个对象,将剩余%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; }
注:chunkAlloc向内存池索要内存
到此,咱们好像就会有一个疑问。既然简单移动startfree就能够欢快的从内存池取到得一块内存返回,那为何又要一次性取20块,返回一块,将剩下那19块挂到freelist对应位置下面呢?挨个挂上去还这么麻烦!每次都直接从内存池返回一块内存不是更欢快吗?在这里固然不用担忧出现外碎片问题。由于在每次内存释放时,能够添加到咱们维护的自由链表上,继续下次分配。
接下来就是chuncAlloc函数
它表示从内存池那一大块内存,同时也尽量保证内存池像水池同样有时刻有“水”。具体它遵循下面几条方针:
到了最后,一级配置器基于它的out-of-memory处理机制,或许有机会释放去其它的内存,而后拿来此处使用。若是能够那就成功“帮助”内存池,不然便发出bad_alloc异常通知使用者。
基于这样的思路,即可以模拟实现出ChunkAlloc函数
//function:从内存池申请一大块内存 template <bool threads, int inst> void* __DefaultAllocTemplate<threads, inst>::ChunkAlloc(size_t size, size_t& nobjs) { size_t totalbytes = nobjs*size; size_t leftbytes = _endfree - _startfree; //a) 内存池中有足够内存 if (leftbytes >= totalbytes){ printf("内存池有足够%u个对象的内存块\n", nobjs); void* ret = _startfree; _startfree += totalbytes; return ret; //b) 内存池仅剩部分对象内存块 }else if (leftbytes > size){ nobjs = leftbytes/size; /*保存可以使用对象块数*/ totalbytes = size*nobjs; printf("内存池只有%u个对象的内存块\n", nobjs); void* ret = _startfree; _startfree += totalbytes; return ret; //c) 内存池中剩余内存不足一个对象块大小 }else{ // 1.先处理掉内存池剩余的小块内存,将其头插到对应自由链表上 if(leftbytes > 0){ size_t index = FREELIST_INDEX(leftbytes); ((Obj*)_startfree)->_freelistlink = _freelist[index]; _freelist[index] = (Obj*)_startfree; } // 2.调用malloc申请更大的一块内存放入内存池 size_t bytesToGet = totalbytes*2 + ROUND_UP(_heapsize>>4); _startfree = (char*)malloc(bytesToGet); printf("内存池没有内存,到系统申请%ubytes\n", bytesToGet); if (_startfree == NULL){ //3. malloc申请内存失败,内存池没有内存补给,到更大的自由链表中找 size_t index = FREELIST_INDEX(size); for (; index < __NFREELISTS; ++index){ //自由链表拿出一块放到内存池 if (_freelist[index]){ _startfree = (char*)_freelist[index]; //BUG ?? Obj* obj = _freelist[index]; _freelist[index] = obj->_freelistlink; return ChunkAlloc(size, nobjs); } } _endfree = NULL; /*in case of exception. !!保证异常安全*/ //逼上梁山,最后一搏. 若内存实在吃紧,则一级配置器看看out-of-memory可否尽点力,不行就抛异常通知用户 _startfree = (char*)__MallocAllocTemplate<0>::Allocate(bytesToGet); } _heapsize += bytesToGet; _endfree = _startfree + bytesToGet; //递归调用本身,为了修正nobjs return ChunkAlloc(size, nobjs); } }
这里也还要注意一个点:就是_endfree= NULL
这样一个操做
这句话很容易被咱们忽略掉。这实际上是十分重要的一个操做,这关乎到异常安全问题,在内存池穷山尽水之时,它取调用了一级配置器,但愿一级配置器可否释放一些内存,在chunkAlloc内能够malloc成功,但一般这都是失败的,因此一级配置器便抛出了异常,然而异常抛出并不意味着程序结束,此时的endfree并不为NULL而且多是较大的数,(endfree保持之前的值)此时的startfree指针是为NULL的。这二者的差值表示着内存池有着大块的内存,然而这已不属于内存池了。
不管alloc被定义为第一级或第二级配置器,SGI还为它包装了一个接口Simple_alloc,使配置器接口符合STL规格:
#ifdef __USE_MALLOC typedef __MallocAllocTemplate<0> alloc; #else typedef __DefaultAllocTemplate<false, 0> alloc; #endif template<class T, class Alloc> class SimpleAlloc { public: static T* Allocate(size_t n){ return 0 == n? 0 : (T*) Alloc::Allocate(n * sizeof (T)); } static T* Allocate(void){ return (T*) Alloc::Allocate(sizeof (T)); } static void Deallocate(T *p, size_t n){ if (0 != n) Alloc::Deallocate(p, n * sizeof (T)); } static void Deallocate(T *p){ Alloc::Deallocate(p, sizeof (T)); } };
这里面内部四个成员函数其实都是单纯的转调用,调用传递给配置器的成员函数,这个接口时配置器的配置单位从bytes转为了个别元素的大小。SGI STL中容器所有使用simple_alloc接口,例如
template< class T, class Alloc= alloc> class vector{ protected: //专属空间配置器,每次配置一个元素大小 typedef simple_alloc<value_type, Alloc> data_allocator; void deallocate(){ if(...) data_allocator::deallocate(start, end_of_storage- start); } ... };
为了将问题控制在必定复杂度内,到此以上的这些,仅仅处理了单线程的状况。对于并发的状况,它的处理过程会相对更复杂。咱们能够查看STL中空间配置器的源码实现来进一步的学习,这当中又会体现出不少优秀的思想,
STL配置器还有许多其它优秀设计,这里只是本人对它的部分认识。为了加深理解,咱们能够查看STL中源码进行更深刻学习。
模拟总体实现:https://github.com/tp16b/project/tree/master/alloc/src
参考:《STL源码剖析》