以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字节的内存,也会失败。这也是二级配置器的弊端所在,
设置一个释放内存的函数是很有必要的
。
六、结语
黎明前的黑夜最让人胆战心惊!