1.问题分析
先来看一段简单的代码,以及他的汇编指令:
/*
汇编指令:
int* p = new int;
000230B6 push 4
000230B8 call operator new (02116Dh)
000230BD add esp,4
000230C0 mov dword ptr [ebp-0D4h],eax
000230C6 mov eax,dword ptr [ebp-0D4h]
000230CC mov dword ptr [p],eax
delete p;
008D2BCF mov eax,dword ptr [p]
008D2BD2 mov dword ptr [ebp-0E0h],eax
008D2BD8 push 4
008D2BDA mov ecx,dword ptr [ebp-0E0h]
008D2BE0 push ecx
008D2BE1 call operator delete (08D11EFh)
008D2BE6 add esp,8
我们可以注意到 new 和 delete 这两个表达式背后调用了两个函数
重载函数 operator new 和 operator delete
*/
int main()
{
int* p = new int;
delete p;
int* q = new int[10];
delete[]q;
return 0;
}
那么我们自己可以写一个全局版本的 operator new/delete,operator new[]/delete[] 来模拟 C++ 标准库提供的全局版本,代码如下:
void* operator new(size_t size)
{
cout << "my global operator new, size = " << size << endl;
void* p = malloc(size);
if (p == nullptr)
throw bad_alloc();
cout << "operator new addr: " << p << endl;
return p;
}
void operator delete(void* p)
{
cout << "my global operator delete" << endl;
cout << "operator delete addr: " << p << endl;
free(p);
}
void* operator new[](size_t size)
{
cout << "my global operator new[], size = " << size << endl;
void* p = malloc(size);
if (p == nullptr)
throw bad_alloc();
cout << "operator new[] addr:" << p << endl;
return p;
}
void operator delete[](void* ptr)
{
cout << "my global operator delete[]" << endl;
cout << "operator delete[] addr:" << ptr << endl;
free(ptr);
}
那么我们来看看打印结果:
my global operator new, size = 4
operator new addr: 010A0900
my global operator delete
operator delete addr: 010A0900
my global operator new[], size = 40
operator new[] addr:0109B700
my global operator delete[]
operator delete[] addr:0109B700
可见 new 和 delete 表达式会把定义的全局 operator new/delete 和 operator new[]/delete[] 调用一下。但是有一点要注意,在 operator delete 的时候,汇编指令上压了两个参数:一个 4 和 p 的地址。
delete p;
008D2BCF mov eax,dword ptr [p]
008D2BD2 mov dword ptr [ebp-0E0h],eax
008D2BD8 push 4
008D2BDA mov ecx,dword ptr [ebp-0E0h]
008D2BE0 push ecx
008D2BE1 call operator delete (08D11EFh)
008D2BE6 add esp,8
那么我们跳转到 operator delete 后面的地址看一下,跳转后,会显示一个函数签名和一个指令:
operator delete(void *,unsigned int)
008D11EF jmp operator delete (08D2C80h)
我们再跳到 08D2C80h 看一下:
008D2C80 push ebp
008D2C81 mov ebp,esp
008D2C83 mov eax,dword ptr [block]
008D2C86 push eax
008D2C87 call operator delete (08D10C8h)
008D2C8C add esp,4
008D2C8F pop ebp
008D2C90 ret
这里又显示了 call operator delete 跳转到后面的地址,就是我们定义的全局的 operator delete ,所以编译器并不会直接调用我们写的 operator delete 而是中间套了一层 sized delete。这个 sizeed delete 也可以自己实现一个,使用 sized delete 的时候,operator delete 函数内部知道你要释放的内存多大。
我们运行下面的代码,看看打印结果:
/*
打印结果:
my global operator new, size = 4
operator new addr: 01370890
p: 01370890
my global operator delete
operator delete addr: 01370890
————————————–
my global operator new[], size = 40
operator new[] addr:0136B700
q: 01370890
my global operator delete[]
operator delete[] addr:0136B700
*/
int main()
{
int* p = new int;
cout << "p: " << p << endl;
delete p;
cout << "————————————–" << endl;
int* q = new int[10];
cout << "q: " << p << endl;
delete[]q;
return 0;
}
可以看到不论开辟数组,还是单个元素,开辟空间时的地址和释放内存时的地址还有返回的地址都是一样的。
那么我们来看一个问题,C++ 中的 new 和 delete 可以混用吗?为什么 C++ 要区分单个元素和数组元素的开辟和释放?
那么下面这段代码能运行吗?
int main()
{
int* p = new int;
cout << "p: " << p << endl;
delete []p;
cout << "————————————–" << endl;
int* q = new int[10];
cout << "q: " << q << endl;
delete q;
return 0;
}
/*
打印结果:
my global operator new, size = 4
operator new addr: 00B60900
p: 00B60900
my global operator delete[]
operator delete[] addr:00B60900
————————————–
my global operator new[], size = 40
operator new[] addr:00B5B700
q: 00B5B700
my global operator delete
operator delete addr: 00B5B700
*/
答案是可以运行的,此时开辟的空间是内置类型的,不涉及到对象的构造与析构,所以 new 和 delete 只剩下 malloc 和 free 的功能了,混用是没有问题的。
那么我们定义一个类,来看看开辟的空间是类类型会怎么样:
class Test
{
public:
Test() { cout << "Test" << endl; }
~Test() { cout << "~Test()" << endl; }
private:
int ma;
};
我们来看这段代码能不能运行:
int main()
{
Test* p1 = new Test();
delete []p1;
return 0;
}
/*
打印结果:
my global operator new, size = 4
operator new addr: 012F0890
Test
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
~Test()
…
下面还有很多~Test()
*/
那下面这段代码呢?
int main()
{
Test* p1 = new Test[5];
delete p1;
return 0;
}
/*
上面这段代码会崩溃,但是还是有打印结果的:
my global operator new[], size = 24
operator new[] addr:015BEDC0
Test
Test
Test
Test
Test
~Test()
my global operator delete
operator delete addr: 015BEDC4
*/
从这两段的结果可以看出,如果类型是一个对象,那么就混用不了了。
我们来分析一下原因,先来分析一下下面这段不混用的代码:
int main()
{
Test* p1 = new Test[5];
cout << "p1: " << p1 << endl;
delete []p1;
return 0;
}
/*
打印结果:
my global operator new[], size = 24
operator new[] addr:007BF120
Test
Test
Test
Test
Test
p1: 007BF124
~Test()
~Test()
~Test()
~Test()
~Test()
my global operator delete[]
operator delete[] addr:007BF120
*/
那么打印的第一行就很奇怪了,为什么开辟的 size = 24 呢?我们的 Test 类中只有一个成员变量 ma,那么开辟 5 个不应该是 size = 20 吗?
所以这里多出来的 4 个字节就是开辟空间的时候多开辟的一个 4 字节的空间来记录开辟对象的个数,叫array cookie!!!后面打印了一个开辟空间的地址 007BF120 之后对 5 个对象进行构造,在主函数中我们打印了 new 表达式返回地址 p1,可以看出这个 p1 比开辟空间时的地址大 4,但是在 operator delete 的时候,地址又变回了开辟空间时候的地址。也就是把计数的 4 个字节和后面对象空间全都释放了。
同样的分析一下下面的代码:
int main()
{
Test* p1 = new Test();
cout << "p1: " << p1 << endl;
delete p1;
return 0;
}
/*
打印结果:
my global operator new, size = 4
operator new addr: 01270900
Test
p1: 01270900
~Test()
my global operator delete
operator delete addr: 01270900
*/
这里可以看出,如果开辟的不是对象数组,那么这三个打印的地址就是一样的。也就是不会另外开辟空间存对象的个数。
那么现在再来看之前混用的代码:
int main()
{
Test* p1 = new Test();
delete []p1;
return 0;
}
/*
分析(GPT给出的): 首先 new 表达式会先调用 operator new 开辟空间。由于这里只开辟一个 Test 对象的空间,
Test 类里面只有一个 int ma,所以 sizeof(Test) == 4,因此 operator new 收到的 size 是 4。
operator new 通过 malloc(size) 申请一块 4 字节的堆内存,并打印这块内存的起始地址,例如:
operator new addr: 012F0890
接着 new 表达式会在 012F0890 这块内存上调用 Test 的构造函数,因此打印:
Test
到这里为止,内存布局大概是:
012F0890
↓
┌──────────────┐
│ Test 对象 │ 只有一个对象,大小 4 字节
└──────────────┘
注意: 这是单对象 new,不是数组 new。
所以 012F0890 前面没有保存“数组元素个数”的 array cookie。
但是后面写的是:
delete[] p1;
delete[] 是数组释放表达式。对于带析构函数的类类型数组,delete[] 需要知道数组里有多少个元素,
因为它必须对数组中的每个对象都调用析构函数。
如果是正常写法:
Test* p = new Test[10];
delete[] p;
那么编译器通常会在数组对象前面额外保存一个元素个数,也就是 array cookie。
内存布局大概是:
真实 malloc/operator new[] 返回地址
↓
┌──────────────┐
│ 元素个数 10 │ array cookie
├──────────────┤
│ Test[0] │ p 指向这里
├──────────────┤
│ Test[1] │
├──────────────┤
│ … │
├──────────────┤
│ Test[9] │
└──────────────┘
delete[] p 的时候,会先从 p 前面的 cookie 中取出元素个数 10,
然后按照数组元素个数依次调用析构函数,通常是从后往前析构:
p[9].~Test();
p[8].~Test();
…
p[0].~Test();
最后再调用 operator delete[] 释放整块数组内存。
但是现在的问题是:
Test* p1 = new Test();
delete[] p1;
p1 是单对象 new 得到的地址,p1 前面没有合法的 array cookie。
delete[] 却会把 p1 当成数组首元素地址,然后试图从 p1 前面的位置读取“数组元素个数”。
例如在 32 位环境下,它可能会尝试读取:
*(size_t*)((char*)p1 – 4)
也就是访问 012F0890 前面的 4 个字节:
012F088C
但是这个位置并不是数组元素个数。
它可能是 CRT 堆管理器的调试信息,也可能是填充值,也可能是其他无关数据。
因此 delete[] 读出来的元素个数是一个错误值,甚至可能是一个很大的值。
假设它误读出来的数组元素个数是 N,那么 delete[] 就会以为这里有 N 个 Test 对象,
于是开始循环调用析构函数:
p1[N – 1].~Test();
p1[N – 2].~Test();
…
p1[0].~Test();
但实际上这里根本只有一个 Test 对象。
所以后面的析构调用都是在对不存在的对象调用析构函数。
由于 Test 的析构函数只是打印 "~Test()",没有访问成员变量,也没有释放资源,
所以程序可能暂时没有立刻崩溃,而是连续打印很多次:
~Test()
~Test()
~Test()
…
如果 Test 的析构函数里面访问成员变量、释放指针、关闭文件、操作资源,
那么程序很可能更早崩溃,或者造成更严重的内存破坏。
等析构循环结束后,delete[] 还会继续尝试调用 operator delete[] 释放数组内存。
但是由于这块内存本来不是 new[] 申请的,delete[] 可能传入错误的释放地址,
例如传入 p1 前面某个位置的地址,这也可能导致 free 崩溃或者堆损坏。
*/
再来看看下面这个混用的代码:
/*
分析(GPT给出的): 首先执行:
Test* p1 = new Test[5];
这是数组 new 表达式,会调用 operator new[] 开辟一整块数组空间。
Test 类里面有一个 int ma,所以 sizeof(Test) == 4。
如果只是 5 个 Test 对象本身,那么对象数据大小应该是:
5 * sizeof(Test) = 5 * 4 = 20 字节
但是实际打印:
my global operator new[], size = 24
说明 operator new[] 实际申请了 24 字节,而不是 20 字节。
多出来的 4 字节通常就是 array cookie,用来保存数组元素个数。
因为 Test 有析构函数,delete[] 时需要知道要调用多少次析构函数,
所以编译器会在数组前面额外保存一个元素个数。
因此内存布局大概是:
operator new[] 返回地址: 015BEDC0
↓
┌──────────────┐
│ cookie = 5 │ 4 字节,保存数组元素个数
├──────────────┤
│ Test[0] │ p1 指向这里,地址 015BEDC4
├──────────────┤
│ Test[1] │
├──────────────┤
│ Test[2] │
├──────────────┤
│ Test[3] │
├──────────────┤
│ Test[4] │
└──────────────┘
所以可以解释这两个地址:
operator new[] addr: 015BEDC0
operator delete addr: 015BEDC4
015BEDC0 是整块数组内存的真实起始地址。
015BEDC4 是第一个 Test 对象的地址,也就是 p1 的值。
因为前 4 个字节被 array cookie 占用了,所以 new Test[5] 返回给用户的 p1
不是 015BEDC0,而是 015BEDC4。
接下来 new[] 会在数组元素区域依次构造 5 个 Test 对象,所以打印:
Test
Test
Test
Test
Test
到这里为止,一切都是正常的。
但是释放时写的是:
delete p1;
这是单对象 delete,不是数组 delete。
单对象 delete 会把 p1 当成“单个 Test 对象的地址”。
所以它只会对 p1 指向的那个对象调用一次析构函数,也就是只析构 Test[0]:
p1->~Test();
因此你只看到一次:
~Test()
而不是 5 次。
如果写的是正确的:
delete[] p1;
那么 delete[] 会先从 p1 前面的 cookie 读出元素个数 5,
然后通常从后往前调用 5 次析构函数:
p1[4].~Test();
p1[3].~Test();
p1[2].~Test();
p1[1].~Test();
p1[0].~Test();
最后再调用 operator delete[],并且传入真正的数组内存起始地址 015BEDC0。
但是现在写的是 delete p1。
它只析构一个对象后,会调用单对象版本的 operator delete。
你打印到:
my global operator delete
operator delete addr: 015BEDC4
这说明单对象 delete 把 p1 的值,也就是 015BEDC4,直接传给了 operator delete。
可是这块内存真正是由 operator new[] 申请的,真实起始地址是 015BEDC0。
也就是说,malloc 返回的是 015BEDC0,而你最终 free 的却是 015BEDC4。
这就出问题了。
free 必须释放 malloc 返回的原始地址。
现在传给 free 的地址偏移了 4 字节,不是堆块的起始地址,
所以运行库会检测到非法释放,导致程序崩溃。
所以这段代码的崩溃原因主要有两个:
1. 析构次数错误
new Test[5] 构造了 5 个对象,
delete p1 只析构了第 1 个对象 Test[0],
Test[1] ~ Test[4] 没有被正确析构。
2. 释放地址错误
operator new[] 真实返回地址是 015BEDC0,
但 p1 指向的是 015BEDC4,
delete p1 把 015BEDC4 传给 operator delete,
最后 free(015BEDC4),释放了一个不是 malloc 返回的地址,
因此堆管理器报错崩溃。
*/
int main()
{
Test* p1 = new Test[5];
delete p1;
return 0;
}
/*
上面这段代码会崩溃,但是还是有打印结果的:
my global operator new[], size = 24
operator new[] addr:015BEDC0
Test
Test
Test
Test
Test
~Test()
my global operator delete
operator delete addr: 015BEDC4
*/
2.总结
对于 new 和 delete 最好不要混用,对于上面的问题分析,使用 new 和 delete 混用来理解在用 new 开辟对象时内存是怎样分布的,加深我们对 new 和 delete 的理解。



