8.STL的两级空间配置器

8.STL的两级空间配置器

为什么有适配器?

(1)小块内存带来的内存碎片问题

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

(2)小块内存频繁申请释放带来的性能问题。

开辟空间的时候,空间配置器会去找一块空闲块给用户,找空闲块也是需要时间的,尤其是在外碎片比较多的情况下。如果空间配置器其找不到,就要考虑处理假碎片现象(释放的小块空间没有合并),这时候就要将这些已经释放的的空闲块进行合并,这也是需要时间的。

(3)小块空间太多会造成空间的浪费

每一次malloc函数开辟出一块堆空间在返回的指针前几个字节保存了开辟空间的大小。 这样free()的时候才知道传进去的指针到底意味着的多大的空间。所以多开辟的空间叫配置空间。小空间向系统申请过多,这些记录空间大小的内存就太多造成内存浪费。

(4)malloc new 出来的空间要 free delete 释放如果忘记不释放会发生内存泄漏

一级配置器

一级空间配置器中重要的函数就是allocate、deallocate、reallocate 。 一级空间配置器是以malloc(),free(),realloc()等C函数执行实际的内存配置 。大致过程是:

1、直接allocate分配内存,其实就是malloc来分配内存,成功则直接返回,失败就调用处理函数

2、如果用户自定义了内存分配失败的处理函数就调用,没有的话就返回异常

3、如果自定义了处理函数就进行处理,完事再继续分配试试

二级配置器

1.维护16条链表,分别是0-15号链表,最小8字节,以8字节逐渐递增,最大128字节,你传入一个字节参数,表示你需要多大的内存,会自动帮你校对到第几号链表(如需要13bytes空间,我们会给它分配16bytes大小),在找到第n个链表后查看链表是否为空,如果不为空直接从对应的free_list中拨出,将已经拨出的指针向后移动一位。

2.对应的free_list为空,先看其内存池是不是空时,如果内存池不为空:

(1)先检验它剩余空间是否够20个节点大小(即所需内存大小(提升后) * 20),若足够则直接从内存池中拿出20个节点大小空间,将其中一个分配给用户使用,另外19个当作自由链表中的区块挂在相应的free_list下,这样下次再有相同大小的内存需求时,可直接拨出。

(2)如果不够20个节点大小,则看它是否能满足1个节点大小,如果够的话则直接拿出一个分配给用户,然后从剩余的空间中分配尽可能多的节点挂在相应的free_list中。

(3)如果连一个节点内存都不能满足的话,则将内存池中剩余的空间挂在相应的free_list中(找到相应的free_list),然后再给内存池申请内存,转到3。

3.内存池为空,申请内存 此时二级空间配置器会使用malloc()从heap上申请内存,(一次所申请的内存大小为2 * 所需节点内存大小(提升后)* 20 + 一段额外空间),申请40块,一半拿来用,一半放内存池中。

4.malloc没有成功 在第三种情况下,如果malloc()失败了,说明heap上没有足够空间分配给我们了,这时,二级空间配置器会从比所需节点空间大的free_list中一一搜索,从比它所需节点空间大的free_list中拔除一个节点来使用。如果这也没找到,说明比其大的free_list中都没有自由区块了,那就要调用一级适配器了。

释放时调用deallocate()函数,若释放的n>128,则调用一级空间配置器,否则就直接将内存块挂上自由链表的合适位置。

1.STL介绍

STL(Standard Template Library,标准模板库),从根本上说,STL是一些“容器”的集合,这些“容器”有list,vector,set,map等,STL也是算法和其他一些组件的集合。
首先呢,让我们一起来理解一下STL的六大组件:

1.1STL六大组件简单介绍

(1)容器(Container):作为STL的最主要组成部分---容器,分为序列式容器和关联式容器:
序列式容器主要包括:向量(vector),双端队列(deque),表(list),队列(queue),堆栈(stack)
关联式容器主要包括:集合(set),多重集合(multiset),映射(map),多重映射(multimap)。
(2)算法(Algorithm):算法部分主要由头文件,和组成;STL的算法也是非常优秀的,它们大部分都是类属的,基本上都用到了C++的模板来实现,这样,很多相似的函数就不用自己写了,只要用函数模板就可以了。
(3)迭代器(Iterator):扮演容器与算法之间的胶合剂,是所谓的“泛型指针”,共有五种类型,以及其它衍生变化,从实现的角度来看,迭代器是一种将:Operators*,Operator->,Operator++,Operator–等相关操作予以重载的Class Template。所有STL容器都附带有自己专属的迭代器——是的,只有容器设计者才知道如何遍历自己的元素,原生指针(Native pointer)也是一种迭代器。
(4)仿函数(Functors): 行为类似函数,可作为算法的某种策略(Policy),从实现的角度来看,仿函数是一种重载了Operator()的Class 或 Class Template。一般函数指针可视为狭义的仿函数。
(5)配接器(适配器)(Adapters):一种用来修饰容器(Containers)或仿函数(Functors)或迭代器(Iterators)接口的东西,例如:STL提供的Queue和Stack,虽然看似容器,其实只能算是一种容器配接器,因为 它们的底部完全借助Deque,所有操作有底层的Deque供应。
(6)空间配置器(Allocators):负责空间配置与管理,从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的Class Template。

2.空间配置器

这篇博客主要是讲解STL中的六大组件之一—–空间配置器,接下来就是重点内容介绍了。

2.1什么是空间配置器?

所谓空间配置器就是用来管理内存的一个器具,对于STL来说,空间适配器是它可以正常工作的基础,也为它可以高效工作提供了动力。对于使用STL来说,它是不和用户直接打交道的,而是隐藏在一切STL组建之后,默默为各种内存申请提供支持的。

2.2为什么有适配器?

(1)小块内存带来的内存碎片问题

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

(2)小块内存频繁申请释放带来的性能问题。

开辟空间的时候,空间配置器会去找一块空闲块给用户,找空闲块也是需要时间的,尤其是在外碎片比较多的情况下。如果空间配置器其找不到,就要考虑处理假碎片现象(释放的小块空间没有合并),这时候就要将这些已经释放的的空闲块进行合并,这也是需要时间的。

(3)小块空间太多会造成空间的浪费

每一次malloc函数开辟出一块堆空间在返回的指针前几个字节保存了开辟空间的大小。 这样free()的时候才知道传进去的指针到底意味着的多大的空间。所以多开辟的空间叫配置空间。小空间向系统申请过多,这些记录空间大小的内存就太多造成内存浪费。

(4)malloc new 出来的空间要 free delete 释放如果忘记不释放会发生内存泄漏

2.3内存池

为了解决上面的内存碎片问题,就提出了内存池的概念

内存池最基本的思想
内存池最基本的思想就是一次向heap申请一块很大的内存(内存池),如果申请小块内存的话就直接到内存池中去要。这样的话,就能够有效的解决上面所提到的问题。

2.4空间配置器的分类

STL里面的空间配置主要分为两级:一级空间配置器(__malloc_alloc_template)和二级空间配置器(__default_alloc_template)。
(1)在STL中默认如果要分配的内存大于128个字节的话就是大块内存,调用一级空间配置器直接向系统申请;
(2)如果小于等于128个字节的话则认为是小内存,则就去内存池中申请。

2.5new的底层实现

谈及new和delete,我们一定不会太陌生,那么它的底层实现是怎样的呢,具体有以下几个步骤:
在C++中的new 内含的操作 :
(1)简单类型直接调用operator new分配内存;
(2)可以通过new_handler来处理new失败的情况;
(3)new分配失败的时候不像malloc那样返回NULL,它直接抛出异常。要判断是否分配成功应该用异常捕获的机制;
(4)new复杂数据类型的时候先调用operator new,然后在分配的内存上调用构造函数
在C++中的delete的操作 :
(1)delete简单数据类型默认只是调用free函数
(2)delete复杂数据类型先调用析构函数再调用operator delete

3.一级空间配置器

3.1定义

在查看空间配置器的源码前,我们需要先找到其定义,在我提供的这份STL源码中,在stl_alloc.h文件中,有如下的代码定义

# ifdef __USE_MALLOC
typedef malloc_alloc alloc; 
...
# else
typedef default_alloc_template<NODE_ALLOCATOR_THREADS, 0> alloc; 
...
#endif

可以看到alloc要么是malloc_alloc要么是default_alloc_template,至于是哪一种,由宏定义__USE_MALLOC决定

其中malloc_alloc较为简单,几乎没有内存管理,我们称它为第一级空间配置器

default_alloc_template较为复杂,但对内存管理非常精细,我们称它为第二级空间配置器

3.2第一级空间配置器源码分析

接下来查看malloc_alloc的定义(这里我忽略了很多,只保留最重要的部分)

class __malloc_alloc_template
{
    ...
        static void (*__malloc_alloc_oom_handler)(); //malloc失败时会调用此函数处理
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 void (*set_malloc_handler(void (*f)()))()
    {
        void (*old)() = __malloc_alloc_oom_handler;
        __malloc_alloc_oom_handler = f;
        return(old);
    }
};

我们可以看到allocate通过malloc分配内存,deallocate通过free释放内存

至于deallocate过于简单,没有什么可谈的,下面来看一看allocate

class __malloc_alloc_template
{
    ...
public:
    static void * allocate(size_t n)
    {
        void *result = malloc(n);
        if (0 == result) result = oom_malloc(n);
        return result;
    }
    ...
};

可以看到allocate通过malloc分配内存,如果分配失败就会调用oom_malloc,我们重点查看oom_malloc是如何处理内存分配失败的

void * __malloc_alloc_template<inst>::oom_malloc(size_t n)
{
    void (* my_malloc_handler)();
    void *result;

    for (;;)
    {
        my_malloc_handler = __malloc_alloc_oom_handler;
        if (0 == my_malloc_handler) { __THROW_BAD_ALLOC; } //如果没有指定处理函数,那么就退出
        (*my_malloc_handler)(); //调用处理函数
        result = malloc(n); //分配内存
        if (result) return(result); //分配成功就返回
    }
}

可以看到oom_malloc就是循环的地调用__malloc_alloc_oom_handler函数处理,渴望__malloc_alloc_oom_handler能够释放掉一些内存,使得malloc能够分配到内存,如果malloc分配到内存,那么就返回

那么__malloc_alloc_oom_handler又是那个函数呢?

你回过头看一下__malloc_alloc_template的定义,__malloc_alloc_oom_handler是一个静态的函数指针,用户可以通过set_malloc_handler函数来设置它

其实上面这个就是仿造了C++ set_newhandler机制的一个过程

4.为什么需要第二级空间配置器?

(1)首先思考一下,为什么需要新的空间配置器?

因为在第一级空间配置器中,是直接采用malloc和free进行内存的申请和释放,这样做的优点是简单,但是它有很大的缺点。

一方面,mallc申请内存的时候,系统会附加一小块cookie来记录这块内存的大小,如果每次分配的内存都很小,那么这块cookie占总内存的比例就很大,使得内存的使用率不高

另一方,如果频繁地分配小块内存,那么势必会造成内存的碎片化,这是我们不愿意看到的

STL为了解决这个问题,实现了另一个空间配置器default_alloc_template,它对内存做了精细的管理

理论上这个空间配置器非常的好,但令人疑惑的是,STL并没有沿用下来,反而使用的是上一篇文章讲的第一级空间配置器,但是这并不妨碍我们学习这个优秀的空间配置器

(2)源码剖析

A.初步分析

这个空间配置器是default_alloc_template,其定义如下

class __default_alloc_template
{
private:
    /* 管理内存块的节点 */
    union obj
    {
        union obj* free_list_link;
        char client_data[1];
    };

    /* 管理内存的指针数组 */
    static obj* __VOLATILE free_list[__NFREELISTS];

    /* 维护内存池 */
    static char* start_free;
    static char* end_free;
    static size_t heap_size;

public:
    static void* allocate(size_t n)
    {
        ...
    }

    static void deallocate(void* p, size_t n)
    {
        ...
    }
};

default_alloc_template维护着一个内存池,内存池每次分配内存都会分配一大块内存,并维护free_list,free_list是一个指针数组,free_list有16项,每一项都维护一个对应大小的内存块链表,大小分别为8、16、24、32、40、48、56、64、72、80、88、96、104、112、120、128,如下图所示

free_list中的每个内存块节点使用obj管理,如下所示

union obj 
{
    union obj * free_list_link; //指向下一个内存块
    char client_data[1]; //表示数据
};

可以看到这里使用的是联合体而不是结构体,如果使用结构体,那么意味着每一个内存块都需要额外的一个指针来维护链表,而这个指针只有在free_list中有用,当这个内存块被分配出去后,这个指针也就没有作用了。这里使用联合体的原因也在于此,其目的是为了节省内存,当节点应用free_list_link则表示维护链表的指针,当节点引用client_data,则表示这一整块内存

当调用这个空间配置器的allocate分配内存的时候,如果要求的内存大于128,那么就会直接调用malloc进行分配,否则就会从free_list中获取(要求的内存大小会8字节对齐)

当调用deallocate释放内存的时候,如果大于128,那么就会直接调用free将其释放,否则会将这个内存块放入free_list当中,下面将一一分析

B.分配内存(allocate)

分配函数的定义如下

class __default_alloc_template
{
    ...
    static void* allocate(size_t n)
    {
        obj* __VOLATILE* my_free_list;
        obj* __RESTRICT result;

        /* 如果大于128,则使用malloc */
        if (n > (size_t)__MAX_BYTES)
        {
            return(malloc_alloc::allocate(n));
        }

        /* 否则从free_list中获取内存,首先找到对应内存块大小的链表 */
        my_free_list = free_list + FREELIST_INDEX(n); //找到指定内存大小的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);
    };
};

上面的注释已经很清楚的,这里再理一下思路,首先,如果申请的内存大于__MAX_BYTES(128),那么就直接调用malloc进行分配。否则,从free_list找到管理指定内存块大小的链表,如果该链表上没有内存块,那么就重新填充,之后将分配得到的内存块从链表中删除,再返回此内存块

至于其中的refill怎么填充的,稍后会专门分析

(3)释放内存 (deallocate)

class __default_alloc_template 
{
    static void deallocate(void* p, size_t n)
    {
        obj* q = (obj*)p;
        obj* __VOLATILE* my_free_list;

        /* 如果大于128,那么会直接使用free */
        if (n > (size_t)__MAX_BYTES) 
        {
            malloc_alloc::deallocate(p, n);
            return;
        }

        /* 从free_list中找到指定块大小的链表 */
        my_free_list = free_list + FREELIST_INDEX(n);

        /* 将该内存块插入到该链表的最前端 */
        q->free_list_link = *my_free_list;
        *my_free_list = q;
    }
};

(4)free_list 填充(refill)

在分配内存时,我们没有分析refill函数

refill函数的作用是,从缓存块中获取内存,填充free_list对应的链表

void* __default_alloc_template<threads, inst>::refill(size_t n)
{
    int nobjs = 20;
    char* chunk = chunk_alloc(n, nobjs); //要求从缓存块中索要20个节点大小的内存

    /* 找到free_list中指定的链表 */
    my_free_list = free_list + FREELIST_INDEX(n);

    result = (obj*)chunk; //返回结果

    /* 将剩余的内存连接到free_list指定的链表上 */
    *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;
}

(5)缓存块(chunk_alloc)

下面再来看看缓存块是如何分配内存给的

其中start_free是缓存块起始处,end_free是缓存块结尾处

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; //缓存块剩余大小

    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 //如果缓存块大小不足一个节点的大小
    { 
        /* 将剩余空间添加到指定的free_list中 */
        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);
        heap_size += bytes_to_get;
        end_free = start_free + bytes_to_get;

        return(chunk_alloc(size, nobjs)); //递归调用
    }
}

case1:如果缓存块剩余大小大于指定节点数的大小,那么就直接从缓存块返回

case2:如果缓存块剩余大小不满足指定节点数的大小,但是大于1个节点的大小,那么就返回最大节点数的内存

case3:缓存块剩余大小小于1个节点的大小,那么就将剩余内存添加到指定的free_list链表中,然后重新分配大块内存,递归调用chunk_alloc
参考:

STL源码剖析(二)第一级空间配置器

[STL源码剖析(三)第二级空间配置器](

posted @ 2023-08-02 22:27  CodeMagicianT  阅读(98)  评论(0编辑  收藏  举报