一、基本概念

1. 基本流程以及和malloc/free的区别

众所周知,C++ 中使用newdelete关键字来进行动态的内存分配和释放,那么这俩关键字到底进行了什么操作呢?可以概括为先申请内存,再构造对象/先析构对象,再释放内存
相比于传统C语言的malloc/freenewdelete本质上是多了对对象构造/析构函数的调用(会对对象初始化),也因此不需要传入分配内存大小(编译器会根据类型大小来推断),并且可以直接返回一个对应类型的指针(而不是像malloc一样,返回一个void*类型的指针,并且需要强制转换为对象类型的指针,因此malloc并不是类型安全的,因为可以任意转换)。其与malloc/free的另一个重要区别是,如果内存分配失败,会抛出std::bad_alloc异常,而不是像malloc一样返回一个NULL(当然你可以使用nothrow new来在分配失败时返回NULL)。
new实际上是两步,先调用operator new来申请一段内存,然后调用对应类型构造函数在这段内存上初始化对象;delete也是一样,先调用析构函数来析构对象,然后调用operator delete来释放内存;这些操作默认有C内置实现,但C同样为用户提供了自定义operator new/delete的机会(注意不是new/delete关键字,关键字是无法重定义的!)

2. 可自定义的形式

实际上,C++ 提供了五种形式的可自定义new/delete组合,这些组合实际是new/delete关键字执行操作所调用的operator new/operator delete,仅仅用于内存的申请和释放,不参与对象构造与析构:

  1. plain new/delete
    new的实际调用过程是首先调用malloc分配一片内存,然后在这片内存上调用对应类型的构造函数(如果有);delete则恰恰相反,先调用对应类型的析构函数,再用free释放占用的内存。自定义如下,并且类中定义的版本会覆盖全局自定义/默认的版本(其他new/delete组合一样):
    void* operator new(size_t size) { return malloc(size); }
    void operator delete(void* obj) { free(obj); }
    
  2. array plain new/delete
    本质上和非数组的plain new/delete没什么区别,参数和返回类型,以及进行的操作都是一样的。
    void* operator new[](size_t size) { return malloc(size); }
    void operator delete[](void* obj) { free(obj); }
    
  3. nothrow new/delete
    前面说过,plain new在内存分配失败的时候会抛出std::bad_alloc异常,而使用nothrownew则不会,只会返回NULL。但是在delete该种new分配的对象时,不会调用相应的nothrow delete,而且需要我们手动调用对象析构函数,原因后面会讲,这里的第二个参数std::nothrow是一个哑元(实际可以不指定参数名,目的是和plain new/delete进行区分)
    void* operator new(size_t size, const std::nothrow_t& nt) { return malloc(size); }
    void operator delete(void* obj, const std::nothrow_t& nt) { free(obj); }
    
  4. placement new/delete
    所谓的placement new,是允许你在已分配的内存,或者叫缓冲区(栈、堆或其他可用储存区均可)上来初始化对象,它不像其他operator new,实际并不分配内存,有两个参数,第一个仍然是分配内存大小,第二个则是缓冲区首地址,系统将在缓冲区首地址开始的一段内存上初始化对象。同样placement delete也不会被显式调用,更进一步,不能使用delete来不能使用delete来删除这种方式new出的对象删除这种方式new出的对象,原因下面会讲。并且由于placement new本身不分配内存,placement delete也就不需要释放内存,事实上,C++内置的placement delete函数定义中,只有一个return(笑)。
    事实上,这类new一般用于频繁创建和销毁的对象(一些生命周期较短的对象)上,因为如果每次创建/销毁对象都重新开辟/释放一段内存,势必影响性能,因此有必要使用一段预先分配好的内存作为缓冲区,来初始化/销毁对象,以避免额外的开销。
    void* operator new(size_t size, void* ptr) { return ptr; }
    void operator delete(void* obj, void* ptr) { return; }
    
  5. user-defined new/delete
    这种new是用户自定义的,可由用户自定义参数,C++是没有内置定义的,比如我们可以为这种new指定一个std::ostream参数,来记录内存的申请与释放。
    void* operator new(size_t size, int m) { return malloc(size); }
    void operator delete(void* obj, int m) { free(obj); }
    

二、问题解析

1、 解决提出的问题

我们来回到一下上述的两个问题:

  1. 为什么nothrow new构造的对象在delete时不会调用相应的nothrow delete?那nothrow delete有什么用呢?

    第一个问题,因为delete在调用时是无法指定参数的,而只要使用delete,系统就会自动去使用plain operator delete释放内存,而不是其他的operator delete

    第二个问题,这里就要谈到运行系统在new未能正确返回指针时的fallback机制了,因为new实际上分两步完成,第一步分配内存,第二步调用构造函数,如果第一步成功而第二步失败,内存已经分配,但用户获取不到内存地址,因为构造对象失败了,也就无法手动释放申请的内存,那么这块内存该由谁来释放?当然是C++ 运行系统来调用delete自动释放,但是系统如何知道要调用哪一个delete?
    C++ 选择的方式是,使用那个和分配内存时所使用的new函数参数列表完全一致的delete函数(除首个参数,因为operator new的首个参数必须是申请内存大小,而operator delete的首个参数必须是对象内存地址),比如对于nothrow new,就调用nothrow delete进行释放,这就是其存在的意义。但这里有一个问题,找不到对应的delete怎么办?那么编译器就会什么也不做,这可能导致内存泄漏。因此需要保证每一种形式的operator new有对应的operator delete

  2. 为什么不能直接使用delete来删除placement new创建的对象?

    这是因为placement new并没有申请内存,而只是在预分配的一段内存(缓冲区)上调用对象的构造函数,之前说过,缓冲区不一定在堆上分配,也可以在栈上或者其他区域,如果直接使用delete,释放的是缓冲区的内存,这会引起两个问题:一是缓冲区上其他的对象将被意外释放,这不是我们所期望的,我们只想要释放当前对象;二是释放的内存如果不在堆上,会引发内存问题。因此虽然编译器没有严格禁止delete一个placement new创建的对象,但这会导致未知的行为,因此,我们我们不应该这样做。

    同样,placement operator delete也只在两步placement new出现异常的时候被调用,但实际上它不做任何事,因为我们在此不需要释放内存(除非构造函数里有内存申请操作)。不过,和其他的new方式不同的是,系统不会帮我们自动调用对象的析构函数,猜测这可能是因为没有释放任何内存?因此需要我们手动去调用构造函数,事实上我们可以在placement operator delete中进行这件事,要做的就是将传入的对象指针转换为类类型指针,再调用其析构函数,最后在代码中手动调用placement operator new。要注意的是,如果是使用类内定义的placement operator delete,需要加类型限定符。

    void operator delete(void* obj, void* ptr) { static_cast<Test*>(obj)->~Test(); return; }
    Test::operator delete(test4, buffer);
    

2. 关于全局/局部的operator new/delete

operator new/delete在全局和局部(类内)的两个版本是允许共存的,那么编译器如何知道我们调用的是哪个operator new/delete(全局还是局部)呢?实际上我们在使用new关键字的时候,已经传入了类的类型名,因此new T实际上会先去查找T::operator new是否存在,如果存在直接使用,否则才会去查找是否有用户定义的相应的operator new,最后才使用默认的operator new,但是我们直接调用operator new时实际不会去查找类内的,只会使用全局的。
但是上面的查找实际上是有限定查找,这意味着,如果在类内有其他的operator new,但不是我们所需要的,而类内没有我们需要的operator new,查找在这一步会失败。如果类内没有其他的operator new,查找才会继续。对于operator delete也是同理。

3. 关于new/delete的一些其他注意事项

  1. 不要手动调用析构函数!

    原因是,delete流程会检查对象是否已被析构,如果被析构,那么将不会释放对象所占的空间,造成内存泄漏。但是在placement new中,我们必须手动调用析构函数,原因上面已经说明。

  2. 正确使用自定义operator/new

    首先,我们其实不应该去自定义全局的operator new/delete,这会对全部类型的new/delete操作产生影响,而应该只在类内自定义operator new/delete,这只会对该类的new/delete操作产生影响。
    另外,即使是在类内自定义operator new/delete,我们也不应该去修改其默认行为(即分配/释放内存的行为),因为我们最好将类内的operator new/delete的调用转发给全局(默认)的operator new/delete(使用全局限定符,比如return ::operator new(size);),在这个调用之外可以添加我们自定义的操作。

  3. new/delete其实不一定会调用构造和析构函数

    这种情况只会发生在我们自定义的operator new返回了一个NULL的情景中,此时根本没有分配内存,也就根本没有必要调用类的构造函数。相应地,析构函数也不会被调用。

  4. delete和delete[]的区别

    delete和delete[]有什么主要区别呢?主要在于delete[]有一个额外的检查。对于内置简单类型的数组来说,delete也好,delete[]也好,都不会调用其析构函数,但是对于类类型,在释放(动态分配的)对象数组时,如果类具有显式析构函数,系统需要知道数组中到底有几个对象,从而调用对应次数的析构函数,如何知道呢?通过额外的信息,因此在为对象数组申请动态内存时,申请内存的起始地址实际上是在数组起始地址(也就是new返回的地址)的前4字节(32位系统)或前8字节(64位系统)处,而多申请的这片内存实际上就存放了对象数组的长度,在用delete[]释放对象数组时,系统先从实际的起始位置读出对象数组长度,然后按照类大小访问数组中每个对象并以此调用其析构函数(注意是倒着调用,即从最后一个对象开始向前依次调用析构函数,因此可能产生奇怪的问题,后面会讲),最后再释放整片内存;
    如果这里换成delete有什么问题?很明显,delete不会去读数组长度,从而只会调用一次析构函数(这里new返回的指针一定是数组第一个对象的首地址,因此可以成功调用一次析构函数),但是随后在进行内存释放时,发现内存链表实际找不到这个起始地址的内存块,因为分配的这块内存实际起始地址在更前一点的位置(4字节或8字节),因此发生段错误,引发程序崩溃;当然
    当然这里有一个例外,也就是之前提到的内置简单类型数组,在为这类数组动态申请内存时,系统不会记录数组长度,因为这些类型根本没有析构函数,直接释放内存即可。故用delete去释放是没问题的(但不推荐!)
    相反地,如果用delete[]去释放new申请的内存,由于delete[]会先读前8个或前4个字节,但是这里读出的内容实际是无效而且未知的,系统以为这是一个对象数组的长度,但其实是一个未知值,由于调用析构函数的顺序是反着来的,假设读出的数组长度为8,系统就会从第8个对象位置(start + 7 * sizeof(Object))开始调用析构函数,但是实际只有一个对象,那么这么调用不会出错吗?不会,因为调用成员函数,实际上是将this传入了成员函数的第一个参数,成员函数本身是属于类而不是属于对象的,但是在一些情况下可能会出错,比如成员包含指针等,因为后面那些地方的内容是不可知的。因此会出现的情况就是调用很多次(次数未知)的析构函数,并且最后由于会从 首地址-8 处释放内存,实际内存首地址是没有-8的,此时也会引发程序崩溃。
    所有,建议new/delete 和 new[]/delete[]配合使用,以避免可能引发的严重问题。

  5. 如何让对象只能在堆/栈上分配?

    如何让对象只能在堆上分配?可以直接将析构函数设为私有,这样如果在栈上分配对象,系统无法自动释放(无法调用析构函数)。但是这样会导致我们自己动态分配的对象,也无法在外部直接用delete删除,因此可以增加public的伪析构函数,函数体中使用delete this(类内是可以随意访问私有析构函数的),在释放对象时手动调用该伪析构函数。
    那如何让对象只能在栈上分配呢?也很简单,只需要将operator new设为私有即可。这样做的原因是因为堆上分配时,我们对于内存分配和释放是可控的,而栈上分配时,对于内存分配和释放是不可控的(由系统进行),因此只能控制对象构造和析构过程。