文章目录
- 为什么需要空间配置器
- 一级空间配置器
- 二级空间配置器
- 内存池解析
- refill 填充内存池
- chunk_alloc 申请堆空间
- deallocate 资源的归还
- 空间配置器的再次封装
- 空间配置器与容器的结合
我们知道在C和C++中都有关于内存管理的问题,C语言用malloc和free这两个函数体现内存管理,C++用new和delete这两个操作符体现内存管理。那么内存管理到底是什么?管理体现在哪些方面?当我们需要存储大量数据时,栈区的容量就有些不够看了,此时我们必须将数据存储在堆区,可以简单的认为电脑的内存有多大,堆区就有多大。但是使用堆区就存在一个问题——内存管理,我们需要主动的向堆区申请资源存储数据,不需要使用资源时要把资源归还,这样的申请与归还操作将消耗系统资源,拖慢程序的运行速度,此外对于申请到的资源如何使用也是需要考虑的,充分利用堆区资源将是对程序的一种性能优化。在C++中,我们可以直接向堆区申请资源(使用VirtualAlloc函数),但是这是一种直接的方式,为了方便内存管理,我们需要设置这块资源的相关属性,C++为我们提供了一对接口,new和delete,我们可以通过new和delete解放我们对内存的管理工作,唯一需要注意的是对于new的资源有没有delete释放。
对于STL中的容器,大部分容器是在堆区上存储数据,频繁的资源申请与释放将浪费系统的大量性能,对此STL设计出空间配置器,当数据的字节大小小于128字节时,不再直接使用new获得资源,而是通过空间配置器。
可以看到容器的构造函数中有关于空间配置器的选项,并且是默认的,平常使用STL的容器时默认使用库中的空间配置器,对此并不影响我们对STL的学习,如果你认为STL库中的空间配置器效率还是低,那么你就可以传入一个你自己的空间配置器,只要提供的接口名称符合要求
为什么需要空间配置器
开始我们有大概的说过,使用空间配置器可以提高程序的性能,减少程序出错的可能性,如果不使用空间配置器,程序可能会存在这几个问题:
空间的申请与释放由用户自己把握,可能造成内存泄漏
频繁向系统申请小块的内存,可能造成内存碎片,还可能降低程序的性能
使用new和malloc的申请操作,需要额外使用资源记录所申请资源的信息
如果空间申请失败,需要用户自己处理异常
除此之外还有代码复用性,线程安全等问题,对此针对需要频繁使用的容器,我们需要设计一套高效的内存管理机制来优化程序
一级空间配置器
对于直接使用new或者malloc会出现的问题,最主要的还是由申请小块内存导致的内存碎片问题,针对这个问题SGI版本下的STL空间配置器以128为大块内存和小块内存的分界线,将空间配置器分为两级,一级空间配置器处理大块内存,二级空间配置器处理小块内存
template <int inst>
class __malloc_alloc_template
{
private:
static void* oom_malloc(size_t);
public:
// 对malloc的封装
static void* allocate(size_t n)
{
// 申请空间成功,直接返回,失败交由oom_malloc处理
void* result = malloc(n);
if (0 == result)
result = oom_malloc(n);
return result;
}
// 对free的封装
static void deallocate(void* p, size_t /* n */)
{
free(p);
}
// 设置新的处理方法,返回旧的处理方法
// 该函数的参数为函数指针void (*)(),返回值类型也为函数指针void(*)()
// void (* set_malloc_handler( void (*f)() ) )()
static void (*set_malloc_handler(void (*f)()))()
{
// __malloc_alloc_oom_handler是一个全局函数
void (*old)() = __malloc_alloc_oom_handler;
__malloc_alloc_oom_handler = f;
return(old);
}
};
// malloc申请空间失败时代用该函数(静态成员函数的类外实现)
template <int inst>
void* __malloc_alloc_template<inst>::oom_malloc(size_t n)
{
// 函数指针变量的创建
void (*my_malloc_handler)();
void* result;
for (;;)
{
// 检测用户是否设置空间不足应对措施,如果没有设置,抛异常,模式new的方式
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler)
{
// 抛异常
__THROW_BAD_ALLOC;
}
// 如果设置,执行用户提供的空间不足应对措施
(*my_malloc_handler)();
// 继续申请空间,可能会申请成功
// 用类型为void*的变量接收函数返回值
// 返回值是malloc申请成功后的堆区地址,将其返回
result = malloc(n);
if (result)
return(result);
}
}
typedef __malloc_alloc_template<0> malloc_alloc;
static void (* __malloc_alloc_oom_handler)();
上面的代码是SGI版本的一级空间配置器,可以看到无论它怎么复杂,本质就是对malloc和free的封装,allocate函数封装了malloc,如果malloc失败将调用一个处理失败函数,如果用户没有设置该函数,程序抛异常。deallocate函数封装了free。
二级空间配置器
当申请的空间大小大于128字节时,STL的容器将使用一级空间配置器,说白了就是直接malloc和free(new的delete底层也是封装了malloc和free),当申请的空间大小小于等于128字节时,程序将使用二级空间配置器,二级空间配置器可以很好的应对由于频繁申请小内存导致的内存碎片问题,达到高效的管理内存
先大概的讲解二级空间配置器的实现:二级空间配置器封装了一个内存池,并且还有两个维护内存池的指针,指针之间的区域就是内存池,但是我们并不是直接对内存池进行访问,而是通过一个哈希桶对内存池间接的访问
// 用来标记内存池中大块内存的起始和结束地址
static char *start_free;
static char *end_free;
// 记录空间配置器拥有的内存块数量
static size_t heap_size;
// 存储地址的哈希桶
static obj * free_list[__NFREELISTS];
start_free和end_free是两个char类型指针,它们之家的区域是一块可以使用的内存空间,这块空间是通过malloc从堆上申请的
内存管理接口之间的逻辑关系大概是上图这样的,空间配置器是基于malloc之上的一套内存管理机制,一级配置器直接是对malloc的封装,但是二级配置器也需要调用malloc拿到内存空间,start_free和end_free之间的空间就是从malloc中拿到的
先来看一些重要的成员
template <int inst>
class __default_alloc_template
{
private:
enum { __ALIGN = 8 }; // 使用户申请的内存为ALIGN的值或者倍数倍
enum { __MAX_BYTES = 128 }; // 大小内存块的分界线
enum { __NFREELISTS = __MAX_BYTES / __ALIGN }; // 需要使用的桶个数
// 如果用户所需内存块不是8的整数倍,向上对齐到8的整数倍
static size_t ROUND_UP(size_t bytes)
{
return (((bytes)+__ALIGN - 1) & ~(__ALIGN - 1));
}
private:
// 用union维护链表结构
union obj
{
union obj* free_list_link;
char client_data[1]; /* The client sees this. */
};
// 哈希函数,根据用户提供字节数找到对应的桶号
static size_t FREELIST_INDEX(size_t bytes)
{
return (((bytes)+__ALIGN - 1) / __ALIGN - 1);
}
//...
};
这里说一下其中关于链表的结构设计,不像传统的链表额外使用一个指针变量保存节点所指向的下一个节点的地址值,STL空间配置器的链表设计十分巧妙地使用联合体union设计,联合体obj的前4个字节保存了节点指向的下一个节点的地址
就像图片中一样,空间配置器中的链表将节点的前4个字节作为一个指针变量,存储了下一个节点的地址,具体的使用都会再说
内存池解析
static void* allocate(size_t n)
{
obj* __VOLATILE* my_free_list;
obj* __RESTRICT result;
// 检测用户所需空间释放超过128(即是否为小块内存)
if (n > (size_t)__MAX_BYTES)
{
// 不是小块内存交由一级空间配置器处理
return (malloc_alloc::allocate(n));
}
// 根据用户所需字节找到对应的桶号
my_free_list = free_list + FREELIST_INDEX(n);
result = *my_free_list;
// 如果该桶中没有内存块时,向该桶中补充空间
if (result == 0)
{
// 将n向上对齐到8的整数被,保证向桶中补充内存块时,内存块一定是8的整数倍
void* r = refill(ROUND_UP(n));
return r;
}
// 维护桶中剩余内存块的链式关系
*my_free_list = result->free_list_link;
return (result);
};
这是二级空间配置器的空间分配函数allocate,可以看到只要申请的内存块大于128B,allocate就会调用一级空间配置器,也就是malloc函数。只要申请的内存块小于等于128B,allocate就会使用内存池进行内存管理。先通过FREELIST_INDEX得到要申请的内存块在哈希桶中的下标,然后对哈希桶的首地址加上这个下标,最后解引用就能锁定对应的哈希桶并得到可以使用的地址,如果解引用后的result是空(也就是0),说明当前哈希桶还未向内存池申请内存,此时程序会调用refill函数,为哈希桶分配内存(将地址挂上哈希桶),然后将可以使用的地址返回。如果哈希桶中有内存可以使用,函数将对该位置进行头删,然后被删除的地址
上图就是哈希桶,我们通过哈希桶维护内存池,进而使用内存池,假设现在要申请7个字节的空间,调用allocate函数,函数看7字节是块小内存,不需要调用malloc函数,只用使用内存池中的资源。函数先算出桶号,7字节要去1号桶申请8字节空间(拿到一个地址),假设现在的哈希桶情况与上图一样,1号桶下挂了三个地址(哈希桶上挂的是地址,并不是内存块,我们从该地址向后使用是不会越界的,比如你要申请25字节空间,函数会向上取整,给你8的倍数32字节空间,但是你最好只使用该地址往后25字节),可以看到每一个桶都是一串链表结构,如果桶不为空,说明桶中至少存储了一个节点的地址(这个地址与往后4字节地址之间组成的数据也是一串地址,指向了下一个节点,这是刚才提到的联合体结构,如果到了链表尾,前4个字节的地址为空)。可以看到1号桶不为空,函数将这个桶指向后一个节点,也就是头删,最后保存第一个节点的地址到result中,返回result变量,这个地址往后8字节空间是可以自由使用的。
refill 填充内存池
// 函数功能:从内存池中向哈希桶中补充空间
// 参数n:小块内存字节数
// 返回值:首个小块内存的首地址
template <int inst>
void* __default_alloc_template<inst>::refill(size_t n)
{
// 一次性向内存池索要20个n字节的小块内存
int nobjs = 20;
char* chunk = chunk_alloc(n, nobjs);
obj** my_free_list;
obj* result;
obj* current_obj, * next_obj;
int i;
// 如果只要了一块,直接返回给用户使用(chunk_alloc会修改nobjs的值)
if (1 == nobjs)
return(chunk);
// 找到对应的桶号
my_free_list = free_list + FREELIST_INDEX(n);
// 将第一块返回值用户,其他块连接在对应的桶中
result = (obj*)chunk; // 先保存返回值
// 记录下一个节点的地址并将其存储到链表的头部
*my_free_list = next_obj = (obj*)(chunk + n);
for (i = 1; ; i++)
{
// 当前节点的更新
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);
}
chunk_alloc 申请堆空间
填充内存池时,调用了chunk_alloc函数,该函数将整合哈希桶剩下的资源,剩下的资源不够会去申请内存池的资源(start_free和end_free维护了内存池可以申请的资源),如果内存池的资源也不够,函数会调用malloc向堆区申请空间
template <int inst>
char* __default_alloc_template<inst>::chunk_alloc(size_t size, int& nobjs)
{
// 注意nobjs是一个引用
// 计算nobjs个size字节内存块的总大小以及内存池中剩余空间总大小
char* result;
size_t total_bytes = size * nobjs; // 需要向内存池申请的总字节数
size_t bytes_left = end_free - start_free; // 内存池可以申请的资源所剩余的字节数(肯定是8的倍数)
// 如果内存池可以提供total_bytes字节,直接返回
if (bytes_left >= total_bytes)
{
// 将start_free返回
result = start_free;
// 维护start_free
start_free += total_bytes;
return(result);
}
// 无法提供所有内存块,但是至少可以提供1块size字节内存块,修改nobjs后将资源返回
else if (bytes_left >= size)
{
// 计算剩下的资源可以提供内存块的数量,修改nobjs的值
nobjs = bytes_left / size;
// 计算可以提供的资源总字节数
total_bytes = size * nobjs;
// result的返回和start_free的维护
result = start_free;
start_free += total_bytes;
return(result);
}
else
{
// 内存池空间不足,连一块小块内存块都不能提供
// 向系统堆求助,往内存池中补充空间
// 计算向内存中补充空间大小:本次空间总大小两倍 + 向系统申请总大小/16
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
// 如果内存池有剩余空间(该空间一定是8的整数倍),将该空间挂到对应哈希桶中
// 不要浪费之前申请的资源
if (bytes_left > 0)
{
// 找对用哈希桶,将剩余空间挂在其上
// 这是一个头插
obj** 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);
if (0 == start_free)
{
// 通过系统堆补充空间失败,这种情况出现的概率较小,在哈希桶中找是否有没有使用的较大的内存块
int i;
obj** 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));
}
}
// 山穷水尽,只能向一级空间配置器求助
// 注意:此处一定要将end_free置空,因为一级空间配置器一旦抛异常就会出问题
end_free = 0;
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 资源的归还
// 函数功能:将申请的空间归还给空间配置器
// 参数:p需要归坏空间的首地址 n空间的总大小
static void deallocate(void *p, size_t n)
{
obj *q = (obj *)p;
obj ** my_free_list;
// 如果空间是大块内存,将其交给一级空间配置器,用free释放
if (n > (size_t) __MAX_BYTES)
{
malloc_alloc::deallocate(p, n);
return;
}
// 如果是小块内存,找到对应的哈希桶,将内存挂在哈希桶中(归还的内存一定是8的整数倍)
// 链表的头插操作
my_free_list = free_list + FREELIST_INDEX(n);
q -> free_list_link = *my_free_list;
*my_free_list = q;
}
空间配置器的再次封装
typedef __default_alloc_template<__NODE_ALLOCATOR_THREADS, 0> alloc;
template <class T, class Alloc>
class simple_alloc {
public:
// 申请n个对象的空间
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)); }
// 释放n个对象的空间
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)); }
};
STL用simple_alloc这个类封装了空间配置器的资源申请和资源释放接口,这个封装只要传入需要申请的块的个数n,也就是需要创建几个对象,不需要我们用sizeof算出一个类型的大小,这样封装就是方便了我们的使用
空间配置器与容器的结合
其实讲了这么多,空间配置器对外暴露的最主要的接口就是allocate和deallocate,allocate接收要需要分配的字节大小,将一串地址返回。deallocate接收需要返回的地址和其字节大小,将其释放。只是空间配置器对这两个接口进行了封装设计。
STL一开始就会向二级空间配置器索要内存,如果索要的内存大于128字节,二级空间配置器会调用一级空间配置器,一级空间配置器直接调用malloc向堆区申请空间。如果索要的内存小于等于128字节,那么二级空间配置器就会从哈希桶中查找内存池现在是否有相应的资源,二级空间配置器也是空间配置器最主要的结构。二级空间配置器通过哈希桶与内存池交互,哈希桶在STL与内存池之间就是一个管理的角色,STL通过哈希桶查找其需要的资源是否存在,内存将其资源分配给哈希桶供STL使用。
作为STL和内存池交互的媒介,我们需要针对哈希桶设计出一套高效的机制以提高内存的申请速度,针对哈希桶的算法就通过refill填充桶,chunk_alloc向堆区申请资源以及deallocate归还分配的资源体现。当STL的容器调用二级空间配置器的allocate申请资源时,函数会将用户需要申请的资源的字节数向上取整,得到8的整数,然后在哈希桶的对应位置查找,如果哈希桶上没有资源(没有地址),allocate会调用refill将内存池的资源填充到哈希桶中,其本质就是将内存地址挂到对应的哈希桶上。其中向内存池申请资源的操作将由chunk_alloc函数完成,如果内存池此时的资源不足以用来分配。chunk_alloc就会调用malloc函数向堆区申请一大块资源,生成新的内存池,注意内存池由start_free和end_free两个指针维护,我们只需要修改这两个指针就可以维护一块内存池。
当哈希桶上有了资源,allocate就会将对应哈希桶上的地址返回给用户。因为这些地址在哈希桶上以链表的形式维护,用户申请资源对应着链表的头删,归还资源对应着链表的头插。要注意的是链表节点之间的连接方式,由于在哈希桶中节点不是用来存储数据,或者说节点存储的是与其他节点间的连接关系,所以我们没有为节点的指针域额外开辟空间,因为每个节点至少向后8字节的空间是可以使用的,我们直接用这些空间存储下一个节点的地址,就算一个节点只能只用8字节,在64系统下也能运行。具体的实现可以看之前我列出的联合体结构
我们知道不论是一级还是二级的空间配置器,其allocate接口返回的都是一个地址,与malloc的返回值很像,使用allocate需要我们传入需要申请的总字节数,为了减少代码量,STL封装了一个simple_alloc,我们只要传入需要申请的对象个数,那么STL中具体的容器是怎么使用空间配置器的呢?
template <class T, class Alloc = alloc>
class list
{
// ...
// 实例化空间配置器
typedef simple_alloc<list_node, Alloc> list_node_allocator;
// ...
protected:
link_type get_node()
{
// 调用空间配置器接口先申请节点的空间
return list_node_allocator::allocate();
}
// 将节点归还给空间配置器
void put_node(link_type p)
{
list_node_allocator::deallocate(p);
}
// 创建节点
link_type create_node(const T& x)
{
// 调用allocate接口申请空间
link_type p = get_node();
// 再对节点进行构造
construct(&p->data, x);
return p;
}
// 销毁节点
void destroy_node(link_type p)
{
// 先释放节点中的资源
destroy(&p->data);
// 再把节点归还给哈希桶
put_node(p);
}
// ...
iterator insert(iterator position, const T& x)
{
// 资源的申请
link_type tmp = create_node(x);
tmp->next = position.node;
tmp->prev = position.node->prev;
(link_type(position.node->prev))->next = tmp;
position.node->prev = tmp;
return tmp;
}
iterator erase(iterator position)
{
link_type next_node = link_type(position.node->next);
link_type prev_node = link_type(position.node->prev);
prev_node->next = next_node;
next_node->prev = prev_node;
// 资源的销毁
destroy_node(position.node);
return iterator(next_node);
}
// ...
};
可以看到容器的类模板有两个参数,一个是容器存储的数据类型,一个则是默认的空间配置器类型,紧接着就是对simple_alloc类模板的重定义,将存储的数据类型T和配置器类型作为其参数,将其重定义为list_node_allocator,当容器需要存储数据时,就会调用list_node_allocator的allocate函数和节点的构造函数在申请的资源上存储数据。当容器不需要存储数据,将其删除时,会先将申请的资源上的数据资源释放(万一它的资源也是在堆区呢?),最后调用list_node_allocator的deallocate函数,释放所申请的资源(将地址归还,挂回哈希桶)
还值注意的是,在多线程的情况下,多线程需要使用同一个空间配置器,也就是说,我们实例化类模板时只能实例化出一个对象,这不就是单例模式吗?但是STL的空间配置器没有实现成常见的饿汉模式,而是将所有成员用static声明,当类的所有成员是静态时,这何尝不是一种单例呢?