代码改变世界

STL容器的内存分配

2014-08-10 16:01  大脑溢出  阅读(1718)  评论(0编辑  收藏  举报

这篇文章参考的是侯捷的《STL源码剖析》,所以主要介绍的是SGI STL实现版本,这个版本也是g++自带的版本,另外有J.Plauger实现版本对应的是cl自带的版本,他们都是基于HP实现的版本,有兴趣可以翻翻最新的源码头文件开始处有声明。

/*
 *
 * Copyright (c) 1994
 * Hewlett-Packard Company(这里)
 *
 * Permission to use, copy, modify, distribute and sell this software
 * and its documentation for any purpose is hereby granted without fee,
 * provided that the above copyright notice appear in all copies and
 * that both that copyright notice and this permission notice appear
 * in supporting documentation.  Hewlett-Packard Company makes no
 * representations about the suitability of this software for any
 * purpose.  It is provided "as is" without express or implied warranty.
 *
 *
 * Copyright (c) 1996
 * Silicon Graphics Computer Systems, Inc.(和这里)
 *
 * Permission to use, copy, modify, distribute and sell this software
 * and its documentation for any purpose is hereby granted without fee,
 * provided that the above copyright notice appear in all copies and
 * that both that copyright notice and this permission notice appear
 * in supporting documentation.  Silicon Graphics makes no
 * representations about the suitability of this software for any
 * purpose.  It is provided "as is" without express or implied warranty.
 */

说到STL容器的内存分配就得先从new说起。

咱们知道,new一个对象出来,编译器会做两步工作,咱称之为“new操作”:

为该类型分配内存 在新分配的内存中构造该类型的一个对象虽然new关键字很方便,但是可以预想到,在某些应用场景中,运行时开销是不太理想的,或者说,有更好的策略可以节省开销,推迟到必要的时候再付出这部分开销。

比如vector容器,对应于c#或者java中的ArrayList,这种容器的特点是一旦new出他们来,就已经分配固定大小的内存,当若干插入元素操作之后如果空间不够,则新申请一块内存,依据具体的策略确定新申请的内存的大小,一般来说2倍于原空间大小。当然了,这是基于vector的接受一个size类型的参数的构造函数来说的。咱们先可以看看vector的默认构造函数的实现的片段:

const size_type __old_size = size();
 const size_type __len = __old_size != 0 ? 2 * __old_size : 1;
 iterator __new_start = _M_allocate(__len);

其中_len即为新空间大小,可以看到确实是2倍于原空间,同时也可以看到,如果size大小为0个单位,则先分配1个单位的空间,由M_allocate函数进行分配,然后复制原来的元素到新分配的内存,最后归还原来的空间:

const size_type __old_size = size();
    const size_type __len = __old_size != 0 ? 2 * __old_size : 1;
    iterator __new_start = _M_allocate(__len);
    iterator __new_finish = __new_start;
    __STL_TRY {
      __new_finish = uninitialized_copy(_M_start, __position, __new_start);
      construct(__new_finish, __x);
      ++__new_finish;
      __new_finish = uninitialized_copy(__position, _M_finish, __new_finish);
    }
    __STL_UNWIND((destroy(__new_start,__new_finish), 
                  _M_deallocate(__new_start,__len)));
    //destroy操作负责调用对象的析构函数
    destroy(begin(), end());//begin()内部为_M_start,end()内部为_M_finish
    //_M_deallocate负责收回空间
    _M_deallocate(_M_start, _M_end_of_storage - _M_start);
    _M_start = __new_start;
    _M_finish = __new_finish;
    _M_end_of_storage = __new_start + __len;

再瞧瞧new的时候vector分配多少单位的内存吧

vector的默认构造函数:

typedef _Vector_base<_Tp, _Alloc> _Base;
explicit vector(const allocator_type& __a = allocator_type())
    : _Base(__a) {}
Vectorbase的默认构造函数:

typedef _Alloc allocator_type;
_Vector_base(const _Alloc&)
    : _M_start(0), _M_finish(0), _M_end_of_storage(0) {}

 

可以看到,vector在new出来的时候是没有任何空间的,其中Mstart,Mfinish等等变量名作何意义可以去找参考相关书籍,或者请教搜索引擎。

好了,下面是接受一个size类型参数的构造函数的源码:

explicit vector(size_type __n)
    : _Base(__n, allocator_type())
    { _M_finish = uninitialized_fill_n(_M_start, __n, _Tp()); }
不看实现,我们先设想一下,容器一旦new出来,必定会有若干的容器内元素,如果不采用特别的应对策略,那么在一般情况下,除了分配内存,还会有一系列的构造操作。比如,如果我们一开始就定义一个vector v(n),每一个BigObject带一个需要执行很久的默认构造函数(假设),然而,我们却只用到了其中n/2个单位的空间,那么除了n/2个单位的空间闲置之外(这个是没办法的,所以不作缺点考虑吧。),这n/2个单位空间的构造操作也没有意义了。

于是,设计者利用了c++提供的一些特性将此情况比较理想地进行了解决,即将空间分配与对象构造分开进行。

接着讲述之前我们先了解一下

construct(T* p,var t):对p所指的内存中构造一个新元素,其中,用t进行复制初始化。

destroy(T* p):与construct相反,运行p所指对象的析构函数。

注意,两者都要求p必须指向了一块儿内存。

placement new(定位new): 语法形式: new(p) type(val)。与传统的new不同的是,定位new,只在已分配的内存中构造对象,并不负责分配内存。

可以猜想到construct内部实现也许就是定位new,但是有时候想象的不一定就是正确的。。。不过。。。这次其实就是正确的:

void construct(pointer p, const Tp& val) { new(p) Tp(val); }
但是,你能想到这些,不一定能想到destroy内部就是运行析构函数而已。。。好吧,其实你是对的:

void destroy(pointer p) { _p->~Tp(); }
(喂,你前面有个代码片段已经暴露了吧~)世界有时候就是充满了恶意。

知道这几个东西之后其实stl对内存分配所做的事情就没有那么神秘了。

那么,之前既然已经知道了vector是怎么节省开销的,也知道了构造的时候其实就是用了定位new,那么,是不是内存的分配用的就是operator new呢?想想可能是这样,因为这东西其实就是用来进行内存的分配,但是不会进行构造,所以可能vector中产生一个元素其实就是operator new+placement new!嗯,但是,源码给了我们一个无情的打击,前面我们已经看到,vector的实现里,内存的分配是靠Mallocate实现,那么,Mallocate_里面是怎么回子事呢?其实:

_Tp* _M_allocate(size_t __n)
    { return _M_data_allocator.allocate(__n); }

///////////////////////////////////////////////
typedef simple_alloc<_Tp, _Alloc> _M_data_allocator;

//////////////////////////////////////////////
template<class _Tp, class _Alloc>
class simple_alloc {

public:
    static _Tp* allocate(size_t __n)
      { return 0 == __n ? 0 : (_Tp*) _Alloc::allocate(__n * sizeof (_Tp)); }
    static _Tp* allocate(void)
      { return (_Tp*) _Alloc::allocate(sizeof (_Tp)); }
    static void deallocate(_Tp* __p, size_t __n)
      { if (0 != __n) _Alloc::deallocate(__p, __n * sizeof (_Tp)); }
    static void deallocate(_Tp* __p)
      { _Alloc::deallocate(__p, sizeof (_Tp)); }
};
可以看到,它内部使用的是一个称为simple_alloc的包装类。

到这里为止,已经没法继续追下去了,因为这里会涉及到根据不同的需求,进行不同的内存分配策略。事实上,slt中的分配策略一般来说有两种。

第一级配置器:默认策略,一会儿讲述 第二级配置器:产生原因是为了避免太多小额区块造成内存碎片(其实这里还有待深入研究,因为目前为止最新版的STL源码有这么一句话——With a reasonable compiler, this(指的就是第一级配置器) should be roughly as fast as the original STL class-specific allocators, but with less fragmentation.),以内存池进行管理,不深入讨论。第一,我本身也没弄明白这个策略的内部实现,怕hold不住。第二是因为:

Defaultalloctemplate parameters are experimental and MAY DISAPPEAR in the future. Clients should just use alloc for now.

嗯,作者都这么说了。

然而,第一级配置器是什么呢?它叫_mallocalloc_template,它是怎么进行内存分配的呢?见下:

static void* allocate(size_t __n)
  {
    void* __result = malloc(__n);
    if (0 == __result) __result = _S_oom_malloc(__n);
    return __result;
  }

上段代码来自mallocalloctemplate的内部实现,可以看到,其实它就是用的C语言中用来进行堆内存分配的malloc函数(第二行的Soom_malloc用于处理内存分配失败时的情况,不做讲解),

至于为什么选它,我猜是因为效率考虑吧,没有查证过。

至此还不能认为vector就是内部是按照这样分配的,还需要接着考证,我们回到vector,看看究竟是不是用的这个策略。

template <class _Tp, class _Alloc = __STL_DEFAULT_ALLOCATOR(_Tp) >
class vector : protected _Vector_base<_Tp, _Alloc> 
{
  // requirements:

可以看到,如果没有为vector指定“分配策略”,那么默认的就是STLDEFAULTALLOCATOR分配策略,这里就已经差不多得到答案了,经过一个遍历所有文件查找字符串的小脚本,终于找到组织了,位于stlconfig.h

# ifndef __STL_DEFAULT_ALLOCATOR
#   ifdef __STL_USE_STD_ALLOCATORS
#     define __STL_DEFAULT_ALLOCATOR(T) allocator< T >
#   else
#     define __STL_DEFAULT_ALLOCATOR(T) alloc
#   endif
# endif
可以看到,这里的预编译命令,官方的解释是

_STLUSESGIALLOCATORS is a hook so that users can disable new-style allocators, and continue to use the same kind of allocators as before, without having to edit library headers.

并且在stlvector中,也针对性地根据是否定义STLUSESTDALLOCATORS给出了两个版本的Vectorbase,其在分配内存上的不同仅仅是一个用的是标准接口,一个是自己定的接口(但是追溯其源码,还是用的标准接口,有兴趣的同学可以瞧瞧去),但是咱们不必关心这之间的区别。

现在我们就需要弄清楚,alloc到底是啥,为什么不讨论allocator呢?因为其内部还是alloc。在stl_alloc中,我们看到

# ifdef __USE_MALLOC

typedef malloc_alloc alloc;
typedef malloc_alloc single_client_alloc;

# else

噢噢噢,mallocalloc,就是你了!嗯,写到这里脑子都散黄了,思绪已经拉不回来了,也不知道我究竟是在写什么了,如果您发现了我文章组织有些问题,没关系,可以提意见,反正我又不听。(/#=皿=)/|_|

PS:对了,为什么只讲了vector?因为其内部实现是固定分配的,比较有代表性,而链表实现的(如List等)虽然也是分配和构造分开的,但是逻辑上看还是一起的,所以可能没有讲的必要,知道allocator差不多就知道容器的内存分配大概状况了,所以主要内容其实并不是在容器本身了。