目录
- 前言
- 一、C/C++内存管理
-
- [1.1 C/C++内存分布](#1.1 C/C++内存分布)
- 二、C语言中动态内存管理方式:malloc/calloc/realloc/free
- 三、C++内存管理方式
-
- [3.1 new/delete操作内置类型](#3.1 new/delete操作内置类型)
- [3.2 new和delete操作自定义类型](#3.2 new和delete操作自定义类型)
-
- [3.2.1 关于异常的调试技巧](#3.2.1 关于异常的调试技巧)
- [四、operator new与operator delete函数](#四、operator new与operator delete函数)
- 五、new和delete的实现原理
-
- [5.1 内置类型](#5.1 内置类型)
- [5.2 自定义类型](#5.2 自定义类型)
- 六、定位new表达式(placement-new)
-
- [6.1 内存池相关概念](#6.1 内存池相关概念)
- 七、malloc/free和new/delete的区别
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、C/C++内存管理
1.1 C/C++内存分布
在我们日常的代码编写过程中,不同类型的数据往往会被存储在不同的内存区域。编写完成的代码经过编译后生成可执行程序,当程序运行时,操作系统会将其加载为一个进程。那么,究竟什么是进程?我们又该如何理解它在计算机系统中所处的位置呢?
从计算机系统的层次结构来看,硬件是底层基础,而在硬件之上运行的操作系统,则扮演着管理和调度者的角色。操作系统通过其内部的各个组件,例如各类硬件驱动程序,来控制诸如音频、视频、网络、存储等设备。我们编写软件的根本目的,正是通过控制这些硬件资源来实现所需的功能。例如,在一个直播场景中,软件需要调用系统接口来控制麦克风进行声音输入,调用显示器呈现画面,利用声卡和音响输出声音,同时还需要通过网络设备和网卡将数据发送给观众------这一切的背后,都离不开操作系统对底层硬件的统一管理。
在操作系统之上,运行着各种应用程序,比如集成开发环境、画图软件、音乐播放器等。这些程序一旦运行,便成为系统中一个个活跃的进程。我们可以将操作系统比作一个大型工厂,而进程就像是工厂里各司其职的工人。每个工人都被分配了特定的任务,并有权使用工厂提供的设备和资源。例如在Windows系统中,打开任务管理器就能看到当前所有正在运行的进程,它们有的负责处理视频,有的处理音频,还有的负责安全监控,彼此分工明确。
程序运行的本质,是对数据进行处理和运算,并在必要时调用硬件设备。例如直播软件,不仅要录制屏幕和声音,还需要对音视频流进行编码、加密,再通过网络发送。在这个过程中,内存作为关键硬件之一,承担着数据的存储任务。而数据结构的作用,正是在内存中有效地组织和管理这些数据。
每个进程在运行时,操作系统都会为其分配一个虚拟的进程地址空间,这个空间通常被划分为几个关键区域:栈、堆、静态区和常量区。从编程语言的角度来看,栈主要用于存放函数调用时的参数、返回地址和局部变量,这些变量也被称为自动存储变量,它们的生命周期随着函数的调用和返回而被自动创建和销毁。堆则是一块用于动态分配内存的区域,程序员可以根据程序运行时的实际需求,在堆上申请任意大小的内存空间,比如数据结构中的链表、树等通常就是在堆上分配节点。如果没有显式释放,这些内存会一直占用,因此需要仔细管理。
静态区(在操作系统术语中也称为数据段)存放全局变量和静态变量,这些数据在程序启动时就被初始化,直到程序结束时才被销毁。常量区(或称代码段)不仅存放程序中定义的常量数据,还存放编译后的机器指令。进程运行的本质,就是不断从这个区域取出指令,交由CPU执行。
需要特别指出的是,上面所说的进程地址空间是"虚拟"的,并非物理内存的实际布局。例如,一个32位进程理论上可以拥有4GB的虚拟地址空间,这是因为32位指针可以寻址2的32次方个地址,每个地址对应1字节,总计4GB。但实际上,物理内存可能只有8GB或16GB,操作系统通过内存映射机制,将虚拟地址与物理地址建立对应关系,进程实际使用多少内存,才分配多少物理内存,从而实现了资源的有效利用。
1024就是2的10次方,2的30次方就是1G,2的32次方就能编址4G
进程,除了有32位,现在还有64位的,现在很多都是64位进程,64位的进程空间就很大了,2的33次方是2的32次方×2为8G,2的64次方等于42亿九千万×4G,等于160多亿G,如果是64位程序,指针就是8个字节,因为有8个字节就可以编址这么大的空间。
现代操作系统多为64位,它们可以同时运行32位和64位的程序。64位进程的地址空间极其巨大,其指针长度为8字节,理论上可寻址的空间达到2的64次方字节,这是一个远超实际硬件支持的数值。因此,尽管虚拟地址空间非常庞大,但操作系统仍然通过映射机制,只在实际需要时才分配物理内存。
理解不同内存区域的作用及其数据的生命周期,对于编写稳定、高效的程序至关重要。栈和静态区的分配与释放由系统自动管理,而堆内存则需要程序员手动控制,这也是数据结构和内存管理成为编程核心内容的原因之一。通过对进程和内存布局的深入理解,我们能更好地掌握程序运行的底层机制,从而写出更高质量、更可控的代码。

只说比较容易错的几个:
- char2是函数内定义的局部字符数组,存储在 栈(A)。* char2是数组的第一个元素(数组本身在栈上),因此也在 栈(A)。注意:数组名sizeof和单独用代表整个数组,但是对其进行运算的时候,代表的是首元素的地址
- pChar3:pChar3是函数内定义的指针变量,内部存的是常量字符串首元素的地址。本身存储在 栈(A)。
它指向的字符串常量"abcd"存储在 代码段(常量区,D),因此* pChar3在 D - ptr1:ptr1是函数内定义的指针变量,存储在 栈(A)
它通过malloc动态分配的内存位于 堆(B),因此* ptr1在 堆(B)
补充,常量区的数据还有一个特点,就是不能被修改,其在物理上被保护,只要触发了去访问该区域的数据,系统就能直接用类似assert断言的方式来检查
想修改有两种方式,第一种方式强制类型转换,从const char *转换为char *再去解引用
编译不会报错,但是修改的时候就会报错
这里再补充一个点,这里看到i是被const修饰的,很多人会认为i是存在常量区的。其实不然,C++中被const修饰的变量叫做常变量,其并不存在常量区,只是不能被修改,但是还是有办法修改的,i的地址类型为const int* ,可以强转为int* ,再解引用就可以修改了,所以这里的i也在栈上
也可以看到i和j二者的地址离的不远,pChar3是常量区的地址,pChar3本身是一个栈上的局部变量,但打印的时候是打印其指向的值,其值的地址就是一个常量区的地址
但是cout直接打印是打印不出pChar3的地址的,因为对于 const char* 类型,cout 的设计者特意将其重载为 "输出该指针指向的字符串内容" (从指针地址开始,一直输出到 \0 终止符)。
对于其他类型的指针(如 int*、void*、double* 等),cout 没有这种 "字符串输出" 的重载,因此会直接输出指针本身的内存地址。
cpp
int* pInt = new int(10);
const char* pStr = "hello";
cout << pInt << endl; // 输出int*的地址
cout << pStr << endl; // 输出字符串"hello"
这里要打印出pChar3的地址,要么使用printf指定%p去打印,要么强制类型转换为void*
C语言和C++在对于不同对象在不同区域的管理性质是一样的

说明
- 栈又叫堆栈 - 非静态局部变量/函数参数/返回值等等,栈是向下增长的(在栈中定义的对象地址是不断变小的,上面是高地址,下面是低地址,用main调用函数,函数中定义的变量地址也是比main函数内定义的变量地址要小的,再上上图地址的打印也有体现)
- 内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享内存,做进程间通信
- 堆用于程序运行时动态内存分配,堆是可以向上增长的(也不一定一定是向上增长,一是因为堆中会对小块内存进行单独的管理。二是申请的空间也有可能是别人释放的空间)
- 数据段 - 存储全局数据和静态数据
- 代码段 - 可执行的代码/只读常量
二、C语言中动态内存管理方式:malloc/calloc/realloc/free

这里realloc既有可能原地扩容,也有可能异地扩容,原地扩容的话,p2,p3的地址是一样的,释放一次就可以了,一块空间是不允许释放两次的(释放完的空间有可能瞬间分配给别人用,再释放就把别人的空间释放了)。C语言也不支持分段释放(比如100字节分两段释放)
一般申请的空间小就是原地扩容,申请大了就是异地扩容
异地扩容也不需要释放p2。realloc开一块新的空间,把数据拷贝过来,其会把旧空间释放,把新空间返回
面试题
- malloc/calloc/realloc的区别
- malloc的实现原理我这里贴一个视频,说实话这个设计原理我还觉得挺复杂的,建议C++这门编程语言学完了再去看,里面涉及的是一些内存池的概念
glibc中malloc实现原理
三、C++内存管理方式
C语言内存管理方式在C++中可以继续使用,但是有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理
3.1 new/delete操作内置类型
C语言中对空间的操作都是malloc和free,其最大的特点就是使用一个库中的函数来进行操作,而C++这里的new和delete就是一个关键字,是关键字中的运算符操作符,和加减乘除一样
new+类型就是申请这个类型的对象,不需要像malloc一样去计算这个类型多少字节,也不需要强制类型转换(new int返回Int* ,new double返回double* )
可以理解为new涵盖了malloc和calloc的所有功能,但是C++中没有提供扩容(realloc)相关的运算符操作符,要扩容需要手动完成
注意:申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[ ]和delete[ ],注意:匹配起来使用
3.2 new和delete操作自定义类型
对于内置类型,malloc和new只是用法上不同,达到的效果差不多,C++的这一套新东西主要是为了搞自定义类型
如果基于使用malloc给自定义类型开空间,初始化会很难很麻烦,类的成员变量是私有的,就无法初始化
new和delete和malloc free除了用法和初始化析构特性上的区别,还有一个开辟失败的区别,malloc失败会返回一个空指针,new开辟失败会抛异常
日常使用中malloc和new很少失败,只有两种失败的情况
- 没有足够的内存供你去使用
- 有足够的空间但申请的空间太大了,而剩余的空间都不是连续的。new和malloc申请的都是一段连续的空间
验证了new开辟失败不会返回空指针
抛异常是C++面向对象处理错误的一种方式,如果一段代码中某个部分会抛异常,要想处理这个错误就要捕获异常,图中try catch就是一种捕获方式,异常我会在后面继承和多态的文章细讲,这里只展示一下用法
当new开辟失败的那一次,会把它的执行流直接跳转到catch这行代码,捕获了之后e就是那个异常对象,其中包含着错误信息。错误信息有一个成员函数叫what,what就拿到了到底发生了什么错误,相当于返回了一个错误信息的字符串,拿what接收打印
可以看到这里退出码为0,也就是说异常一定是要被捕获的,堆这里能申请到的空间还是蛮大的,上面合计从堆上申请了494×4MB,大概在1.9个G,因为1024MB是1G。这是在X86的运行环境下,整个进程的空间才4G
3.2.1 关于异常的调试技巧
下面再说一下异常捕获的特性,在上面如果想在循环中通过调试来找异常的问题,那么就要调试495次才可以,这样一次一次按效率太低了,下面就说两种技巧
-
打条件断点,调到哪个函数的时候停下来或到达哪个数据停下来
但是我个人觉得这个方式比较麻烦
-
手动条件断点
调试到这里执行流会直接跳转到45行捕获的地方,这样也可以精准的调试某个条件
这样的跳转是依靠一个关键字throw,throw了一个exception对象
异常还可以跨函数调换,下图func内抛异常了,但是func没有捕获。必须被捕获的限定点是main函数截止前必须被捕获,main结束还没有被捕获就会报错,当前函数是可以没有捕获的
四、operator new与operator delete函数
new和delete是一个运算符操作符,运算符和操作符的特性就是要么转换为函数调用,要么转换为对应的指令。比如说自定义类型加加就可以转换为对应的函数调用,内置类型加加就会转换为调用对应的指令
new在面对类类型的时候分为两步,第一步是去堆上开空间,第二步去调用构造函数。去堆上开空间最终是走到系统调用然后不断向后走,所以没有必要创建一个新的函数去堆上开空间。new在堆上开空间的底层还是调用malloc,在底层上可以认为new是对malloc的再包装,malloc是new的组成部分,但也不是一个单纯的组成部分,malloc失败了是返回一个空指针,new是抛异常。C++的new和delete要符合面向对象处理错误的特性,面向对象处理错误的特性就是异常。但是malloc失败了不是抛异常,所以就会对其再次包装
所以C++的库中先实现了operator new和operator delete函数,二者不算是对new和delete的重载,而是系统提供的两个特殊的全局函数,我们也可以直接使用
new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间
下面截取了一些库中的源码
operator new与operator delete其实是C++对malloc和free的平替,但是C++库弄出这个东西不是为了给我们直接调用的,而是作为new底层对应的机制
运算符要么转换为对应的运算符重载的调用,要么就转换为指令,这里二者兼有,new和delete都转换为对应的指令了,但是不是转换为一个指令,而是多个指令,这多个指令又包含了调用函数,这里看一下new的反汇编
底层最核心的语句如上图
Debug版本下会有很多的语句,有一些语句是要在栈帧中压一些数据,如果编译为Release语句会少很多
再看一下delete的反汇编代码
new是先开空间(operator new),再调构造函数
对象上有资源的情况下delete是先调用析构再释放空间,析构是对对象上的资源进行清理
如果先把对象的空间释放了,就无法找到对象上的资源了,delete是先调析构,再调operator delete释放对象本身
new和delete之所以用操作符来搞的原因就是可以直接转换为对应的指令
五、new和delete的实现原理
5.1 内置类型
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:
new/delete申请和释放的是单个元素的空间,new[和delete[申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。
5.2 自定义类型
- new的原理
-
- 调用operator new函数申请空间
-
- 在申请的空间上执行构造函数,完成对象的构造
- delete的原理
-
- 在空间上执行析构函数,完成对象中资源的清理工作
-
- 调用operatordelete函数释放对象的空间
- new T[N]的原理
-
- 调用operatornew[ ]函数,在operator new[ ]中实际调用operator new函数完成N个对象空间的申请
-
- 在申请的空间上执行N次构造函数
- delete[ ]的原理
-
- 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
-
- 调用operator delete[ ],释放空间,实际在operator delete[ ]中调用operator delete来释放空间
再补充一点,这里一个A对象是4个字节,new10个A对象理应是40字节
这里转到反汇编,push就是给operator new[ ]压的参数,参数是44
operator new[ ]底层
多开了这四个字节,申请的空间起始在第一个箭头位置,返回的空间起始位置是第二个箭头位置,前面的4个字节存了一个10进去,10就是new申请的对应的对象个数,再看一下内存窗口
p2对象的前4个字节存了一个10,delete[ ]时编译器不知道p2的对象个数,就让p2指针向前偏移四个字节,然后把头部这四个字节取出来,就知道需要释放多少个对象了,后面就对这10个对象依次调用析构
所以说这些编译器的底层设计的是非常精巧的
如果把析构函数注释掉,这里又会有一个变化
此时就变为正真的申请40个字节了,所以说编译器的语法分析是很精细的,它发现A这个类型没什么资源要释放(因为没有写析构,使用默认生成的析构,但有没有什么资源需要释放),编译器就优化不调用析构,就不用多开四个字节存对象的个数了,直接调operator delete
六、定位new表达式(placement-new)
这个new和前面的new无关,都是用同一个关键字,但是用法特性是不一样的
定位new表达式是在已分配的原始内存空间中显式调用构造函数初始化一个对象
使用格式 :
new(place_address) type或者new(place_address) type(initializer-list)
place_address必须是一个指针,initializer-list是类型的初始化列表
使用场景 :
定位new表达式在实际中一般是配合内存池使用。因为内存储分配出的内存没有初始化,如果是自定义类型的对象,需要使用new的定义表达式进行显示构造函数进行初始化
用这两种方式就完美模拟了new和delete的特性
6.1 内存池相关概念
补充一下内存池的概念,现实当中开辟内存还有一种方式叫内存池,内存池源自软件开发的一个池化技术,池化技术的设计包括内存池,线程池,连接池等方面(系统拥有的一种资源)。一个程序有时候可能需要大量的内存,每次需要再去创建,不用了再销毁,这个过程可能会很浪费性能。池化技术就是用一个池子把这些内存,线程,连接装起来,用的时候就不用去找系统线程申请,而是在池子中去取,不用了就归还回池子,达到一种可以复用的效果。这就是一种提高性能的方式。
new,malloc这套机制可以理解为直接找系统申请空间,其实C++中malloc本身也是一个内存池,但是在某些特殊场景下,malloc这个内存池不够高效
谷歌就在多线程并发的场景下做了一个比malloc更高效的内存池tcmalloc,系统里的new默认封装的是malloc,用new相当于用到malloc这个内存池,而且会自动调用构造函数。这时候谷歌想用自己的内存池tcmalloc更高效的开辟一个A对象,这就没有自动调用构造函数初始化了,这时候要对A对象构造,就要用placement-new显式调用构造函数,这就是定位new的一个使用场景
七、malloc/free和new/delete的区别
malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。不同的地方是:
- malloc和free是函数,new和delete是操作符
- malloc申请的空间不会初始化,new可以初始化
- malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可,如果是多个对象,[ ]中指定对象个数即可
- malloc的返回值为void* ,在使用时必须强转,new不需要,因为new后跟的是空间的类型
- malloc申请空间失败时,返回的是NULL,因此使用时必须判空,new不需要,但是new需要捕获异常
- 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理释放
new/delete的底层是封装的malloc/free
结语
