一、基本概念
1. 基本流程以及和malloc/free的区别
众所周知,C++ 中使用new
和delete
关键字来进行动态的内存分配和释放,那么这俩关键字到底进行了什么操作呢?可以概括为先申请内存,再构造对象/先析构对象,再释放内存。
相比于传统C语言的malloc
/free
,new
和delete
本质上是多了对对象构造/析构函数的调用(会对对象初始化),也因此不需要传入分配内存大小(编译器会根据类型大小来推断),并且可以直接返回一个对应类型的指针(而不是像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
,仅仅用于内存的申请和释放,不参与对象构造与析构:
- 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); }
- array plain new/delete
本质上和非数组的plain new/delete
没什么区别,参数和返回类型,以及进行的操作都是一样的。void* operator new[](size_t size) { return malloc(size); } void operator delete[](void* obj) { free(obj); }
- nothrow new/delete
前面说过,plain new
在内存分配失败的时候会抛出std::bad_alloc
异常,而使用nothrow
的new
则不会,只会返回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); }
- 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; }
- 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、 解决提出的问题
我们来回到一下上述的两个问题:
-
为什么
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
。 -
为什么不能直接使用
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的一些其他注意事项
-
不要手动调用析构函数!
原因是,
delete
流程会检查对象是否已被析构,如果被析构,那么将不会释放对象所占的空间,造成内存泄漏。但是在placement new
中,我们必须手动调用析构函数,原因上面已经说明。 -
正确使用自定义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);
),在这个调用之外可以添加我们自定义的操作。 -
new/delete其实不一定会调用构造和析构函数
这种情况只会发生在我们自定义的operator new返回了一个NULL的情景中,此时根本没有分配内存,也就根本没有必要调用类的构造函数。相应地,析构函数也不会被调用。
-
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[]配合使用,以避免可能引发的严重问题。 -
如何让对象只能在堆/栈上分配?
如何让对象只能在堆上分配?可以直接将析构函数设为私有,这样如果在栈上分配对象,系统无法自动释放(无法调用析构函数)。但是这样会导致我们自己动态分配的对象,也无法在外部直接用delete删除,因此可以增加public的伪析构函数,函数体中使用
delete this
(类内是可以随意访问私有析构函数的),在释放对象时手动调用该伪析构函数。
那如何让对象只能在栈上分配呢?也很简单,只需要将operator new
设为私有即可。这样做的原因是因为堆上分配时,我们对于内存分配和释放是可控的,而栈上分配时,对于内存分配和释放是不可控的(由系统进行),因此只能控制对象构造和析构过程。