牛骨文教育服务平台(让学习变的简单)
博文笔记

C++ 中的 new/delete 和 new[]/delete[]深入理解

创建时间:2016-09-22 投稿人: 浏览次数:1738

在 C++ 中,你也许经常使用 new 和 delete 来动态申请和释放内存,但你可曾想过以下问题呢?

  • new 和 delete 是函数吗?
  • new [] 和 delete [] 又是什么?什么时候用它们?
  • 你知道 operator new 和 operator delete 吗?
  • 为什么 new [] 出来的数组有时可以用 delete 释放有时又不行?

如果你对这些问题都有疑问的话,不妨看看我这篇文章。

new 和 delete 到底是什么?

如果找工作的同学看一些面试的书,我相信都会遇到这样的题:sizeof 不是函数,然后举出一堆的理由来证明 sizeof 不是函数。在这里,和 sizeof 类似,new 和 delete 也不是函数,它们都是 C++ 定义的关键字,通过特定的语法可以组成表达式。和 sizeof 不同的是,sizeof 在编译时候就可以确定其返回值,new 和 delete 背后的机制则比较复杂。
继续往下之前,请你想想你认为 new 应该要做些什么?也许你第一反应是,new 不就和 C 语言中的 malloc 函数一样嘛,就用来动态申请空间的。你答对了一半,看看下面语句:

string *ps = new string("hello world");

你就可以看出 new 和 malloc 还是有点不同的,malloc 申请完空间之后不会对内存进行必要的初始化,而 new 可以。所以 new expression 背后要做的事情不是你想象的那么简单。在我用实例来解释 new 背后的机制之前,你需要知道 operator new 和 operator delete 是什么玩意。

operator new 和 operator delete

这两个其实是 C++ 语言标准库的库函数,原型分别如下:

void *operator new(size_t);     //allocate an object
void *operator delete(void *);    //free an object

void *operator new[](size_t);     //allocate an array
void *operator delete[](void *);    //free an array

后面两个你可以先不看,后面再介绍。前面两个均是 C++ 标准库函数,你可能会觉得这是函数吗?请不要怀疑,这就是函数!C++ Primer 一书上说这不是重载 new 和 delete 表达式(如 operator= 就是重载 = 操作符),因为 new 和 delete 是不允许重载的。但我还没搞清楚为什么要用 operator new 和 operator delete 来命名,比较费解。我们只要知道它们的意思就可以了,这两个函数和 C 语言中的 malloc 和 free 函数有点像了,都是用来申请和释放内存的,并且 operator new 申请内存之后不对内存进行初始化,直接返回申请内存的指针。

我们可以直接在我们的程序中使用这几个函数。

new 和 delete 背后机制

知道上面两个函数之后,我们用一个实例来解释 new 和 delete 背后的机制:

我们不用简单的 C++ 内置类型来举例,使用复杂一点的类类型,定义一个类 A:

class A
{
public:
    A(int v) : var(v)
    {
        fopen_s(&file, "test", "r");
    }
    ~A()
    {
        fclose(file);
    }

private:
    int var;
    FILE *file;
};

很简单,类 A 中有两个私有成员,有一个构造函数和一个析构函数,构造函数中初始化私有变量 var 以及打开一个文件,析构函数关闭打开的文件。

我们使用

class A *pA = new A(10);

来创建一个类的对象,返回其指针 pA。如下图所示 new 背后完成的工作:

简单总结一下:

  1. 首先需要调用上面提到的 operator new 标准库函数,传入的参数为 class A 的大小,这里为 8 个字节,至于为什么是 8 个字节,你可以看看《深入 C++ 对象模型》一书,这里不做多解释。这样函数返回的是分配内存的起始地址,这里假设是 0x007da290。
  2. 上面分配的内存是未初始化的,也是未类型化的,第二步就在这一块原始的内存上对类对象进行初始化,调用的是相应的构造函数,这里是调用 A:A(10); 这个函数,从图中也可以看到对这块申请的内存进行了初始化,var=10, file 指向打开的文件
  3. 最后一步就是返回新分配并构造好的对象的指针,这里 pA 就指向 0x007da290 这块内存,pA 的类型为类 A 对象的指针。

所有这三步,你都可以通过反汇编找到相应的汇编代码,在这里我就不列出了。

好了,那么 delete 都干了什么呢?还是接着上面的例子,如果这时想释放掉申请的类的对象怎么办?当然我们可以使用下面的语句来完成:

delete pA;

delete 所做的事情如下图所示:

delete 就做了两件事情:

  1. 调用 pA 指向对象的析构函数,对打开的文件进行关闭。
  2. 通过上面提到的标准库函数 operator delete 来释放该对象的内存,传入函数的参数为 pA 的值,也就是 0x007d290。

好了,解释完了 new 和 delete 背后所做的事情了,是不是觉得也很简单?不就多了一个构造函数和析构函数的调用嘛。

如何申请和释放一个数组?

我们经常要用到动态分配一个数组,也许是这样的:

string *psa = new string[10];      //array of 10 empty strings
int *pia = new int[10];           //array of 10 uninitialized ints

上面在申请一个数组时都用到了 new [] 这个表达式来完成,按照我们上面讲到的 new 和 delete 知识,第一个数组是 string 类型,分配了保存对象的内存空间之后,将调用 string 类型的默认构造函数依次初始化数组中每个元素;第二个是申请具有内置类型的数组,分配了存储 10 个 int 对象的内存空间,但并没有初始化。

如果我们想释放空间了,可以用下面两条语句:

delete [] psa;
delete [] pia;

都用到 delete [] 表达式,注意这地方的 [] 一般情况下不能漏掉!我们也可以想象这两个语句分别干了什么:第一个对 10 个 string 对象分别调用析构函数,然后再释放掉为对象分配的所有内存空间;第二个因为是内置类型不存在析构函数,直接释放为 10 个 int 型分配的所有内存空间。

这里对于第一种情况就有一个问题了:我们如何知道 psa 指向对象的数组的大小?怎么知道调用几次析构函数?

这个问题直接导致我们需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。

还是用图来说明比较清楚,我们定义了一个类 A,但不具体描述类的内容,这个类中有显示的构造函数、析构函数等。那么 当我们调用

class A *pAa = new A[3];

时需要做的事情如下:

从这个图中我们可以看到申请时在数组对象的上面还多分配了 4 个字节用来保存数组的大小,但是最终返回的是对象数组的指针,而不是所有分配空间的起始地址。

这样的话,释放就很简单了:

delete []pAa;

这里要注意的两点是:

  • 调用析构函数的次数是从数组对象指针前面的 4 个字节中取出;
  • 传入 operator delete[] 函数的参数不是数组对象的指针 pAa,而是 pAa 的值减 4。

为什么 new/delete 、new []/delete[] 要配对使用?

其实说了这么多,还没到我写这篇文章的最原始意图。从上面解释的你应该懂了 new/delete、new[]/delete[] 的工作原理了,因为它们之间有差别,所以需要配对使用。但偏偏问题不是这么简单,这也是我遇到的问题,如下这段代码:

int *pia = new int[10];
delete []pia;

这肯定是没问题的,但如果把 delete []pia; 换成 delete pia; 的话,会出问题吗?

这就涉及到上面一节没提到的问题了。上面我提到了在 new [] 时多分配 4 个字节的缘由,因为析构时需要知道数组的大小,但如果不调用析构函数呢(如内置类型,这里的 int 数组)?我们在 new [] 时就没必要多分配那 4 个字节, delete [] 时直接到第二步释放为 int 数组分配的空间。如果这里使用 delete pia;那么将会调用 operator delete 函数,传入的参数是分配给数组的起始地址,所做的事情就是释放掉这块内存空间。不存在问题的。

这里说的使用 new [] 用 delete 来释放对象的提前是:对象的类型是内置类型或者是无自定义的析构函数的类类型!

我们看看如果是带有自定义析构函数的类类型,用 new [] 来创建类对象数组,而用 delete 来释放会发生什么?用上面的例子来说明:

class A *pAa = new class A[3];
delete pAa;

那么 delete pAa; 做了两件事:

  • 调用一次 pAa 指向的对象的析构函数;
  • 调用 operator delete(pAa); 释放内存。

显然,这里只对数组的第一个类对象调用了析构函数,后面的两个对象均没调用析构函数,如果类对象中申请了大量的内存需要在析构函数中释放,而你却在销毁数组对象时少调用了析构函数,这会造成内存泄漏。

上面的问题你如果说没关系的话,那么第二点就是致命的了!直接释放 pAa 指向的内存空间,这个总是会造成严重的段错误,程序必然会奔溃!因为分配的空间的起始地址是 pAa 指向的地方减去 4 个字节的地方。你应该传入参数设为那个地址!

同理,你可以分析如果使用 new 来分配,用 delete [] 来释放会出现什么问题?是不是总会导致程序错误?

总的来说,记住一点即可:new/delete、new[]/delete[] 要配套使用总是没错的!

二. 简介
new有三种使用方式:plain new,nothrow new和placement new。

(1)plain new顾名思义就是普通的new,就是我们惯常使用的new。在C++中是这样定义的:
    void* operator new(std::size_t) throw(std::bad_alloc);
    void operator delete(void *) throw();

提示:plain new在分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的。

(2)nothrow new是不抛出异常的运算符new的形式。nothrow new在失败时,返回NULL。定义如下:
    void * operator new(std::size_t,const std::nothrow_t&) throw();
    void operator delete(void*) throw();

(3)placement new意即“放置”,这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:
    void* operator new(size_t,void*);
    void operator delete(void*,void*);

提示1:palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组。

提示2:placement new构造起来的对象或其数组,要显示的调用他们的析构函数来销毁,千万不要使用delete。

char* p = new(nothrow) char[100];
long *q1 = new(p) long(100);
int *q2 = new(p) int[100/sizeof(int)];

三.实例

1.plain new/delete.普通的new
定义如下:
void *operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void*) throw();

注:标准C++ plain new失败后抛出标准异常std::bad_alloc而非返回NULL,因此检查返回值是否为NULL判断分配是否成功是徒劳的。

测试程序:

复制代码代码如下:
#include "stdafx.h"
#include <iostream>
using namespace std;

char *GetMemory(unsigned long size)
{
char *p=new char[size];//分配失败,不是返回NULL
return p;
}

int main()
{
try
{
  char *p=GetMemory(10e11);// 分配失败抛出异常std::bad_alloc
  //...........
  if(!p)//徒劳
   cout<<"failure"<<endl;
  delete [] p;

}
catch(const std::bad_alloc &ex)
{
  cout<<ex.what()<<endl;
}

    return 0;
}


2.nothrow new/delete不抛出异常的运算符new的形式,new失败时返回NULL。
定义如下:
复制代码代码如下:
void *operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();
struct nothrow_t{};  const nothrow_t nothrow;//nothrow作为new的标志性哑元

测试程序:
复制代码代码如下:
#include "stdafx.h"
#include <iostream>
#include <new>
using namespace std;

char *GetMemory(unsigned long size)
{
char *p=new(nothrow) char[size];//分配失败,是返回NULL
if(NULL==p)
  cout<<"alloc failure!"<<endl;
return p;
}

int main()
{
try
{
  char *p=GetMemory(10e11);
  //...........
  if(p==NULL)
   cout<<"failure"<<endl;
  delete [] p;

}
catch(const std::bad_alloc &ex)
{
  cout<<ex.what()<<endl;
}

    return 0;
}


3.placement new/delete 主要用途是:反复使用一块较大的动态分配成功的内存来构造不同类型的对象或者它们的数组。例如可以先申请一个足够大的字符数组,然后当需要时在它上面构造不同类型的对象或数组。placement new不用担心内存分配失败,因为它根本不分配内存,它只是调用对象的构造函数。

测试程序:

复制代码代码如下:
#include "stdafx.h"
#include <iostream>
#include <new>
using namespace std;

class ADT
{
int i;
int j;
public:
ADT()
{
}
~ADT()
{
}
};

int main()
{
char *p=new(nothrow) char[sizeof(ADT)+2];
if(p==NULL)
  cout<<"failure"<<endl;

ADT *q=new(p) ADT;  //placement new:不必担心失败
// delete q;//错误!不能在此处调用delete q;
q->ADT::~ADT();//显示调用析构函数
delete []p;
    return 0;
}


注:使用placement new构造起来的对象或数组,要显式调用它们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete.这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。

四、new创建类对象与不new区别

下面是自己总结的一些关于new创建类对象特点:

  • new创建类对象需要指针接收,一处初始化,多处使用
  • new创建类对象使用完需delete销毁
  • new创建对象直接使用堆空间,而局部不用new定义类对象则使用栈空间
  • new对象指针用途广泛,比如作为函数返回值、函数参数等
  • 频繁调用场合并不适合new,就像new申请和释放内存一样

五、new创建类对象实例

1、new创建类对象例子:

CTest* pTest = new CTest();

delete pTest;

pTest用来接收类对象指针。

不用new,直接使用类定义申明:

CTest mTest;

此种创建方式,使用完后不需要手动释放,该类析构函数会自动执行。而new申请的对象,则只有调用到delete时再会执行析构函数,如果程序退出而没有执行delete则会造成内存泄漏。

2、只定义类指针

这跟不用new申明对象有很大区别,类指针可以先行定义,但类指针只是个通用指针,在new之前并为该类对象分配任何内存空间。比如:

CTest* pTest = NULL;

但使用普通方式创建的类对象,在创建之初就已经分配了内存空间。而类指针,如果未经过对象初始化,则不需要delete释放。

最后注意:普通方式创建的类对象在出了对象定义的范围后自动析构

                    new创建的类对象除非进程结束或显示调用delete释放

六、理解C++中new背后的行为

1:C++标准说:An allocation function shall be a class member function or a global function; a program is ill-formed if an allocation function is declared in a namespace scope other than global scope or declared static in global scope.
必须是全局函数或类成员函数,而不能是全局之外的名字空间或static全局函数。

2:new operator的行为
Foo* p = new Foo;

...
delete p;
我们知道,上面的代码,也就是C++中的new操作符(new operator)大致会做下面的事情:
a.调用operator new, 给对象分配内存
b.调用Foo的构造函数
c.返回指针
...
d. 调用Foo的析构函数~Foo()
e. 调用operator delete释放内存

更具体的, new operator的行为如下:
对于如下代码:
    Foo* p = new(arg1,arg2,…… ) Foo(para1, para2, ...);
    ...
    delete p;
编译器将生成如下代码:
    调用 p = operator new( size_t 需要的大小,arg1,arg2,…… );  // 分配内存,这里有可能抛出std::bad_alloc,但无须在new operator中捕捉
    如果构造Foo没有抛出异常                                                    // 即Foo的构造函数后面显式的声明了 throw()
        在p指向处构造foo(para1,para2,……);                              // 调用Foo的构造函数
        return p;
    否则
        try
        {
            在p指向处构造Foo(para1,para2,……);
            return p;
        }
        catch(...)
        {
            调用 operator delete( void* p, arg1,arg2,…… );
            throw;
        }
    ...
    调用 Foo的析构函数~Foo();
    调用 operator delete( void* p );

从上面的步骤可以看出:
(1)对于operator new, 我们只要确保第一参数是表示申请内存的大小, 其他参数可以自己随意重载
(2)只有Foo构造失败(构造函数内抛出异常),我们的operator delete( void* p, arg1,arg2,…… )才会被调用,否则只会调用operator delete( void* p )

3:全局形式的operator new伪代码
void* operator new( size_t size ) // 包括其他形式
{
    if( 0 == size ) // 须要注意
        size = 1;

    while(1)
    {
        分配size字节内存;
        if(分配成功)
            return 指向内存的指针;

        new_handler globalhandler = set_new_handler(0);
        set_new_handler(globalhandler);

        if( globalhandler )
            (*globalhandler)();
        else
            throw std::bad_alloc();
    }
}
void operator delete( void* raw )
{
    if( 0 == raw ) // 须要注意
        return;
    ...
}
须要说明的是,编译器本身就隐含着一个 void* operator new( size_t ),所以重载全局operator new必须加其他参数以示区别。
一般重载分配函数时都会重载三个,分别是 void* operator new( size_t, …… ),void operator delete( void*, …… ),以及一般形式的 void operator delete( void* )。

4. set_new_handler的作用
    set_new_handler设置一个函数,此函数将在分配内存失败时被调用,见3中的代码。
    从3中的代码还能看得出,new_handler必须有主动退出的功能,否则就会导致operator new内部死循环。因此newhandler的一般形式是:
    void mynewhandler()
    {
        if( 有可能使得operator new成功(比如释放部分内存) )
        {
            做有可能使得operator new成功的事
            return;
        }
        // 主动退出
        或 abort/exit 直接退出程序
        或 set_new_handler(其他newhandler);
        或 set_new_handler(0)
        或 throw bad_alloc()或派生类 // 这一种比较好,不粗鲁的关闭程序,也不更改其他设置
    }
须要说明的是,没有类形式的set_new_handler,但这也无所谓,你可以自己写。(见《Effective C++ 2e》条款7)

5. 类形式的operator new伪代码:
struct base
{
    ...
    static void* operator new( size_t size );
    static void operator delete( void* raw );
};
void* base::operator new( size_t size )
{
    if( sizeof(base) != size ) // 须要注意
        return ::operator new(size);

    类似于3 // 注意“没有类形式的set_new_handler”
}
void base::operator delete( void* raw )
{
    if( sizeof(base) != size ) // 须要注意
    {
        ::operator delete(raw);
        return;
    }
    同3
}

6. operator new的函数类型: 对我们来说一般有3种是语言要求的标准operator new(plain new, nothrow new, placement new):
void *operator new(std::size_t count) throw(std::bad_alloc);             //一般的版本(plain new)
void *operator new(std::size_t count,  const std::nothrow_t&) throw();    //兼容早版本, new内存分配失败不会抛出异常(nothrow new)
void *operator new(std::size_t count, void *ptr) throw();  //placement版本(placement new)
上面的方法我们可以这样调用:
Foo* p = new Foo;
delete p;

Foo* p1 = new(std::nothrow) Foo;
delete p1;

Foo f;
Foo* p2 = new(&f) Foo; p2->~Foo();

针对数组则是:
void *operator new[](std::size_t count) throw(std::bad_alloc);            
void *operator new[](std::size_t count,  const std::nothrow_t&) throw();    
void *operator new[](std::size_t count, void *ptr) throw();  
可以看到上面函数第一个都是对象空间大小,除了重载C++中operator new的标准类型,另外我们也可以重载其他类型的operator new, 比如
void *operator new(std::size_t count, const string& s) throw(std::bad_alloc);  
void *operator new[](std::size_t count, const string& s) throw(std::bad_alloc); 

然后就可以这样调用了: string str("abc"); Foo* p = new(str) Foo;

当然,如果我们自己重写了operator new, 最好我们也重写operator delete,这样如果我们的构造函数里抛出异常,我们自己重写的operator delete会被调用。(当然,如果构造对象成功,最后delete时只会调用operator delete( void* p ))

比如针对上面新加的operator new函数,新加operator delete如下:
void operator delete(void* p, const string& s) throw();
void operator delete[](void* p, const string& s) throw(); 可以看到,自己新加的operator delete, 只需确保第一个参数内存指针。

7. new operator和operator new的区别

      new operator就象sizeof一样是语言内置的,我们不能改变它的含义,它的功能总是一样的。它要完成的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new operator总是做这两件事情,你不能以任何方式改变它的行为。

  我们所能改变的是如何为对象分配内存。new operator调用一个函数来完成必需的内存分配,你能够重写或重载这个函数来改变它的行为。new operator为分配内存所调用函数的名字是operator new。

      如果想在堆上建立一个对象,应该用new operator。它既分配内存又为对象调用构造函数。如果你仅仅想分配内存,就应该调用operator new函数;它不会调用构造函数。如果你想定制自己的在堆对象被建立时的内存分配过程,你应该写你自己的operator new函数,然后使用new operator,new operator会调用你定制的operator new。如果你想在一块已经获得指针的内存里建立一个对象,应该用placement new。
      与new operator/operator new相对应的是delete operator/operator delete, 当我们调用delete operator时,实际上包含析构函数调用和通过operator delete释放内存2个阶段。
   我们可以单纯的通过operator new 和 operator delete来分配和释放内存:
    void *buffer = operator new(50*sizeof(char)); // 内存以容纳50个char, 没有调用构造函数   ...
  operator delete(buffer); // 释放内存, 没有调用析构函数
8. operator new的一些原则: a. 一般不要重写全局的operator new, 具体可以参考 不要重载全局 ::operator new
b. 如果重载了operator new, 同时提供所有版本(plain new, nothrow new, placement new)
c. 成对的提供new和delete, 即如果重载了operator new, 同时重载operator delete

七、从汇编角度看new和delete的背后行为

我们的代码很简单, 如下: #include <iostream> class A { public: virtual void print() { std::cout << 10; } virtual ~A() { std::cout << "~A()"; } }; class B: public A { public: virtual void print() { std::cout << 100; } }; int _tmain(int argc, _TCHAR* argv[]) { A* p = new B(); p->print(); delete p; return 0; } 我用WinDbg可以看到main函数生成的汇编代码如下:  NewTest!wmain: 00aa1020 56              push    esi 00aa1021 6a04            push    4  00aa1023 e8b4030000      call    NewTest!operator new (00aa13dc) //调用operator new分配大小为4字节的空间 00aa1028 83c404          add     esp,4 00aa102b 85c0            test    eax,eax 00aa102d 740a            je      NewTest!wmain+0x19 (00aa1039) 00aa102f c7005421aa00    mov     dword ptr [eax],offset NewTest!B::`vftable" (00aa2154) //将虚表地址写入对象地址的头4个字节(虚表指针) 00aa1035 8bf0            mov     esi,eax 00aa1037 eb02            jmp     NewTest!wmain+0x1b (00aa103b) 00aa1039 33f6            xor     esi,esi 00aa103b 8b06            mov     eax,dword ptr [esi] 00aa103d 8b10            mov     edx,dword ptr [eax] 00aa103f 8bce            mov     ecx,esi 00aa1041 ffd2            call    edx //调用虚表内的第一个函数print 00aa1043 8b06            mov     eax,dword ptr [esi] 00aa1045 8b5004          mov     edx,dword ptr [eax+4] 00aa1048 6a01            push    1 00aa104a 8bce            mov     ecx,esi 00aa104c ffd2            call    edx //调用虚表内的第二个函数(析构函数) 00aa104e 33c0            xor     eax,eax 00aa1050 5e              pop     esi 00aa1051 c3              ret 00aa1052 cc              int     3 从上面代码中我们可以看到我们构造的B对象一共只有4个字节,而这四个字节包含的就是对象的虚表指针,对于C++对象内存布局, 对于C++对象的内存布局,可以看我这篇 探索C++对象模型。同时我们可以看到, C++里确实是通过虚表来实现多态的。
上面的代码也告诉了我们为什么不能在构造函数里通过调用虚函数实现多态? 因为虚表是在最终派生类的构造函数中生成的的, 执行基类构造函数时虚表都还没有生成。
接下来我们看看operator new背后的行为: 0:000> u 00aa13dc NewTest!operator new: 00aa13dc ff25cc20aa00    jmp     dword ptr [NewTest!_imp_??2YAPAXIZ (00aa20cc)] 里面是一个直接跳转: 0:000> u poi(00aa20cc) L10 MSVCR90!operator new: 74603e99 8bff            mov     edi,edi 74603e9b 55        
声明:该文观点仅代表作者本人,牛骨文系教育信息发布平台,牛骨文仅提供信息存储空间服务。