STL源码剖析——空间配置器

来源:互联网 发布:js中字符串的方法 编辑:程序博客网 时间:2024/05/17 23:55

SGI STL 源码下载:SGI-STL-v3.3

1. 空间配置器概述

STL空间配置器(allocator)在所有容器内部默默工作,负责空间的配置和回收。STL标准为空间配置器定义了标准接口(可见《STL源码剖析》P43)。而具体实现细节则由各编译器实现版本而不同。下面介绍SGI STL中的allocator(实际叫alloc)配置器。
  空间配置器概述:
  1:new操作包含两个阶段操作
  1>调用::operator new配置内存(底层使用malloc来申请内存)。
  2>调用函数的构造函数,构造对象内容。
  delte和new一样,先调用析构函数,再调用::operator delete释放内存。
  2:为了效率,stl把两个阶段分开来。
  1>内存配置操作: alloc::allocate();
  2>内存释放操作: alloc::deallocate();
  3>对象构造操作: ::construct();
  4>对象析构操作: ::destory();
  3:内存配置器的整体:
  1><stl_construct.h>: 这里定义了全局函数construct()和的destroy()。
  2><stl_alloc.h>: 定义了一二级配置器。
  3><stl_uninitialized.h>: 定义了一些全局函数,用来填充或复制大块内存数据。
  un_initialized_copy(), un_initialized_fill(), un_initialized_fill_n()这些函数对效率考虑得面面俱到:最差的情况下会调用construct(),最佳的情况下会使用c的标准函数memmove()直接进行内存数据的移动。

2. 构造和析构基本工具construct()和destroy()


//cygwin-b20\include\g++\stl_construct.h#ifndef __SGI_STL_INTERNAL_CONSTRUCT_H#define __SGI_STL_INTERNAL_CONSTRUCT_H#include <new.h>__STL_BEGIN_NAMESPACEtemplate <class T>//析构单个元素inline void destroy(T* pointer) {    pointer->~T();}template <class T1, class T2>inline void construct(T1* p, const T2& value) {  new (p) T1(value);//布局new(placement new) 在p地址处调用T1构造函数构造对象}template <class ForwardIterator>inline void__destroy_aux(ForwardIterator first, ForwardIterator last, __false_type) {  for ( ; first < last; ++first)//如果元素的析构函数是必要的 那么逐个调用析构函数    destroy(&*first);}template <class ForwardIterator> //如果元素的析构函数是无关紧要的  就什么也不做inline void __destroy_aux(ForwardIterator, ForwardIterator, __true_type) {}template <class ForwardIterator, class T>inline void __destroy(ForwardIterator first, ForwardIterator last, T*) {  //通过元素型别来判断析构函数是否无关紧要(trivial) 并调用对应的函数进行析构  typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;  __destroy_aux(first, last, trivial_destructor());}template <class ForwardIterator>inline void destroy(ForwardIterator first, ForwardIterator last) {  __destroy(first, last, value_type(first));//通过泛型的类型识别技术来得到元素类型}inline void destroy(char*, char*) {}inline void destroy(wchar_t*, wchar_t*) {}__STL_END_NAMESPACE#endif /* __SGI_STL_INTERNAL_CONSTRUCT_H */

这里值得一提的主要是析构部分使用的一些技巧。首先解释一下所谓的trivial destructor,值得就是调用不调用都无所谓的析构函数,那么处于效率方面的考虑,在这样的情况下肯定选择什么都不做(如果进行十万百万次这样的函数调用,是不是就白白浪费了大好的时光了?)而且这里是在编译器就通过函数的重载来决定是否要调用析构函数。
  具体是通过__type_traits来萃取出类型是否具有trivial destructor的,这里在后面的文章会提到这些细节。现在所要了解的就是通过__type_traits可以萃取出类型的destructor特性(trivial or non-trivial),然后通过函数重载来决定具体进行什么样的操作。

3. stl_alloc.h

      
       (1) STL空间配置器:
主要分三个文件实现,stl_construct.h  这里定义了全局函数construct()和destroy(),负责对象的构造和析构。stl_alloc.h文件中定义了一、二两级配置器,彼此合作,配置器名为alloc. stl_uninitialized.h 这里定义了一些全局函数,用来填充(fill)或复制(copy)大块内存数据,他们也都隶属于STL标准规划。
  在stl_alloc.h中定义了两级配置器,主要思想是申请大块内存池,小块内存直接从内存池中申请,当不够用时再申请新的内存池,还有就是大块内存直接申请。
  当申请空间大于128字节时调用第一级配置器,第一级配置器没有用operator::new和operator::delete来申请空间,而是直接调用malloc/free和realloc,并且实现了类似c++中new-handler的机制。
  所谓c++ new handler机制是,你可以要求系统在内存配置需求无法被满足时,调用一个指定的函数。换句话说,一旦::operator::new无法完成任务,在丢出std::bad_alloc异常状态之前,会先调用由客端指定的处理例程,该处理例程通常称为new-handler.new-handler解决内存做法有特定的模式。
  SGI第一级配置器的allocate()和realloc都是在调用malloc和realloc不成功后,改调用oom_malloc()和oom_realloc(),后两者都有内循环,不断调用"内存不足处理例程",期望在某次调用之后,获得足够的内存而圆满完成任务。但如果“内存不足处理例程“并未被客端设定,oom_malloc()和oom_realloc便调用_THROW_BAD_ALLOC, 丢出bad_alloc异常信息,或利用exit(1)硬生生中止程序。
  在stl_alloc.h中定义的第二级配置器中,如果区块够大,超过128字节时,就移交给第一级配置器处理。当区块小于128字节时,则以内存池管理,此法又称为次层配置,每次配置一大块内存,并维护对应的自由链表(free-list)。下次若再有相同大小的内存需求,就直接从free-list中拔出。
  如果客端释还小额区块,就由配置器回收到free-lists中,另外,配置器除了负责配置,也负责回收。为了管理方便,SGI第二级配置器会主动将任何小额区块的内存需求量上调至8的倍数。并维护16个free-lists,各自管理大小分别为8,16,24,32,40,48,56,64,72,80,88,96,104, 112,120,128 字节的小额区块。
  当申请小于等于128字节时就会检查对应的free list,如果free-list中有可用的区块,就直接拿来,如果没有,就准备为对应的free-list 重新填充空间。新的空间将取自内存池,缺省取得20个新节点,如果内存池不足(还足以一个以上的节点),就返回的相应的节点数。
  如果当内存池中连一个节点大小都不够时,就申请新的内存池,大小为2*total_bytes+ROUND_UP(heap_size》4),totoal_bytes 为申请的空间大小,ROUND_UP调整为8的倍数,heap_size为当前总申请内存池的大小。
  如果申请该内存池成功就把原来内存池中剩下的空间分配给适当的free-list.万一山穷水尽,整个system heap空间都不够了(以至无法为内存池注入源头活水),malloc()行动失败,就会四处寻找有无"尚有未用区块,且区块足够大 "之free lists.找到了就挖一块交出,找不到就调用第一级配置器。第一级配置器其实也是使用malloc来配置内存。但它有out-of-memory处理机制(类似new-handler机制),或许有机会释放其他的内存拿来此处使用。如果可以就成功,否则发出bad_alloc异常。

  (2) STL的默认内存分配器

  隐藏在这些容器后的内存管理工作是通过STL提供的一个默认的allocator实现的。当然,用户也可以定制自己的allocator,只要实现allocator模板所定义的接口方法即可,然后通过将自定义的allocator作为模板参数传递给STL容器,创建一个使用自定义allocator的STL容器对象,如:
  stl::vector<int, UserDefinedAllocator> array;大多数情况下,STL默认的allocator就已经足够了。这个allocator是一个由两级分配器构成的内存管理器,当申请的内存大小大于128byte时,就启动第一级分配器通过malloc直接向系统的堆空间分配,如果申请的内存大小小于128byte时,就启动第二级分配器,从一个预先分配好的内存池中取一块内存交付给用户,这个内存池由16个不同大小(8的倍数,8~128byte)的空闲列表组成,allocator会根据申请内存的大小(将这个大小round up成8的倍数)从对应的空闲块列表取表头块给用户。

  这种做法有两个优点:
  (1)小对象的快速分配。小对象是从内存池分配的,这个内存池是系统调用一次malloc分配一块足够大的区域给程序备用,当内存池耗尽时再向系统申请一块新的区域,整个过程类似于批发和零售,起先是由allocator向总经商批发一定量的货物,然后零售给用户,与每次都总经商要一个货物再零售给用户的过程相比,显然是快捷了。当然,这里的一个问题时,内存池会带来一些内存的浪费,比如当只需分配一个小对象时,为了这个小对象可能要申请一大块的内存池,但这个浪费还是值得的,况且这种情况在实际应用中也并不多见。

  (2)避免了内存碎片的生成。程序中的小对象的分配极易造成内存碎片,给操作系统的内存管理带来了很大压力,系统中碎片的增多不但会影响内存分配的速度,而且会极大地降低内存的利用率。以内存池组织小对象的内存,从系统的角度看,只是一大块内存池,看不到小对象内存的分配和释放。

  实现时,allocator需要维护一个存储16个空闲块列表表头的数组free_list,数组元素i是一个指向块大小为8*(i+1)字节的空闲块列表的表头,一个指向内存池起始地址的指针start_free和一个指向结束地址的指针end_free。空闲块列表节点的结构如下:
union obj{union obj * free_list_link;char client_data[1];};

这个结构可以看做是从一个内存块中抠出4个字节大小来,当这个内存块空闲时,它存储了下个空闲块,当这个内存块交付给用户时,它存储的时用户的数据。因此,allocator中的空闲块链表可以表示成:
  obj* free_list[16];
  分配算法:
// 算法:allocate// 输入:申请内存的大小size// 输出:若分配成功,则返回一个内存的地址,否则返回NULL{if(size 大于 128)启动第一级分配器直接调用malloc分配所需的内存并返回内存地址;else{将size向上round up成8的倍数并根据大小从free_list中取对应的表头free_list_headif(free_list_head 不为空){从该列表中取下第一个空闲块并调整free_list,返回free_list_head}else{调用refill算法建立空闲块列表并返回所需的内存地址}}}// 算法:refill// 输入:内存块的大小size// 输出:建立空闲块链表并返回第一个可用的内存地址{调用chunk_alloc算法分配若干个大小为size的连续内存区域并返回起始地址chunk和成功分配的块数nobjif(块数为1)直接返回 chunk;else{开始在chunk地址块中建立free_list根据size取free_list中对应的表头元素free_list_head 将free_list_head 指向chunk中偏移起始地址为size的地址处,即free_list_head = (obj*)(chunk+size)再将整个chunk中剩下的nobj-1个内存块串联起来构成一个空闲列表返回chunk,即chunk中第一个空闲的内存块}}// 算法:chunk_alloc// 输入:内存块的大小size,预分配的内存块数nobj(以引用传递)// 输出:一块连续的内存区域的地址和该区域内可以容纳的内存块的块数{计算总共所需的内存大小total_bytesif(内存池足以分配,即end_free-start_free >= total_bytes){则更新start_free返回旧的start_free}else if(内存池不够分配nobj个内存块,但至少可以分配一个){计算可以分配的内存块数并修改nobj更新start_free并返回原来的start_free}else     // 内存池连一个内存块都分配不了{先将内存池的内存块链入到对应的free_list中后调用malloc操作重新分配内存池,大小为2倍的total_bytes为附加量,start_free指向返回的内存地址if(分配不成功){if(16个空闲列表中尚有空闲块)尝试将16个空闲列表中空闲块回收到内存池中再调用chunk_alloc(size,nobj)else调用第一级分配器尝试out of memory机制是否还有用}更新end_free为start_free+total_bytes,heap_size为2倍的total_bytes调用chunk_alloc(size,nobj)}}// 算法:deallocate// 输入:需要释放的内存块地址p和大小size{if(size 大于128字节)直接调用free(p)释放else{将size向上取8的倍数,并据此获取对应的空闲列表表头指针free_list_head调整free_list_head将p链入空闲列表块中}}

      假设这样一个场景,free_list[2]已经指向了大小为24字节的空闲块链表,如图1所示,当用户向allocator申请21字节大小的内存块时,allocaotr会首先检查free_list[2]并将free_list[2]所指的内存块分配给用户,然后将表头指向下一个可用的空闲块,如图2所示。注意,当内存块在链表上是,前4个字节是用作指向下一个空闲块,当分配给用户时,它是一块普通的内存区。



                        图1 某时刻allocator的状态 



                                  图2 分配24字节大小的内存块 


小结
  STL中的内存分配器实际上是基于空闲列表(free list)的分配策略,最主要的特点是通过组织16个空闲列表,对小对象的分配做了优化。
  1)小对象的快速分配和释放。当一次性预先分配好一块固定大小的内存池后,对小于128字节的小块内存分配和释放的操作只是一些基本的指针操作,相比于直接调用malloc/free,开销小。
  2)避免内存碎片的产生。零乱的内存碎片不仅会浪费内存空间,而且会给OS的内存管理造成压力。
  3)尽可能最大化内存的利用率。当内存池尚有的空闲区域不足以分配所需的大小时,分配算法会将其链入到对应的空闲列表中,然后会尝试从空闲列表中寻找是否有合适大小的区域,但是,这种内存分配器局限于STL容器中使用,并不适合一个通用的内存分配。因为它要求在释放一个内存块时,必须提供这个内存块的大小,以便确定回收到哪个free list中,而STL容器是知道它所需分配的对象大小的,比如上述:
  stl::vector<int> array;
  array是知道它需要分配的对象大小为sizeof(int)。一个通用的内存分配器是不需要知道待释放内存的大小的,类似于free(p)。


0 0