【《STL源码剖析》提炼总结】 第1节:空间配置器 allocator

一. 什么是空间配置器

负责空间配置与管理。从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的class template

​ ——《STL源码剖析》
这段话指出了空间配置器的几个特征:

  • 主要功能:负责空间的配置与管理,其将空间方面的操作抽象出来,从而使其他组间更好地面向其他方向
  • 具体功能:动态空间配置、空间管理、空间释放
  • 类型:为一个类模板(class template),实际上,6大组件除了算法组件是函数模板(function template),其他均为类模板。

实际上,因为在使用容器时默认指定了allocate,对大部分使用者来说空间配置器是透明的

二. STL allocator的四个操作: allocate,deallocate,construct,destroy

假如要进行C++的内存配置和释放操作,我们会进行如下操作:

class Foo{ ... }
Foo *pf = new Foo;
delete pf;

这个过程中new有两个操作:

  1. 调用::operator new配置内存,
  2. 调用::operator Foo::Foo()构造对象函数

delete也有两段操作

  1. 调用Foo::~Foo()将对象析构
  2. 调用::operator delete释放内存

综上,虽然只有new和delete两个操作,但是实际上有4个操作:内存的分配和回收、对象的构造和析构。因此STL allocator将其分为4个部分:

  • alloc:allocate() 内存配置
  • alloc:deallocate() 内存释放
  • ::construct() 对象构造
  • ::destroy() 对象析构

关于allocate()和deallocate() 将于后面介绍,这里简单介绍一下构造和析构函数的思想原理

construct()

构造函数其实比较简单,因为已经抽象出了内存配置功能,因此只要实现构造即可。这里直接使用使用了 placement new

一般来说,使用new申请空间时,是从系统的“堆”(heap)中分配空间。申请所得的空间的位置是根据当时的内存的实际使用情况决定的。但是,在某些特殊情况下,可能需要在已分配的特定内存创建对象,这就是所谓的“定位放置new”(placement new)操作。

by placement new机制 - AlexNoBug的文章 - 知乎

destroy()

相比于简单调用placement new的construct()destroy()略有不同,它进行了如下分类

  • 假如是对一个指针执行析构函数,则直接调用该对象的析构函数即可
  • 假如是对[first,last) 的范围执行析构函数,那需要进行一次判断:假如这个范围内的对象是"trivial"(不重要的),那鉴于循环调用析构函数的开销不小,没有调用析构函数的必要;反之,若为"non-trivial",那说明有调用的必要,因此会遍历调用析构函数

PS: 实际上,对于是否为trivial,有一个很好判断的依据:这个对象是否有用new在堆中创建内存,假如有的话,那必然为non-trivial,因为不执行析构函数会造成内存泄漏,这也是判断是否是trivial的依据——假如对象没有分配额外内存,那等着deallocate进行内存释放即可

三. SGI STL独有的空间配置器alloc

1. 各个版本的allocate

  • VC和BC5版本的allocate,本质上就是对new和delete的调用
  • GNU 2.9版本的allocate,采用的是其独特的空间配置器alloc (虽然有标准接口allocate,但是其没有调用,这个标准接口的存在纯粹是为了与旧版本的代码适配)
  • GNU 4.9时,allocate又变回了对new和delete的简单封装,原有的那个版本被改名为 __poll_alloc (内存池,也是很贴合它的本质)
  • 我查看自己的编译器 gcc 11.2.0 (mingw版) 同样容器适配的是标准接口的allocate(面向类型),同时底层也是使用delete和new实现的

2. alloc空间配置器的独特结构

双层配置器:

alloc独特的设计

  • 一级空间配置器:直接使用malloc与free来进行空间配置(没有使用new和delete)
  • 二级空间配置器:使用内存池技术,对于过小的内存块,使用内存池进行统一管理
一级配置器

​ 一级配置器与其他版本的配置器比较相似,是直接分配一块内存,略有区别的是其使用的是malloc与free来操作内存。

​ 《STL源码剖析》的猜测为: 一为历史因素,二是因为C++并未提供相应与realloc()的内存分配操作

二级配置器

​ 这是其独特的地方,其使用了内存池来进行统一管理

什么是内存池

​ 简单来说,就是申请一块较大的内存,然后使用其他程序来对其分块,每次要使用的话就从中取出一块

为什么要使用内存池

​ 对于每块申请的内存,其都有一块cookie来记录其大小——这就是额外的开销,当内存块太小时,大量的额外开销会带来很大的浪费,因此采用内存池是一个降低开销的好办法——它一次只申请一块较大的内存,额外开销很小。

​ 同时我觉得还有一个方面,就是内存的申请需要一定的时间,而内存池调用空闲内存只要在空闲的小内存块中选择即可,释放内存只需要将其归为空闲内存块即可,这方面的开销较低。

二级配置器的内存池的具体实现

​ 如果内存足够大,超过128B时,就将其移交给一级配置器处理。

​ 内存池中相同大小的内存区块是以链表形式连接的——链表的指针域会有一定的开销,但是将链表的数据域作为分配的内存移交出去的时候,节点的指针域是不需要使用的——而将内存放入链表中时,数据域也没有作用。因此可以使用共用体来处理

union obj{
    union obj * free_list_link;	// 指向下一个空间节点 指针域
    char client_data[1];// 作为一个数据域的开头
}
这个char client_data[1]很有迷惑性,看起来空间只有1字节那么大,实际上只是指明起点而已,具体大小要具体分析

​ 内存池中有16个相关的链表(free-lists)(因为是一大块静态内存,也可以说是静态链表),对应的区块大小均为8的倍数,即 8,16,24,32,40,…,128 B,因此在进行取区块的时候,程序会对需要的内存数上取整为8的倍数(会带来微不足道的额外内存分配)

内存池模型

  • 因此调用二级配置器时,只需要从对应的链表中取出空闲区块(头指针指向的第一个节点),取下之后,将头结点指向其next
  • 要归还区块(deallocate)时,使用头插法将区块插入作为链表的第一个节点即可
  • 假如对应链表已经没有空闲区块(头指针指向NULL),那需要重新分配内存产生新的节点放入free-lists——使用malloc分配或者从节点内存更大的链表处取,将其初始化的思路也就是我将其称为静态链表的原因——通过这个大区块的地址,第 n 块的地址位为起始地址 +(n-1)*小区块大小 即可推出

3. alloc用于适配STL标准的通用接口simple_alloc

​ 因为alloc不符合STL标准:alloc是面向字节分配的,而STL规定是为class template,需要面向类型

​ 因此对其有一个简单的封装——通过sizeof来调用alloc,使配置器的配置单位从bytes转为面向元素的大小

template<class T,class Alloc>
class simple_alloc{
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(n*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));}
};

4. SGI 容器对于alloc的匹配

默认配置器为alloc,在内部会定义对应的simple_alloc,然后使用simple_alloc来进行操作

四. 关于allocate变化的思考

​ 侯捷老师在课上和书内均没有提到为什么GNU要改回简单封装的new和delete,我经过查阅资料,现在的malloc本身已经实现了内存池技术,因此allocate就不需要自己来手动操作了。

​ 好像这样一来旧版的alloc就不适用了。其实不然,这个架构依然是有很大的学习价值的,并不会因为现在版本的更新而消退。

五. 内存基本处理工具

STL定义有5个全局函数作用于未初始化的空间上

construct

前面已经介绍过

destroy

前面同样介绍过

uninitialized_fill_n

接受三个参数:

  • 迭代器first指向初始化空间的起始处
  • n 表示初始化元素的数量
  • x表示初值

简单来说就是将[first,first+n)范围内的元素全部赋值为 x

在进行操作的时候需要判断元素是否为POD类型

POD意指Plain Old Data,也就是标量类型或传统的C struct类型,POD的构造函数/析构函数/拷贝赋值函数等必然为trivial类型

因此面对POD类型可以采用最有效率的初值填写(也就是直接赋值)手法,面对non-POD类型采用更保险的做法(非trivial的构造函数可能有额外操作,因此不能用初值填写的方式):

  • 面对POD类型,直接调用高阶函数 fill_n
  • 面对非POD类型,进行遍历然后逐个调用construct函数进行构造

uninitialized_copy

接受三个参数

  • first 指向输入端的起始位置
  • last 指向输入端的结束位置
  • result指向输出端的起始处

简单来说就是将[first,last)的元素拷贝到[result,result+(last-first) 的位置

与上面同样,进行POD类型判断

  • POD类型 直接调用高阶函数 copy
  • 非POD类型逐个调用construct

uninitialized_fill

接受三个参数

  • first 指向输入端的起始位置
  • last 指向输入端的结束位置
  • x 表示初值

简单来说就是将[first,last)的元素赋值为x

与上面同样,进行POD类型判断

  • POD类型 直接调用高阶函数 fill
  • 非POD类型逐个调用 construct

程序如何进行类型选择

入口函数使用类型萃取(type traits)获取其POD类型__type_traits<T1>::is_POD_type,将结果作为一个参数调用多路选择函数(针对POD萃取结果进行了函数重载)

uninitialized_xxx 系列函数与其对应的高阶函数的区别是什么

其作用于未初始化的空间,调用的是construct,(假如是POD类型则没有区别)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值