C++动态内存管理与源码剖析

面试过程中,常会问道C++中的new和malloc的区别。

在使用了new运算符之后,编译器实际做了哪些事情呢?

Foo* p = new Foo();

在这个代码中,这里的new实际上是执行如下3个过程:

1 调用operator new分配内存,大小为Foo对象所占用内存大小;

2 调用构造函数生成类对象;

3 返回相应指针。

在C语言中,我们使用malloc/calloc/realloc和free动态开辟和释放内存,而在C++中,我们则经常使用的是new和delete进行动态内存管理。区别在于:

1、malloc/calloc/realloc按字节开辟内存的;new开辟内存时需要指定类型,例如:new int[10]。

2、new不仅可以做内存开辟,还可以做内存的初始化操作。

3、malloc/calloc/realloc开辟内存失败,是通过返回值和nullptr作比较;而new开辟内存失败是通过抛出bad_alloc类型的异常来判断。

4、malloc/calloc/realloc和free,都是c的库函数,new和delete都是运算符。

5、申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和 delete[]。

6、在申请自定义类型的空间时,new会调用构造函数,delete会调用析构函数,而malloc/calloc/realloc与free不会。

new:指我们在C++里通常用到的运算符,比如A* a = new A; 对于new来说,有new和::new之分,前者位于std。

operator new():指对new的重载形式,它是一个函数,并不是运算符。对于operator new来说,分为全局重载和类重载,全局重载是void* ::operator new(size_t size),在类中重载形式 void* A::operator new(size_t size)。还要注意的是这里的operator new()完成的操作一般只是分配内存,事实上系统默认的全局::operator new(size_t size)也只是调用malloc分配内存,并且返回一个void*指针。而构造函数的调用(如果需要)是在new运算符中完成的

new和operator new之间的关系

A* a = new A;我们知道这里分为两步:1.分配内存,2.调用A()构造对象。事实上,分配内存这一操作就是由operator new(size_t)来完成的,如果类A重载了operator new,那么将调用A::operator new(size_t ),如果没有重载,就调用::operator new(size_t ),全局new操作符由C++默认提供。因此前面的两步也就是:1.调用operator new 2.调用构造函数。

(1)new :不能被重载,其行为总是一致的。它先调用operator new分配内存,然后调用构造函数初始化那段内存。

new 操作符的执行过程:

1. 调用operator new分配内存 ;

2. 调用构造函数生成类对象;

3. 返回相应指针。

(2)operator new:要实现不同的内存分配行为,应该重载operator new,而不是new。

operator new就像operator + 一样,是可以重载的。如果类中没有重载operator new,那么调用的就是全局的::operator new来完成堆的分配。同理,operator new[]、operator delete、operator delete[]也是可以重载的。

C++中new有三种形式:new operator、operator new和placement new。

动态内存管理函数及其调用关系

c++中的动态内存分配和释放方式有很多,主要包括:

  • malloc与free

  • new expression与delete expression

  • array new 与array delete

  • operator new和operator delete

  • allocator中的allocate与deallocate

除此之外还有placement new,但需要注意placement new不是用来内存分配和释放的,而是在已分配的内存上构造对象。

他们之间的调用关系如下:

下面我们来具体看下每一种分配和释放方式的使用和原理。

malloc与free

void *p1 = malloc(32); //分配32字节的内存    free(p1);//释放指针p1指向的内存

malloc函数以字节数为参数,返回指向分配的内存的首地址的void指针,malloc在分配失败时会返回NULL;而free函数释放给定指针指向的内存。

事实上,malloc分配的返回给用户使用的内存外面上下会有两个cookie

这两个cookie用户并不能感受到,但malloc函数实际从操作系统取得的内存实际上是返回给用户的内存加上cookie以及一些对齐填充(请注意,除了最两边的cookie,用户实际得到的内存旁边还有对齐填充字节等其他的overhead,上图以及下面的计算没有考虑这点)。

在VC6中,上下两个cookie记录的是实际分配的内存大小。

以上面的代码举个例子,若上下两个cookie各占4个字节,那么cookie中的值为32+4*2=40或者41(取决于当前内存是否被分配给用户使用)。

正是由于cookie的存在,使得free函数回收内存时,只需要32个字节的首地址,减去4个字节即是真正分配的内存的首地址,而大小也已经知道。free函数不需要大小参数正是由于cookie的存在。

operator new与operator delete

operator new:

(1) 只分配所要求的空间,不调用相关对象的构造函数。当无法满足所要求分配的空间时,则如果有new_handler,则调用new_handler,否则如果没要求不抛出异常(以nothrow参数表达),则执行bad_alloc异常,否则返回0

(2) 可以被重载

(3) 重载时,返回类型必须声明为void*

(4) 重载时,第一个参数类型必须为表达要求分配空间的大小(字节),类型为size_t

(5) 重载时,可以带其它参数

new operator:

(1) 调用operator new分配足够的空间,并调用相关对象的构造函数

(2) 不可以被重载

或许你没有听过 operator new(),只听过 new()。其实 new() 底层是调用 operator new() 来实现的。而operator new() 其实还是调用 malloc()。

    void *p6 = ::operator new(32); //分配32字节
    ::operator delete(p6);
//在这里是这节指定申请的空间大小,调用的函数是下面的这个

operator new失败会抛出异常。

PS:底层调用mallocfree。gnu的实现:

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
  void *p;

  /* malloc (0) is unpredictable; avoid it.  */
  if (__builtin_expect (sz == 0, false))
    sz = 1;

  while ((p = malloc (sz)) == 0)
    {
      new_handler handler = std::get_new_handler ();
      if (! handler)
    _GLIBCXX_THROW_OR_ABORT(bad_alloc());
      handler ();
    }

  return p;
}

_GLIBCXX_WEAK_DEFINITION void
operator delete(void* ptr) noexcept
{
  std::free(ptr);
}

new expression与delete expression

    int *p2 = new int;
    delete p2;
    
    string *p3 = new string("hello");
    delete p3;
//这里调用的是

new expression完成两样工作:

  1. 申请并分配内存。

  1. 调用构造函数。

string *p3 = new string("hello");被编译器替换成下面的工作:

    string *p3;
    try{
            void * tmp_p = operator new(sizeof(string));
            p3 = static_cast<string *>(tmp_p);
            //string 通过宏被替换为basic_string,string的实际实现是basic_string,这里不是重点。
            p3 -> basic_string::basic_string("hello");    //编译器可以这么调用,但我们自己写代码时不能。即我们不能以这种方式通过指针显式调用构造函数。
        }catch (std::bad_alloc){
            //若分配失败,构造函数不执行
        }

我们看到,原来new expression内存申请和分配是通过调用operator new()来完成的

delete expression也完成两样工作:

  1. 调用析构函数。

  1. 释放内存。

delete p3;被编译器替换成下面的工作

    p3 -> ~string();//通过指针直接调用析构函数。我们自己写代码时也可以这么做。
    operator delete(p3);//释放内存

array new 与array delete

    //Complex为自定义类,只需要知道Complex类中没有指针成员。
    Complex *pca = new Complex[3];//3次构造函数
    delete[] pca;//3次析构函数

    string *psa = new string[3];//3次构造函数
    delete[] psa;//3次析构函数

array new调用一次内存分配函数(底层源码实现中,其实是调用operator new,调用的时候计算好了大小,大小为3*对象大小 + 一个记录array new的对象个数的字段。因此,有上下两个cookie,还有一个记录分配对象个数的字段cookie由malloc、free维护,除他们之外不可见;那个长度字段由array new、array delete即new[]、delete[]维护。)和多次构造函数。正因为调用多次构造函数,因此只能调用无参构造函数。

Complex和string的很大不同之处在于,string有指针成员,布局如下图:

array delete调用多次析构函数(调用次数通过delete[]ptr 的ptr再向上某长度,获得前面提到的那个记录new[N]分配的对象个数N的字段),一次内存释放函数(底层源码实现中其实是调用一次operator delete)。

我们来看下,如果本应该使用array delete的地方使用了delete expression会发生什么:

    Complex *pca = new Complex[3];//3次构造函数
    delete pca;//1次析构函数

    string *psa = new string[3];//3次析构函数
    delete psa;//1次析构函数

对于Complex,我们使用了array new调用了3次构造函数,却没有使用array delete而使用了delete expression,因此只调用了一次析构函数。那么,会发生内存泄漏吗不会。因为Complex的析构函数是无关痛痒的(trivial),因为没有要释放的关联的内存(Complex对象自身所占内存之外没有隐式占用的内存)。

同样,对于string,我们使用了array new调用了3次构造函数,却没有使用array delete而使用了delete expression,因此只调用了一次析构函数。那么,会发生内存泄漏吗。因为string的析构函数不是无关痛痒的(non-trivial),因为要释放关联的内存(我们知道string底层是通过char[]存储的,析构时会释放掉那些实际存储字符的内存)。

PS: 具体的内存布局例子(涉及到cookie、对齐填充padding等等)。

    int *p = new int[10];
    delete[]p;
    //delete p 亦可。int无关痛痒。

VC6中的内存布局如下:

另:

    Demo *p = new Demo[3];//Demo为析构函数non-trivial的自定义class
    delete[] p;
    //delete p; //错误

VC6中的内存布局(注意红框内的3):

allocate与deallocate

#ifdef __GNUC__    //GNUC环境下
    void *p7 = allocator<int>().allocate(4);  //非static函数,通过实例化匿名对象调用allocate,分配4个int的内存。
    allocator<int>().deallocate((int *)p7, 4);
    
    void *p8 = __gnu_cxx::__pool_alloc<int>().allocate(4);
    __gnu_cxx::__pool_alloc<int>().deallocate((int *)p8, 4);
#endif

allocator为模板,实例化时需提供模板类型参数,上面的程序中模板类型参数为<int>,allocate的参数为4则allocate函数分配时就分配4int的内存。释放内存时需要给出指向所要释放的内存位置的指针,以及要释放的内存大小,单位为模板类型参数类型的大小。

__pool_alloc也为模板,除底层调用malloc的时机不同外(__pool_alloc使用内存池降低cookie带来的overhead),使用和上面的allocator相同。

placement new

用法:

    char *buf = new char[sizeof(Complex) * 3];
    Complex *pc = new(buf) Complex(1, 2);
    new(buf + 1) Complex(1, 3);
    new(buf + 2) Complex(1, 3);
    delete[] buf;

Complex *pc = new(buf) Complex(1, 2);被编译器替换成如下的工作:

    Complex *pc;
    try{
            void *tmp = operator new(sizeof(Complex), buf);//该重载版本并不分配内存。buf指针已经指向内存。
            pc = static_cast<Complex*>(tmp);
            pc->Complex::Complex(1, 2);//构造函数
        }catch(std::bad_alloc){
            //若分配失败则不执行构造函数。实际上没有分配,因为之前已经分配完。
        }

上面使用的GNU库重载版本的operator new()函数如下:

// Default placement versions of operator new.
_GLIBCXX_NODISCARD inline void* operator new(std::size_t, void* __p) _GLIBCXX_USE_NOEXCEPT
{ return __p; }

可以看到确实没有分配内存。

重载内存管理函数

new expressiondelete expression都不可重载。

operator newoperator delete可以重载:

  • 重载globaloperator newoperator delete,即::operator new(size_t)::operator delete(void *)。(一般不会重载全局的该函数,因为影响太广)

  • 重载某个class的operator newoperator delete

若某个类重载了operator newoperator delete,则用new expression实例化该类时,调用的是类的operator newoperator delete,否则,调用globaloperator newoperator delete

array newarray delete也可以重载。同样分全局的和类所属的。

具体如何重载这些内存管理函数,以及如何使用重载的内存管理函数,将在下一篇文章中分析。

参考资料

[1] 《STL源码剖析》
[2] 《Effective C++》3/e
[3] 《C++ Primer》5/e
[4] 侯捷老师的课程
[5] gcc开源库: https://github.com/gcc-mirror/gcc
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值