思考由来:关于c++内delete[]原理的探索

1.地址、字节、位

  • 位(bit)是电子计算机中最小的数据单位。每一位的状态只能是0或1。

  • 字节(Byte)是用于计量存储容量的一种单位,每一个字节由8位组成(1Byte = 8bit)。

  • 地址可以理解为在一片内存中,每个字节(Byte)的编号。

他们在内存中的关系可以比作,内存是一栋大楼,字节(Byte)是大楼中的每一层,地址是楼层编号,位(bit)是每一层中的房间,每一层有8个房间。

2.变量的内存

编译器根据变量的类型,在内存中申请一块空间。例如32位与64位中 int 类型申请到4字节的空间,可理解为编译器申请了4层楼,作为”办公区域“。

3.指针变量

指针是指程序数据在内存中的地址。在C++语言当中,允许用一个变量来存放指针,这种变量称为指针变量。

1    int a;
2   int *p;
3   p = &a;
4   printf("%p %d\n",p,*p);

​ 以上程序中,”&“操作符取出了变量 a 在内存空间中的首地址,而后通过 “ * ” 操作符取出首地址所在内存空间的数据。

​ 前面我们提到,存储变量的内存,是由多个字节组成。而指针变量在只知道首地址(第一个字节的地址)的情况下,就能找到a的内存区域。它是怎么做到的?先来看看指针变量的声明。

​ 我们在声明一个指针变量的时候,会根据它将要指向的变量类型,声明对应的类型,例如:

1     int a;
2 long b;
3 char c;
4
5 int *pa = &a;
6 long *pb = &b;
7 char *pc = &c;

不管是什么类型的指针变量,所存的值都是地址(int类型的值)。

那么声明不同类型的作用是什么?答案是规定指针在内存中每次移动的字节数

例如定义int *pa = &a,取值时,int类型占4个字节,指针就从首地址开始移动,读取4个字节。同理,short类型占2字节,指针就移动2字节。通过声明指针类型,告诉指针每次移动多少字节,来获取变量的值。

4.值相同的两个指针所指向的变量的值可以不同

“值相同的两个指针变量”,意思是两个指针变量指向同一个首地址。但是如果指针变量的类型不同,因为指针移动的字节数量不同,就可能读取出不同的数据。

要实现不同类型指针变量指向同一个地址,需要使用指针类型转换。

#include <iostream>
using namespace std;

int main() {
short a = 1;
short *p1 = &a;
int *p2 = (int *)p1;
cout << "p1=" << *p1 << endl; //输出p1=1
cout << "p2=" << *p2 << endl; //输出p2 = -32636927
system("pause");
return 0;
}

以上例子将一个每次移动读取2字节的 short 类型指针变量,转化为一个每次读取4字节的int型指针变量。因此很显然,p1能读取到正确的结果,而p2会多读取两个字节的数据,最终算出来的数据错误。

接下来,我们通过指针类型转换,用同一个首地址,取出不同的值。

1#include <iostream>
using namespace std;

2 int main()
3 {
4 short c[2]; //等价于申请2个连续的内存空间,每个空间2字节
5 c[0] = 1;   //为第一个short空间赋值为1
6 c[1] = 1;   //为第二个short空间赋值为1
7 short *p1 = c; //p1指向c[]首地址
8 int *p2 = (int *)p1; //p2指向c[]首地址,并强制转换类型为 int
9
10 printf("p1指向:%p\np2指向:%p\n",p1,p2);
11 printf("p1取出:%d\np2取出:%d\n",*p1,*p2);
12 return 0;

对应结果为:

p1指向:000000000062FE30
    p2指向:000000000062FE30
    p1取出:1
    p2取出:65537

根据二进制转换得,10000000000000001 为 65537。由此可验证强制转换前指针读取2字节,转化后读取4字节。两个指针指向的首地址相同,但是读出了不同的结果。

5. 关于c++内delete[]原理的探索

本文转载自重庆大学于卓浩同学写的文章:(https://puluter.cn/20200331/delete/)

在面向对象编程中,我们经常会用到这样的动态分配数组:

Person* a = new Person[100];

在上述申请数组的过程中,我们使用到了new []这个表达式来完成,它会调用类的构造函数初始化这个数组的所有对象,有多少对象,就会执行多少次构造函数。如果我们用完了这个数组,想要释放空间,就需要调用:

delete[] a;

在这个过程中,我们使用了delete[]操作符来完成对象释放。

但是两个问题出现了:

  • 如何知道a数组的内存空间大小?
  • 如何知道要调用几次析构函数(a数组的元素个数)?

显然,想要知道数组有多长,我们必然要存下这个数组的长度。C++中也正是这么做的。本文讨论具体工作原理。

5.1. 结合代码的分析

注:本文适用于64位mingw。在32位mingw下后文内8字节应为4字节,long long应为int。

我们先定义一个自定义的类。

class Yu {
public:
int iNumber;//Yu类内将只有这一个int变量
//即 一个Yu对象的大小=一个int的大小=4字节
Yu(){iNumber = 1;}
~Yu(){}
...
};

同时,在main中声明一个长度为len(20194134)的Yu类型的数组,再获取该数组开头的地址并打印。

const long long len = 20194134;
int main(){
...
Yu* testArr = new Yu[len];
char* p1 = (char*) testArr;
cout << "Address for the Array: "<< testArr <<endl;
...
}

输出为:Address for the Array: 0x2670048

理论上,20194134个Yu对象,总共需要 20194134 x sizeof(Yu) = 80776536字节的空间。实际申请的堆(自由内存区)空间稍有出入,我们可以通过重载new [ ]操作符来研究。

我们重载new[]了操作符:

void* operator new[](size_t sz){
printf("|Length: %lld\n|Real Size: %lld\n|Raw Size(int(4)*length): %lld\n|Gap: %lld\n",len,sz,len*4,(long long)sz-len*4);
void* o = malloc(sz);
return o;
}

在这个过程中,我们打印四个关键数值:

  • 数组的长度(len)
  • new[]过程中实际申请的内存空间大小(sz)
  • 数组理论上需要的内存空间 (数组长度 x sizeof(Yu) = len x 4 )
  • 实际空间与理论空间的差 ( sz - len x 4)

上述程序的执行结果为:

|Length: 20194134
|Real Size: 80776544
|Raw Size(int(4)*length): 80776536
|Gap: 8

这里会发现,编译器传递给new [ ]操作符的空间大小比实际需要多8个字节。而8个字节,恰好是一个long long变量的大小,实践中,这8个字节用于存储动态数组的元素个数。

为了弄清楚这8个字节的具体位置,我们重载了delete [ ]函数:

void operator delete[](void *o){
cout<<"Destruct from: "<<o<<endl;
free(o);
}
int main(){
...
char* p1 = (char*) testArr;
cout << "Address for the Array: "<< testArr <<endl;
...
delete[] testArr;
return 0;
}

最后几行的输出为:

Address for the Array: 0x2670048
...
Destruct from: 0x2670040

成了!我们发现,解构时得到的地址(0x2670040)恰好是数组的地址(0x2670048)减8。

即:0x2670048 = 0x2670040 + 8
接下来,我们更进一步,取出数组地址-8对应地址的一个long long变量,看一下它的值会是什么.

接下来获取该数组前的8字节,识别为long long并打印。

int main(){
...
char* p1 = (char*) testArr;
cout << "Address for the Array: "<< testArr <<endl;
cout<< "The long long before the Array: " << *(long long*)(p1-len_ll)<<endl;
delete[] testArr;
...
}

输出为:

Address for the Array: 0x2670048
The long long before the Array: 20194134 【这个值就是数组长度】
Destuct from: 0x2670040

代码给出的结果证明了前述的猜想:自定义类数组前的8个字节,是一个long long类型的变量,储存了该数组的长度。

5.2. 结论

v2-01cc4ad855bce2ae1f9126eb68a8e4a1_720w_result

我们以Yu\* a = new Yu\[2\]为例进行说明。表面上,我们需要sizeof(Yu) x 2共8个字节的空间,但事实上,new [ ]操作符会从堆里申请8 + 8 = 16个字节的空间。其中,前8个字节用于存储数组的元素个数,后续空间用于存放数组元素。具体到本例,变量a得到的是数组首元素的地址,它事实上等于真实的堆空间地址 + 8!

当delete [ ]a被执行时:

  • delete []操作符会把a值 - 8,获得真实的堆空间首地址;
  • 从堆空间首地址获得数组的元素个数(本例为2);
  • 依据元素个数及a值逐个执行全部数组元素的析构函数;
  • 最后,以堆空间首地址为依据,通过free( )函数向操作系统归还堆空间。

本例中,如果执行delete a而不是delete []a,可能导致两个后果:

  • 仅有数组的首元素被正确析构;
  • 释放堆空间时向操作系统提供的地址是不正确的,后果未知。

正是基于上述理由,书里反复强调,new/delete, new [] /delete []要配对使用。

5.3. 完整实验代码

#include <iostream>
#include <string>
using namespace std;

const long long len = 20194134;

class Yu {
public:
int iNumber;
Yu(){iNumber = 1;}
~Yu(){}
//重载 new[]操作符
void* operator new[](size_t sz){
/*打印四个关键数据:
①数组的长度
②new[]过程中申请的内存空间大小
③数组实际需要的内存空间
④ ②、③的差值
*/
printf("|Length: %lld\n|Real Size: %lld\n|Raw Size(int(4)*length): %lld\n|Gap: %lld\n",len,sz,len*4,(long long)sz-len*4);
//完成内存分配
void* o = malloc(sz);
return o;
}
//重载delete[]操作符
void operator delete[](void *o){
//打印析构的开端地址
cout<<"Destruct from: "<<o<<endl;
//完成内存释放
free(o);
}
};

int main(){
// 打印long long的大小
int len_ll = sizeof(long long);
cout<<"Size of long long: "<<len_ll<<endl;
//生成一个长度为len(20194134)的Yu类型的数组
Yu* testArr = new Yu[len];
//获取该数组开头的地址并打印
char* p1 = (char*) testArr;
cout << "Address for the Array: "<< testArr <<endl;
//获取该数组前的8字节,识别为long long 并打印
cout<< "The long long before the Array: " << *(long long*)(p1-len_ll)<<endl;
//释放数组空间
delete[] testArr;
return 0;
}

完整输出:

Size of long long: 8
|Length: 20194134
|Real Size: 80776544
|Raw Size(int(4)*length): 80776536
|Gap: 8
Address for the Array: 0x2670048
The long long before the Array: 20194134
Destuct from: 0x2670040