前言:C++相比于java、python来说并没有自动垃圾回收机制(GC),需要我们手动管理内存,C++的内存管理和我们C语言那里保存一致即可,甚至有了new和detele后管理内存比C语言还要方便而且更好支持自定义类型,关于C语言的内存管理在我之前的文章:【C语言】动态内存管理详细解析
1.C/C++的内存分布
在经典的32位/64位操作系统中,C/C++程序的内存通常被划分为五个主要区域。为了方便理解,我们通常按照内存地址从低到高或从高到低来排列。以下是标准的内存布局分布图:
1.栈区
栈区一般用来存放函数的局部变量、函数参数、返回值以及函数调用的上下文(比如返回地址),这个区域并不用我们主动的管理内存,只要是编译器替我们完成,当我们进入一个函数时,变量被压入栈中;当函数返回时,这些变量被弹出销毁。栈区有以下的几个特点:
- 地址增长方向从高地址向低地址生长(向下生长)与堆区正好相对
- 生命周期随着作用域(如函数、循环块)的结束而自动销毁
- 大小限制: 空间非常有限(通常默认几MB,比如Linux默认8MB,Windows默认1MB)。如果声明了过大的局部变量或递归过深,就会导致栈溢出,相比之下堆区一般会比栈区大很多
- 在性能上因为仅仅是移动栈顶指针(寄存器),所以分配速度比较快
2.堆区
堆区通常存放我们通过代码动态分配的内存(比如new、malloc、calloc)申请的内存空间,在C语言中我们用malloc, calloc, realloc 分配,使用 free 释放,而在C++中使用 new 分配,使用 delete 释放。关于堆区有下面几个特点:
- 地址增长方向从低地址向高地址生长(向上生长)
- 生命周期完全由我们控制。如果分配了不释放,就会造成内存泄漏
- 空间很大,几乎受限于操作系统的虚拟内存大小,尤其是64位下达到了恐怖的数字(2^64)
- 性能相比于栈区来说因为分配和释放需要调用操作系统API,容易产生内存碎片,速度相对较慢
3. 全局/静态存储区
这个区域用来存放全局变量和静态变量,它在程序的整个生命周期内都存在。它在底层又被细分为两块:
- 已初始化数据段 (.data):存放已经显式初始化且非零的全局变量和静态变量
- 未初始化数据段 (.bss): 存放未初始化或初始化为零的全局变量和静态变量
- 操作系统在加载程序时,会自动将 .bss 段的内存全部清零,因此未初始化的全局/静态变量默认值都是 0
4 只读数据区
存放程序中不可修改的常量数据。比如字符串字面量(如 “Hello World”)和被 const 修饰且在编译期就能确定的全局变量。同样是严格只读的。如果用指针强行指向这里并试图修改,程序会直接崩溃。
5 代码段
存放程序编译后的机器指令(也就是我们的代码编译出来的二进制文件)和函数体。这个区域有以下一个特点:
- 只读: 操作系统为了防止程序在运行中意外修改自己的指令,将这块内存设置为只读。如果尝试修改会触发异常
- 共享 : 如果同一个程序运行了多个实例(比如开了两个同样的软件),它们在物理内存中会共享同一份代码区
下面我们来看一个道经典的例题,先来来看下面的代码:
intglobalVar=1;staticintstaticGlobalVar=1;voidTest(){staticintstaticVar=1;intlocalVar=1;intnum1[10]={1,2,3,4};charchar2[]="abcd";constchar*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);}提问:
选项: A.栈 B.堆 C.数据段(静态区) D.代码段(常量区)
globalVar在哪里?____
staticGlobalVar在哪里?____
staticVar在哪里?____
localVar在哪里?____
num1 在哪里?____
char2在哪里?____
*char2在哪里?___
pChar3在哪里?____
*pChar3在哪里?____
ptr1在哪里?____
*ptr1在哪里?____
答案解析
- C:全局变量,生命周期贯穿整个程序,存放在全局/静态存储区
- C:静态全局变量,虽然它的作用域被限制在当前源文件内,但它的物理存储位置和全局变量一样,都在数据段
- C:静态局部变量。虽然它写在函数内部,但有 static 修饰,这意味着它只会被初始化一次,且函数结束后不会被销毁。它同样被挪到了数据段
- A:普通局部变量,没有 static 修饰,进入函数时自动在栈上分配,函数退出时自动销毁
- A:num1 是一个局部数组的名字。在 C/C++ 中,局部数组的所有元素(包括 1, 2, 3, 4 以及后面自动补的 0)都是在栈上连续开辟的空间
- A:char2 是一个局部数组名。char char2[] = “abcd”; 的底层原理是:编译器在常量区存放了 “abcd”,当程序运行到这一行时,在栈上开辟了 5 个字节的空间,并把常量区的 “abcd\0” 拷贝到了栈上的这块空间里。所以 char2 代表的是栈上的这块空间
- A:*char2 代表数组的第一个元素,即字符 ‘a’。既然整个数组都在栈上,它的元素自然也都在栈上
- A:pChar3 是一个指针变量。只要是函数内部定义的普通局部变量,不管它是什么类型(指针、整型、结构体),变量本身一定在栈上
- 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*p1=newint;// 2. 分配内存,并初始化为 0int*p2=newint();// 3. 分配内存,并初始化为指定值 (例如 10)int*p3=newint(10);deletep1;deletep2;deletep3;return0;}2.2操作自定义类型
如果用C语言的malloc申请空间的话对于自定义类型是不会对申请的类进行初始化的,但是new会自动调用类的构造函数,再这个类实例化时自动的初始化这个对象。同理,用delete会自动的调用这个类的析构函数,然后才是释放这个对象的空间:
classA{public:A(inta=0):_a(a){std::cout<<"A()"<<std::endl;}A(constA&aa){std::cout<<"A(const A& aa)"<<std::endl;}~A(){std::cout<<"~A()"<<std::endl;}private:int_a=1;int_b=1;};intmain(){//会自动的调用构造函数进行初始化A*p1=newA(1);//先调用析构函数,然后再用free释放(底层)deletep1;return0;}2.3 数组用法
无论对于内置类型还是自定义类型,我们都可以使用new来创建数组:
intmain(){//申请大小为3个整形大小的整形数组int*arr1=newint[3];//申请大小为3个整形大小的整形数组,并都初始化为0int*arr2=newint[3]();//申请大小为10个整形大小的整形数组,并将前三个元素初始化为1、2、3int*arr3=newint[10]{1,2,3};return0;}调试观察:
对于内置类型也是一样的:
intmain(){A*p1=newA[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((p=malloc(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(pUserData==NULL)return;_mlock(_HEAP_LOCK);__TRY pHead=pHdr(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*p1=newA(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::cout<<"A()"<<std::endl;}A(constA&aa){std::cout<<"A(const A& aa)"<<std::endl;}~A(){std::cout<<"~A()"<<std::endl;}private:int_a=1;int_b=1;};classB//无构造函数{private:int_a=1;int_b=1;};可以运行报错的程序:
intmain(){A*p1=newA[3];deletep1;return0;}可以正常运行的程序:
intmain(){B*p2=newB[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 直接交给底层内存释放函数,因为没有 Cookie,p2 指向的地址恰好就是底层内存分配的原始起始地址。内存管理器一看,地址是对的,就顺利把这一整块(包含 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. 对象生命周期 | 只会开辟空间,绝不会调用自定义类型的构造函数与析构函数。 | 申请空间后自动调用构造函数; 释放空间前自动调用析构函数。 |
完