【C++】内存管理与new、delete详解
前言C相比于java、python来说并没有自动垃圾回收机制GC需要我们手动管理内存C的内存管理和我们C语言那里保存一致即可甚至有了new和detele后管理内存比C语言还要方便而且更好支持自定义类型关于C语言的内存管理在我之前的文章【C语言】动态内存管理详细解析1.C/C的内存分布在经典的32位/64位操作系统中C/C程序的内存通常被划分为五个主要区域。为了方便理解我们通常按照内存地址从低到高或从高到低来排列。以下是标准的内存布局分布图1.栈区栈区一般用来存放函数的局部变量、函数参数、返回值以及函数调用的上下文比如返回地址这个区域并不用我们主动的管理内存只要是编译器替我们完成当我们进入一个函数时变量被压入栈中当函数返回时这些变量被弹出销毁。栈区有以下的几个特点地址增长方向从高地址向低地址生长向下生长与堆区正好相对生命周期随着作用域如函数、循环块的结束而自动销毁大小限制 空间非常有限通常默认几MB比如Linux默认8MBWindows默认1MB。如果声明了过大的局部变量或递归过深就会导致栈溢出相比之下堆区一般会比栈区大很多在性能上因为仅仅是移动栈顶指针寄存器所以分配速度比较快2.堆区堆区通常存放我们通过代码动态分配的内存比如new、malloc、calloc申请的内存空间在C语言中我们用malloc, calloc, realloc 分配使用 free 释放而在C中使用 new 分配使用 delete 释放。关于堆区有下面几个特点:地址增长方向从低地址向高地址生长向上生长)生命周期完全由我们控制。如果分配了不释放就会造成内存泄漏空间很大几乎受限于操作系统的虚拟内存大小尤其是64位下达到了恐怖的数字2^64性能相比于栈区来说因为分配和释放需要调用操作系统API容易产生内存碎片速度相对较慢3. 全局/静态存储区这个区域用来存放全局变量和静态变量它在程序的整个生命周期内都存在。它在底层又被细分为两块已初始化数据段 (.data)存放已经显式初始化且非零的全局变量和静态变量未初始化数据段 (.bss) 存放未初始化或初始化为零的全局变量和静态变量操作系统在加载程序时会自动将 .bss 段的内存全部清零因此未初始化的全局/静态变量默认值都是 04 只读数据区存放程序中不可修改的常量数据。比如字符串字面量如 “Hello World”和被 const 修饰且在编译期就能确定的全局变量。同样是严格只读的。如果用指针强行指向这里并试图修改程序会直接崩溃。5 代码段存放程序编译后的机器指令也就是我们的代码编译出来的二进制文件和函数体。这个区域有以下一个特点只读 操作系统为了防止程序在运行中意外修改自己的指令将这块内存设置为只读。如果尝试修改会触发异常共享 如果同一个程序运行了多个实例比如开了两个同样的软件它们在物理内存中会共享同一份代码区下面我们来看一个道经典的例题先来来看下面的代码intglobalVar1;staticintstaticGlobalVar1;voidTest(){staticintstaticVar1;intlocalVar1;intnum1[10]{1,2,3,4};charchar2[]abcd;constchar*pChar3abcd;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);}提问选项: A.栈 B.堆 C.数据段(静态区) D.代码段(常量区)globalVar在哪里____staticGlobalVar在哪里____staticVar在哪里____localVar在哪里____num1 在哪里____char2在哪里____*char2在哪里___pChar3在哪里____*pChar3在哪里____ptr1在哪里____*ptr1在哪里____答案解析C全局变量生命周期贯穿整个程序存放在全局/静态存储区C静态全局变量虽然它的作用域被限制在当前源文件内但它的物理存储位置和全局变量一样都在数据段C静态局部变量。虽然它写在函数内部但有 static 修饰这意味着它只会被初始化一次且函数结束后不会被销毁。它同样被挪到了数据段A普通局部变量没有 static 修饰进入函数时自动在栈上分配函数退出时自动销毁Anum1 是一个局部数组的名字。在 C/C 中局部数组的所有元素包括 1, 2, 3, 4 以及后面自动补的 0都是在栈上连续开辟的空间Achar2 是一个局部数组名。char char2[] “abcd”; 的底层原理是编译器在常量区存放了 “abcd”当程序运行到这一行时在栈上开辟了 5 个字节的空间并把常量区的 “abcd\0” 拷贝到了栈上的这块空间里。所以 char2 代表的是栈上的这块空间A*char2 代表数组的第一个元素即字符 ‘a’。既然整个数组都在栈上它的元素自然也都在栈上ApChar3 是一个指针变量。只要是函数内部定义的普通局部变量不管它是什么类型指针、整型、结构体变量本身一定在栈上D解引用 *pChar3 代表它所指向的内容。pChar3 指向的是 “abcd” 这个字符串字面量。字符串字面量是不可修改的存放在只读数据区/常量区A与第 8 题同理B解引用 *ptr1 代表它指向的动态内存空间。因为这块空间是通过 malloc 申请出来的malloc/calloc/realloc/new 申请的内存都在堆区2.C的内存管理方式C里依旧可以用C语言的malloc和free那套但是C提供了new和detele这两个关键字来管理内存虽然底层依旧是C语言那套但是在套了一层马甲后功能更加的强大下面来介绍这两个关键字的用法2.1操作内置类型对于 int, double 等基础类型可以直接分配并选择性地初始化intmain(){// 1. 仅分配内存不初始化是一个随机值int*p1newint;// 2. 分配内存并初始化为 0int*p2newint();// 3. 分配内存并初始化为指定值 (例如 10)int*p3newint(10);deletep1;deletep2;deletep3;return0;}2.2操作自定义类型如果用C语言的malloc申请空间的话对于自定义类型是不会对申请的类进行初始化的但是new会自动调用类的构造函数再这个类实例化时自动的初始化这个对象。同理用delete会自动的调用这个类的析构函数然后才是释放这个对象的空间classA{public:A(inta0):_a(a){std::coutA()std::endl;}A(constAaa){std::coutA(const A aa)std::endl;}~A(){std::cout~A()std::endl;}private:int_a1;int_b1;};intmain(){//会自动的调用构造函数进行初始化A*p1newA(1);//先调用析构函数然后再用free释放底层deletep1;return0;}2.3 数组用法无论对于内置类型还是自定义类型我们都可以使用new来创建数组intmain(){//申请大小为3个整形大小的整形数组int*arr1newint[3];//申请大小为3个整形大小的整形数组,并都初始化为0int*arr2newint[3]();//申请大小为10个整形大小的整形数组,并将前三个元素初始化为1、2、3int*arr3newint[10]{1,2,3};return0;}调试观察对于内置类型也是一样的intmain(){A*p1newA[10];//注意这里要使用delete[]释放否则会产生未定义行为delete[]p1;return0;}3.new和delete的底层原理简单介绍3.1 operator new与operator delete函数new和delete是我们进行动态内存申请和释放的操作符而operator new与operator delete函数是系统提供的全局函数new在底层调用的正是operator new全局函数来申请空间而delete正是调用的operator delete函数来释放空间我们可以来看看operator new和operator delete的底层代码//operator new 实现void*__CRTDECLoperatornew(size_t size)_THROW1(_STD bad_alloc){void*p;while((pmalloc(size))0){if(_callnewh(size)0){staticconststd::bad_alloc nomem;_RAISE(nomem);}}return(p);}//operator delete 实现voidoperatordelete(void*pUserData){_CrtMemBlockHeader*pHead;RTCCALLBACK(_RTC_Free_hook,(pUserData,0));if(pUserDataNULL)return;_mlock(_HEAP_LOCK);__TRY pHeadpHdr(pUserData);_ASSERTE(_BLOCK_TYPE_IS_VALID(pHead-nBlockUse));_free_dbg(pUserData,pHead-nBlockUse);__FINALLY_munlock(_HEAP_LOCK);__END_TRY_FINALLYreturn;}//. 底层 free 的宏定义#definefree(p)free_dbg(p,_NORMAL_BLOCK)可以看到实际上new、delete其实就是malloc、free套了层马甲相当于是这两个的升级版。底层其实还是malloc和free我们可以看看这个关系图【 new 操作符 (关键字) 】 —— 程序员在代码里写下的字眼 │ ├── 步骤 1调用 【 operator new 函数 】 (获取物理内存) │ │ │ └── 底层循环调用 【 malloc() 】 (向操作系统 C 库要内存) │ └── 步骤 2调用 【 类的构造函数 】 (在要来的内存上建房子) 【 delete 操作符 (关键字) 】 │ ├── 步骤 1调用 【 类的析构函数 】 (拆除房子清理内部资源) │ └── 步骤 2调用 【 operator delete 函数 】 (归还物理内存) │ └── 底层调用 【 free() 】 (把内存还给 C 库和操作系统)可以看到虽然底层还是malloc、free但是C的new和delete明显功能更加强大而且更好的支持了内置类型在C语言阶段当我们申请内存失败了我们会判断时候返回空指针但是在C中如果资源申请失败了并不会返回一个空指针而是会抛异常关于抛异常因为涉及到继承、多态的知识所以我想放到后面再讲解反正我们平常写一些小练习小程序的基本是不会失败的但是到正经项目中肯定是要处理这种情况的。美国有个火箭就是一个未被处理的异常导致陨落了感兴趣可以看看这个视频为什么战斗机禁用 90% 的 C 功能3.2 定位new表达式定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象这个先了解一下即可说实话我感觉挺偏的intmain(){A*p1newA(1);//申请空间A*p2(A*)operatornew(sizeof(A));//定位new,调用构造函数new(p2)A(2);//析构p2-~A();operatordelete(p2);deletep1;return0;}上面的代码p1、p2在实例化和析构上是一样的是不是有一种脱裤子放屁的感觉但是存在即合理听过定位new在内存池上会有应用这里我就不展开了因为我也不懂3.3 new和delete总结1.new的原理调用 operator new 函数向操作系统申请刚好容纳该对象的内存空间在这块刚申请到的内存空间上执行该类的构造函数完成对象内部数据的初始化2.delete的原理准备释放的空间上先执行该类的析构函数清理对象内部占用的其他资源比如释放内部指针、关闭文件等调用 operator delete 函数把对象本身占用的这块内存还给操作系统3.new T[N]的原理调用 operator new[] 函数实际上是调用 operator new一口气申请足以容纳 N 个对象的总内存在这块连续的内存上循环执行 N 次构造函数4.delete[] 的原理循环执行 N 次析构函数依次完成这 N 个对象内部资源的清理调用 operator delete[] 函数实际是调用 operator delete将整块连续空间一次性释放3.4new[]与delete错误搭配问题图一乐我们先来看下面的代码,有两个类classA//有构造函数{public:A(){std::coutA()std::endl;}A(constAaa){std::coutA(const A aa)std::endl;}~A(){std::cout~A()std::endl;}private:int_a1;int_b1;};classB//无构造函数{private:int_a1;int_b1;};可以运行报错的程序intmain(){A*p1newA[3];deletep1;return0;}可以正常运行的程序intmain(){B*p2newB[3];deletep2;return0;}那么问题来了同样是错误的搭配为什么A类报错了但是B类却正常运行呢当我错误搭配使用时会产生未定义的行为因为类 A 提供了自定义析构函数而类 B 没有。这导致编译器在底层为它们分配数组内存时采用了不同的内存布局mesp;mesp;类A有一个自定义的析构函数~A()。当编译器看到new A[3]时它知道在释放这块内存时必须调用 3 次析构函数,为了在 delete[] p1 时知道到底要调用多少次析构函数编译器会在实际分配的内存块头部偷偷多分配一点空间通常是 4 或 8 个字节用来记录数组的元素个数。这个隐藏的记录通常被称为Cookie当我们调用delete p1时编译器以为 p1 指向的是一个单个对象于是它只对第一个元素调用了一次析构函数~A()接着它试图把 p1 指向的地址直接交给底层的内存释放函数如 C 语言中的 free()去释放底层释放函数要求传入的地址必须是当初分配时的原始起始地址。但由于 Cookie 的存在p1 实际上比原始地址偏移了几个字节。这时候把一个错误的、偏移过的指针交给了内存管理器直接导致堆损坏那为什么B可以成功运行呢类 B 没有自定义析构函数且它的成员变量 _a 和 _b 都是基本类型编译器认为 B 是一个平凡析构类型当编译器看到 new B[3] 时它知道释放这块内存时不需要执行任何额外的析构代码。既然不需要调用析构函数编译器为了优化内存和性能干脆就不生成那个记录数组大小的 Cookie了当我们调用delete p2时编译器以为 p2 是单个对象不调用析构函数它把 p2 直接交给底层内存释放函数因为没有 Cookiep2 指向的地址恰好就是底层内存分配的原始起始地址。内存管理器一看地址是对的就顺利把这一整块包含 3 个 B 对象的空间物理内存给释放了。因此没有报错但我们正常正确的搭配使用就可以了何必自找麻烦4.malloc/free和new/delete的区别总结我们可以把上面的几点总结成一个表格维度malloc / free (C 风格)new / delete (C 风格)1. 语法属性是标准库函数是C 运算符 / 关键字2. 尺寸与类型必须手动计算并传递字节大小如sizeof(int)返回void*使用时必须强转类型。后面直接跟类型/对象个数自动计算大小如new int[10]返回具体类型指针无需强转。3. 初始化申请的空间不会初始化里面全是垃圾随机值。可以通过括号/大括号进行合法的初始化。4. 失败处理机制申请失败时返回NULL因此代码里必须判空。申请失败时抛出bad_alloc异常无需判空但需要捕获异常。5. 对象生命周期只会开辟空间绝不会调用自定义类型的构造函数与析构函数。申请空间后自动调用构造函数释放空间前自动调用析构函数。完