这一讲,我将为大家介绍C++中的内存管理相关知识,除了一些理论知识以外,还会为大家介绍new/delete的具体用法和底层。
首先我们说C/C++不分家,在内存管理中也能很好的体现这一点,C++和C内存区域的划分是完全一致的。所以我先简单带大家复习一下C语言的内存区域划分。
C/C++大概是这么划分的,内核空间用户不可操控,不能进行读写操作;栈区有我们常说的函数栈帧,也就是存放局部变量的位置,内存映射段主要是文件映射、动态库、匿名映射等;堆区主要是存放动态开辟变量,数据段主要存放全局变量和静态变量;代码段主要就是存放可执行代码以及常量。
那么大家来看一下这几道题,简单回顾一下。
int globalVar = 1; static int staticGlobalVar = 1; void Test() { static int staticVar = 1; int localVar = 1; int num1[10] = { 1, 2, 3, 4 }; char char2[] = "abcd"; const char* pChar3 = "abcd"; int* ptr1 = (int*)malloc(sizeof(int) * 4); int* ptr2 = (int*)calloc(4, sizeof(int)); int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4); free(ptr1); free(ptr3); }从上往下逐个问,globalVar存放在哪里?全局变量存放在数据段。
staticGlobalVar存放在哪里?静态变量存放在数据段。
同理staticVar即使是局部静态变量,但是除了作用域依然不销毁,所以也是存放在数据段。
localVar一个普通的局部变量存放在哪里?存放在栈区,也就是函数开辟栈帧申请的空间。
那num1呢?num1其实也没什么神奇的,数组又怎么?不还是局部变量吗,那当然也存在于栈区了。
char2在哪里?字符数组和整型数组有区别吗?没有区别,char2存放在栈区没啥问题。
那么*char2呢?你可能觉得,*char2得到常量字符串所以存放在代码段也就是常量区,事实上这么说是不合适的,因为char2是数组名,也就是首元素地址,也可以理解成代表这个数组,换句话讲,常量字符串当然是存放在代码段里,但是你利用char2对其进行了一个约束,操作系统会从代码段把常量字符串拷贝到栈区受你数组名的约束,一个显而易见的证据就是,你可以对char2里的元素进行修改。
pChar3呢?他也是局部变量,所以也存在于栈区。
*pChar3呢?这个就不同以往了,我们说const放在*左边,意味着不能修改解引用后的内容,也就是说我们把这个字符串给写死了,你修改不了,所以你可以理解成它存放在常量区或者说是代码段,但是,我上述说法给大家埋了个小坑,假如这里没有const,难道*pChar3就不放在代码段里了吗?我们知道,字符串是不能修改的,不论你加不加const,所以即使去掉const,*pChar3也是存放在代码段。
然后是ptr1,显然局部变量,存放在栈区。
*ptr1,显然是动态开辟的,所以存放在堆区。
所以,内存分区的本质是什么?是分生命周期,你要根据自己的需求将不同的变量放在不同的作用域中。
在介绍完上述后,我们要进入今天的重点了也就是C++的内存管理篇章。
都知道C语言动态申请内存用的是malloc/calloc/realloc释放用的是free,而C++用的是new/delete。
在引入new/delete之前,我们不妨从一个操作者的角度出发,思考一下C语言这套模式有什么弊端,为什么非要搞一个new/delete出来。
对于malloc来说,我们没法初始化,虽然你用calloc可以,但是也挺麻烦的。
malloc还有可能会申请内存失败的情况,返回空指针,我们需要手动判断并进行处理,比如perror打印错误信息,exit退出程序等等。
对于自定义类型来说,简单拿链表举个例子,我们想要新建节点,是不是要单独写一个BuyNode函数,不然你直接malloc,你连表里的值都没初始化,也就是说你要手动初始化,手动赋值。
对应free一个自定义类型,你也得手动free每个节点里的成员,假如有一些动态申请的空间你忘了free了,只是单纯的free了链表的node,那你会不会造成内存泄露呢?
这都是问题,上述这些,我很负责任的说new/delete基本都解决了。
简单来讲,现在new的同时可以初始化,对于自定义类型可以自动调用它的构造函数,delete可以自动调用自定义类型的析构函数。
我们接下来讲一讲语法。
#define _CRT_SECURE_NO_WARNINGS 1 #include <iostream> using namespace std; int main() { int* pa = new int; cout << *pa << endl; int* pb = new int(10); cout << *pb << endl; int* parr1 = new int[5]; int* parr2 = new int[10] {0}; for (int i = 0; i < 10; i++) { cout << parr2[i] << " "; } cout << endl; int* parr3 = new int[10] {1, 2, 3, 4, 5}; for (int i = 0; i < 10; i++) { cout << parr3[i] << " "; } cout << endl; delete pa; delete pb; delete[] parr1; delete[] parr2; delete[] parr3; return 0; }当用new/delete操作内置类型时,对于单个对象,直接new int delete 变量名即可,我们可以看到当new int(10)时,会对开辟的空间对应的变量执行初始化操作,这是malloc所不能实现的,也很好解决了上述所说的弊端。
当开辟连续的空间充当数组时,使用new int[10] delete[] 变量名的方式,第一可以指定开辟多少个空间,也不用我们手动去指定字节数了,第二也可以完成初始化操作,不论时是不完全初始化还是完全初始化都可以,new int[10]{1,2,3,4,5};这就代表对这十个空间进行不完全初始化,只给她指定五个值,这是相当方便的。
接下来讲一下自定义类型的new/delete
class A { public: A(int a = 10) :_a(a) { cout << "A(int a = 10)" << endl; } ~A() { cout << "~A()" << endl; } void Print() { cout << _a << endl; } private: int _a; }; int main() { A* pa = new A; (*pa).Print(); A* pb = new A(2); (*pb).Print(); delete pa; delete pb; return 0; }与内置类型的new/delete至少在用法上没什么区别,但值得注意的一点是,当你想初始化时,对应的必须有参数个数匹配的构造函数。开辟数组的写法和内置类型无异,但不同的是new/delete分别会调用自定义类型的构造和析构函数。
我们之前也提到过,自定义类型自动调用构造和析构其实是非常方便的,调用构造完成初始化就不多说了,调用析构自动对类型中成员变量申请的空间进行释放就不用我们自己去考虑这件事情了,也就自然而然避免了内存泄漏的风险,除非你自己忘了写析构函数(前提是成员变量中有动态申请空间的部分)。
接下来我讲一下new/delete的底层。
看一下这段汇编代码
可以看到,当我们调用new/delete操作符的时候,底层会去调用operatornew/operatordelete函数,而这两个函数有分别调用了malloc/freedbg,freedbg就是free的调试版本。
所以我们说operatornew/operatordelete是一个增强版的malloc/free是没有问题的
这就是加强的点,我们这里通过反汇编也证明了new/delete会调用构造/析构函数。
除了这个以外,当我们new空间失败的时候不需要手动判断了,会直接抛出异常这也是优化的一点。
最后,我给大家讲一下new/delete的不匹配问题
假如new一个对象,我用free给他释放掉,可以吗?理论上来说是没问题的,因为前边提到过delete的底层就是free。
但是,如果我们new一个数组却使用普通的delete会不会出问题呢?或者new一个对象使用delete[],是否会出问题呢?
先看后者,在C++中编译器对new和new[]的理解是截然不同的,相应的,对于delete/delete[]的理解也有异,假如new一个对象delete[]释放,就会因为delete想要在内存中找一个动态开辟的数组而无休止的向后释放,调用许多次析构函数直到程序崩溃。
简单来讲,delete[]正是为了拿到动态开辟空间的大小。
那么前者呢?
仔细看这个size,我先前对A类型进行了修改,里边有两个成员变量,这里我执行的操作是new A [10];按理来说,开辟80个字节就可以了,为啥要多开4个?这就是我上边说的new/new[]的不同,new[]会专门开辟一块空间存放动态申请的空间大小,以便delete[]拿到这个数字。
那我们简单看一下不匹配的执行结果。
这是抛出了异常,为什么会抛出这个异常?
int* parr = (int*)malloc(sizeof(int) * 10); free(parr + 1);看看这段代码的执行结果。
这是不是相同的异常?也就是说,当我们按照上述形式写的时候,系统会认为我们从动态申请的空间中间执行释放操作,这是不合理的,为什么呢?这正是delete/delete[]的区别,delete不会认为自己释放的对象是一块连续的空间,也自然无法get到new[]多开辟了一块空间用来存放申请空间的大小,所以,delete会从内存中第二个空间开始当成起始地址释放,自然会抛出这个异常。
这里需要补充一点,为啥会有这个异常是因为delete要调用自定义类型的析构函数,如果是内置类型,则不会去开辟一块新的空间存储大小,自然也不会报错,但是会内存泄漏。
int* p3 = new int[10]; delete p3;关于上述问题,最好的解决方案就是你不要瞎用,就老老实实匹配使用肯定是不会出问题的。
第三讲的内容到此结束。