浅析二级空间配置器
SIG对于空间配置器的设计哲学为:
1.向system heap 要求空间
2.考虑多线程的状态
3.考虑内存不足的应变措施
4.考虑过多的"小型区块"可能造成的空间碎片问题.
在我的上一个博客当中我们认识了一级空间配置器,但是我们在最后引出来了一个外碎片的问题,这是一级空间配置器没有办法解决. 外碎
片其实就是
系
统剩余的内存明明足够,但是由于开辟太多了不连续的小块空间,导致没有办法开辟出来大块的内存. 具体理解可以看看
下面这个图:
并且频繁的开辟小块内存效率也不够高,举个例子来讲比如你一共需要20块空间,我是一次性拿给你好呢? 还是等你想要的时候过来给你
切一个然后
切20次呢? 我们其实发现对于频繁开辟小块内存的问题,我们直接事先给它分配一块比较大的连续空间,
,然后让它每次申请
的时候就不要跑到我系统
内存这里来申请,直接去那个给你申请的那一块连续空间里面去分配. 这样不就解决了外碎片的问题了! 其实STL
做的更加的全面细致. 我们来看看
STL是怎
么解决的??
二级空间配置器的结构.
在STL当中如果在我们分配内存空间时,用户要求的空间大小大于128bytes时,那么系统认为这是一个大块内存的申请直接调用一级空间配
置器. 如果
小于128bytes时,系统认为是小块内存的申请所以调用二级空间配置器来解决这个问题. 现在我们来看看系统中的二级空间配
置器是一个什么结构:
上图就是一个最基本的二级空间配置器,我们可以看到它很像我们当时的哈希表,不过又有一点不同. 我们现在看到了一个free_List数组
结构. 它每
一个元素对应一个内存的大小,然后该位置下面挂对应内存大小的内存块. 这些内存块以链式结构连接就和链表相似. 比如0号
下标对应内存大小为8,
所以它下
面挂的内存块都为8,然后这些内存块像链表一样挂起来.
好,现在我们尝试一下申请空间的过程:
1:比如我们的想向系统申请32个bytes,这个时候我们的申请内存小于128,进入二级空间配置器. 再然后在
free_List当中寻找32对应的下
标,找到3号
下标,从里面拿掉第一个内存块返回给用户. 然后让下标元素的值指向拿走的内存块的下一个内存块. (如果是空,那么下面
没有内存可用)
2:第二种情况比如我们想向系统申请24bytes大小的内存空间,这个时候我们找到了对应下标的位置,但是发现下面并没有内存块. 这个时
候系统大手
笔直接分给你20个对应大小的内存块.然后全都挂到你这个下标位置,当你下次来申请该大小内存的时候,直接就和第一步一样
了.
3:第三种情况我想向系统申请25bytes大小的内存空间,我们发现这个时候我们找不到对应的下标了,这个时候我们只能给他申请32bytes
大小的内存
块,因为如果给了用户24bytes的内存用户是无法使用的,给了用户32bytes的内存用户使用完了会有一点剩余,所以我们碰到这
种
情况,会相应给用户
相应大一点的内存块. 不过这样做也是无奈之举,它也是有缺陷的->浪费空间(申请了32bytes内存空间,只使用了
25bytes内存空间). 这就是内碎片的
问题.
上述第三种情况就会引发我们的内碎片的问题. 不过这也是一个无奈之举啊. 相比于外碎片内碎片其实好多了. 有人又开始思考了,那怎
么消除
内碎片
的问题. 这里我可以很明确的告诉你
不可能!
因为我们的内存块最小的单位为8bytes,最大的是128bytes. 有人又说了那
让
每个内存块间隔为1. 然
后让free_list有120个元素,这样每个大小的内存块都有自己对应的位置. 这样想的初衷是好的. 但是你想过
没有如果你这样搞. 每个对应位置下面
挂满内存块,一个二级空间配置器就占用了多少内存? 所以你还是老老实实的接受内碎片了吧.
又有人想了为什么内存块最小的单位一定就要是8bytes? 为什么不是1bytes? 感觉是1bytes更节省空间啊. 这样想还是没有错的,只是
少了全局思考
我们上面都说了内存块是像链表一样链接起来,而这样就需要指针来链接了. 32位平台下指针4bytes,64位平台下指针
8bytes.所以我们为了可移植性
内存块最小也要容纳一个指针,所以呢我们的8bytes的由来就是这样啦.
我们再思考一个问题,我们是直接去系统空间中申请空间快还是去二级空间配置器中申请空间快? 答案很简单肯定是去系统空间中申请快
!
动一动
start_Free指针就好了! 去二级空间配置器当中还要先找到位置,再然后找到内存块交给用户. 但是二级空间配置器肯定也有自己
的好处,它可以
解决一些线程安全的问题. 大家都知道多线程编程当中所有的线程共用一个PCB.所以我们在多线程当中也会公用同一个内存
空间.也就是我们的内存
池,比如我们两个线程同时执行,如果你申请24bytes,我申请32bytes(假装没有二级空间配置器). 他两个都要从
系
统空间
当中获取内存.然后他们同
时
拿到start_Free指针. 然后线程1先申请了24bytes将start_Free指针往后挪24个单位.这个时候线程2拿
到原始的start_Free指针申请32个bytes并且
往后挪32个单位. 这就是线程的资源竞争, 系统内存就会变成下图的样子了:
我们上述的情况就是图中多线程竞争的情况. 我们看到线程二并不知道自己和线程1共用了内存空间,以后访问修改内存的时候一定会出现
问题. 这种
情况是一定要加锁的. 而我们的二级空间配置器根本不会发生这样的情况. 因为线程1和线程2申请内存空间的位置并不在
同一个地方. 当然如果两个
线程申请同样的内存大小的空间时在二级空间配置器也会发生线程安全问题. 所以在二级空间配置器当中申请
空间是一定要加锁的. 虽然他和
系统空间
都要加锁,但是二级空间配置器的效率很明显高于系统空间. 我们都知道加锁后另一个线程需要
等
待,在系统空间中不管谁申请内存我都要等待前一
个
线
程
申请空间完成,而
二级空间配置器当中只有你跟我申请同样大小内存块的时候
才需要等待我申请完,因为大小不同时我们都不在同一个地方申请内
存
空
间.
所以二级空间配置器在线程安全这里也有一定的作用. 并且
效率高于直接在内存池中申请
.
二级空间配置器源码剖析
我们想思考一下二级空间配置器的具体实现思想,如下:
1.如果要分配的区块大于128bytes,则移交第一级空间配置器处理.
2.如果要分配的区块小于128bytes,则交给二级空间配置器处理. 每次配置一大块内存,并维护对应的自由链表.下次若有相同大小的内
存需求,则直
接
从free_list中取. 如果有小额区块被释放,则有配置器回收到free_list当中. 我们使用union表示内存块节点结构:
union obj {
union obj * free_list_link;//指向下一个节点
char client_data[1]; /* The client sees this. */
};
为了维护链表,每个节点都需要额外的指针,这不又造成另一种负担吗? 你的顾虑是对的,但早已有好的解决方法. union能够实现一
物两用的效果,
当节点所指的内存块是空闲块时,obj被视为一个指针,指向另一个节点. 当节点已经被分配时,被视为一个指针.
指
向实际区块. 我们来看看二级空间
配置器主要函数声明和具体框架:
template <bool threads, int inst>
class __default_alloc_template {
private:
//effective C++ 中条款二: 尽量使用const enum inline替换#define.
# ifndef __SUNPRO_CC
enum {__ALIGN = 8};
enum {__MAX_BYTES = 128};
enum {__NFREELISTS = __MAX_BYTES/__ALIGN};
# endif
static size_t ROUND_UP(size_t bytes) {
return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1));
}
__PRIVATE:
union obj {
union obj * free_list_link;
char client_data[1]; /* The client sees this. */
};
private:
# ifdef __SUNPRO_CC
static obj * __VOLATILE free_list[];
// Specifying a size results in duplicate def for 4.1
# else
static obj * __VOLATILE free_list[__NFREELISTS];
# endif
static size_t FREELIST_INDEX(size_t bytes) {
return (((bytes) + __ALIGN-1)/__ALIGN - 1);
}
// Returns an object of size n, and optionally adds to size n free list.
static void *refill(size_t n);
// Allocates a chunk for nobjs of size "size". nobjs may be reduced
// if it is inconvenient to allocate the requested number.
static char *chunk_alloc(size_t size, int &nobjs);
// Chunk allocation state.
static char *start_free;
static char *end_free;
static size_t heap_size;
/* n must be > 0 */
static void * allocate(size_t n){...}
/* p may not be 0 */
static void deallocate(void *p, size_t n){...}
static void * reallocate(void *p, size_t old_sz, size_t new_sz);
template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::start_free = 0;//内存池起始位置
template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::end_free = 0;//内存池结束位置
template <bool threads, int inst>
size_t __default_alloc_template<threads, inst>::heap_size = 0;
template <bool threads, int inst>
__default_alloc_template<threads, inst>::obj * __VOLATILE
__default_alloc_template<threads, inst> ::free_list[
# ifdef __SUNPRO_CC
__NFREELISTS
# else
__default_alloc_template<threads, inst>::__NFREELISTS
# endif
那我们现在开始申请空间,我们申请一个小于128bytes的内存空间的时候,调用了二级空间配置器的allocate函数,我们来看看
allocate函数的源码:
static void * allocate(size_t n)
{
obj * __VOLATILE * my_free_list;
obj * __RESTRICT result;
if (n > (size_t)__MAX_BYTES) {
return(malloc_alloc::allocate(n)); //调用一级空间配置器
}
my_free_list = free_list + FREELIST_INDEX(n);
//FREELIST_INDEX(n)函数是用来获取free_list下标的,一会会提到的.
# ifndef _NOTHREADS
//防止线程资源竞争,加锁.
lock lock_instance;
# endif
//这个过程是将头部的内存空间返回给用户,然后让下标元素的值指向下面的内存块
//类似链表的头删过程,不过是将拿掉的内存块返回给用户了.
//refill(ROUND_UP(n)) 是用来向系统内存申请空间的.
//ROUND_UP(n) 这个函数是为了让n和8的倍数对齐. 申请空间单个的单位一定是free_list中对应的内存块大小.
result = *my_free_list;
if (result == 0) {
void *r = refill(ROUND_UP(n));
return r;
}
*my_free_list = result->free_list_link;
return (result);
};
我们申请到对应大小的内存空间其实非常容易,如果你对应内存大小的那个下标位置下面挂有内存块,就不需要调用refill函数. 直
接拿
走内存块即可
过程如图:
如果对应内存大小下标位置没有一个内存块的时候我们就需要向系统申请内存了,这个时候调用refill函数.
我们再来看看refill函数的源代码:
template <bool threads, int inst>
void* __default_alloc_template<threads, inst>::refill(size_t n)
{
//每次refill的时候,二级空间配置器希望直接向内存申请20块相同大小的内存块,这样就不用反复的调用自己
//这样做就是我上面举到的例子 为了节约效率. 然后我们定一个nobjs,将它传给chunk_alloc函数.
//具体申请空间的过程扔给chunk_alloc函数去做,refill专心做链接内存块的事情.
int nobjs = 20;
char * chunk = chunk_alloc(n, nobjs);
obj * __VOLATILE * my_free_list;
obj * result;
obj * current_obj, *next_obj;
int i;
//如果chunk_alloc函数只申请到一个内存块,那么直接返回.
if (1 == nobjs) return(chunk);
//这里说明chunk_alloc申请的内存块不止一个,可以进行链接内存块.
my_free_list = free_list + FREELIST_INDEX(n);
//下面的过程是链接内存块的过程.
//这里的my_free_list和next_obj 都是直接指向chunk+n的位置的. 因为第一个内存块需要返回给用户使用.
result = (obj *)chunk;
*my_free_list = next_obj = (obj *)(chunk + n);
for (i = 1;; i++) { //i=0的那部分已经为用户准备好了是要返回的空间.
current_obj = next_obj;
next_obj = (obj *)((char *)next_obj + n);
if (nobjs - 1 == i) {
current_obj->free_list_link = 0;
break;
}
else {
current_obj->free_list_link = next_obj;
}
}
return(result);
}
我们再来看
FREELIST_INDEX和
ROUND_UP函数的源代码,让我们看看为什么STL这么强大! 为什么STL的代码都是极品.
他两个功能分别是FREELIST_INDEX在free_list当中找到相应的下标. ROUND_UP将n 保证为8的倍数并且这个数大于n.
static size_t ROUND_UP(size_t bytes) {
return (((bytes)+__ALIGN - 1) & ~(__ALIGN - 1));
}
static size_t FREELIST_INDEX(size_t bytes) {
return (((bytes) + __ALIGN-1)/__ALIGN - 1);
}
这是STL当中的实现, 细细品味它的效率. 因为这两个函数的调用频率极高.所以他们使用了最精简的算法 我们好好思考这两个算法
为什么高效.
好了我们继续往下走,我们明白了链接过程,那么我们看看申请内存的细节吧. 这个chunk_alloc就没有前面这些函数
那么容易了,
他比较复杂!
希望大家耐心点慢慢看下去,我先列出来chun_alloc的源代码:
template <bool threads, int inst>
char* __default_alloc_template<threads, inst>::chunk_alloc(size_t size, int& nobjs)
{
char * result;
size_t total_bytes = size * nobjs;
size_t bytes_left = end_free - start_free;
//当系统中剩余的内存大于你申请20个相同内存块的空间.
if (bytes_left >= total_bytes) {
result = start_free;
start_free += total_bytes;
return(result);
}
//当系统中剩余的内存大于一个你申请的内存块.
else if (bytes_left >= size) {
nobjs = bytes_left / size;
total_bytes = size * nobjs;
result = start_free;
start_free += total_bytes;
return(result);
}
//
else {
//如果内存池连一个区块都无法给客端提供,就调用malloc申请内存,新申请的空间是需求量的两倍
//与随着配置次数增加的附加量. 在申请之前,将内存池的残余的内存收回.
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
if (bytes_left > 0) {
obj * __VOLATILE * my_free_list =
free_list + FREELIST_INDEX(bytes_left);
((obj *)start_free)->free_list_link = *my_free_list;
*my_free_list = (obj *)start_free;
}
start_free = (char *)malloc(bytes_to_get);
//如果没有申请成功,如果free_list当中有比n大的内存块,这个时候将free_list中的内存块释放出来.
//然后将这些内存编入自己的free_list的下标当中.调整nobjs.
if (0 == start_free) {
int i;
obj * __VOLATILE * my_free_list, *p;
for (i = size; i <= __MAX_BYTES; i += __ALIGN) {
my_free_list = free_list + FREELIST_INDEX(i);
p = *my_free_list;
if (0 != p) {
*my_free_list = p->free_list_link;
start_free = (char *)p;
end_free = start_free + i;
return(chunk_alloc(size, nobjs));
// Any leftover piece will eventually make it to the
// right free list.
}
}
end_free = 0; // In case of exception.
//这个是个就是弹尽粮绝了,去求求一级空间配置器来帮帮忙. 看看oom机制是否能过来帮忙
//这样会有两种可能,一种是抛出ban_alloc异常 一种是开辟空间成功.
start_free = (char *)malloc_alloc::allocate(bytes_to_get);
}
heap_size += bytes_to_get;
end_free = start_free + bytes_to_get;
return(chunk_alloc(size, nobjs));
}
}
最后的最后,我们来看看释放空间是一个什么情况. deallocate() 释放空间函数.
1.如果需要回收的区块大于128bytes,则调用第一级空间配置器
2.如果需要回收的区块小于128bytes,则找到free_list当中该块空间的大小的位置,然后将区块回收.
(记住这里的回收其实就是头插)
/* p may not be 0 */
static void deallocate(void *p, size_t n)
{
obj *q = (obj *)p;
obj * __VOLATILE * my_free_list;
if (n > (size_t)__MAX_BYTES) {
malloc_alloc::deallocate(p, n);
return;
}
my_free_list = free_list + FREELIST_INDEX(n);
// acquire lock
# ifndef _NOTHREADS
/*REFERENCED*/
lock lock_instance;
# endif /* _NOTHREADS */
//很标准的一个头插
q->free_list_link = *my_free_list;
*my_free_list = q;
// lock is released here
}
其实走到这里,我们可以基本明白了二级空间配置器的基本框架了,我整个博客就是按着申请空间开始到结束时每一步发生的事情.
这就是整个框架. 我找一张图帮助我们理解这整个过程:
总结
首先为什么STL要自己定制二级空间配置器? 首先就是我们的外碎片问题And效率问题,用户随意调用系统调用,非常的浪费时间.
我们想一想平时我
们使用空间的时候,基本都是new和delete来操作内存.但是这样都是一些很零碎的调用空间. 但是对于我们STL
来讲,STL有时候需要很大一块空间,有
时候又会频繁的开辟小空间. 这种操作就有一点不适合了. 所以空间配置器就被创造了出来.
当然如果你觉得这样你就理解二级空间配置器那是远远不够的. 有一句叫做眼里过千遍 不如手里过一遍. 既然我们有源代码,我
们
也明白了二级空间
配置器的基本框架,那么我们是否可以造一个轮子. 自己实现一个二级空间配置器? 如果你想了解STL那么
请
二级空间配置器的缺陷与问题>>
⑴ 二级空间配置器从头到尾都没有看到它释放内存,究竟是否释放,何时释放?
首先应该明确一点,二级配置器并没有将申请的空间释放,而是将它们挂在了自由链表上。空间配置器的所有方法,成员都是静态
的,那么它们就存放在静态区,因此释放的时机也必定是程序结束时。
⑵ 自由链表释放空间的连续性问题
真正在程序中就归还空间的只有自由链表中的未使用值,由于用户申请空间、释放空间顺序的不可控性,这些空间并不一定是连
续的,而释放空间必须保证其连续性。保证连续的方案可以是:跟踪分配释放过程、记录节点信息,释放时,仅释放连续的大块
空间。
⑶ 二级空间配置器的效率问题
二级配置器虽然解决了外碎片的问题,但同时也造成了内存片。加入用户频繁的申请char类型的空间,而配置器默认对其到8的倍
数,那么剩下的7/8的空间就会浪费。如果用户频繁申请8字节的空间,甚至将堆中的可用空间全部挂在了自由链表的第一个节点
,这时如果申请一个16字节的内存,也会失败。这也是二级配置器的弊端所在,设置一个释放内存的函数是很有必要的。