C语言vsC++中的动态内存管理(内含底层实现讲解!)

动态内存管理


前言

本篇进行动态内存管理的讲解


一、C语言中的动态内存管理

我们知道,在C语言中,我们在使用数组的时候,一般采用申请静态内存空间,也就是写死的空间大小,比如我们直接定义一个空间大小为10的数组int a[10] = {};,此时数组a的空间已经写死了,是无法改变的,那么如果当10个空间不够,我们需要更多空间的时候呢?我们该如何去获取更多空间呢?

此时数组已经不适用了,因为我们并不确定我们到底需要多大的空间,这时候就要用到指针

使用指针,我们可以通过动态申请内存资源来为指针开辟内存空间,譬如:int* a = (int*)malloc(sizeof(int) * 4 );这代表我们为指针a申请了四个字节大小的空间,而且我们可以根据自己需要的空间大小来修改,譬如我们需要申请十个字节大小的空间,那么我们可以直接写int* a = (int*)malloc(sizeof(int) * 10);,此时就代表我们为指针a申请了十个字节大小的空间

在C语言中,我们有三个函数可以进行动态申请内存资源,分别为malloc、calloc、realloc这三个函数,其实malloc的用法我们已经熟知了,而calloc的作用与malloc相似,不同的是,calloc会自动将申请的空间全部初始化为0,譬如:int* a = (int*)calloc(2,sizeof(int));这代表我们为指针分配两个字节大小的空间,并且将空间内的值全部初始化为0

至于realloc,则是对原有的空间进行扩容,不需要重新写,适用于内存不够时继续申请,譬如:int* a = (int*)malloc(sizeof(int)*4);我们为指针a申请了4个字节大小的空间,而我们在使用的过程中,却发现内存好像不够用,需要8个字节,此时,我们就可以使用realloc,如:int* b = (int*)realloc(a,sizeof(int) * 8)这代表我们将b的空间扩容到了八个字节,而realloc扩容空间,也有两种情况,分别是原地扩容和异地扩容

第一种情况:原地扩容,当我们使用realloc去扩容空间时,编译器会先检查原空间后面是否有充足的空间,比如我们对a指针原有空间进行扩容,如果a指针指向的空间后面有未被使用的充足的空间,那么编译器会将后面那一块空间申请下来,作为新扩容的空间,而如果后面的空间不足以达到我们扩容的要求,就会转为第二种情况

第二种情况:异地扩容,当我们使用realloc函数去进行空间扩容时,如果原空间后面空间并不能满足扩容要求,就会在其他有剩余空间的地方重新申请一块空间,来作为扩容后的空间,并且原空间会被释放,譬如:

因此,当我们对指针p1进行realloc扩容后,如果扩容成功,那么p1指针指向的空间就会被释放,那么我们最后要释放的空间就是扩容后的空间,并且不能再重新释放p1,因为这会导致编译器出错,同一块空间不可能被连续释放两次,如:

c 复制代码
int* p1 = (int*)calloc(4,sizeof(int));
int* p2 = (int*)realloc(p1,sizeof(int) * 8);
free(p2);

我们此时释放了p2,不能再对p1进行释放,因为p1指向的空间在扩容成功后就被直接释放了,并且我们一般采取新指针p2来接收扩容后的空间,就是为了避免扩容失败返回NULL,NULL被p1接收导致原指针指向的空间丢失

但是我们发现,有一个问题,每次我们使用malloc动态申请完内存空间后,我们都需要对其检查是否成功,并且malloc动态开辟空间之后无法进行初始化,这些都是极为不方便的,有同学说,我们使用calloc不就行了吗,它可以进行初始化呀!

是,calloc函数确实动态申请内存空间,也可以进行初始化,但是它的初始化是全部初始化为0,如果我们不想初始化为0呢?如果我们需要初始化为其他数呢?

此时我们的calloc就无能为力了,而为了应对这种情况,祖师爷在C++增加了两个操作符来解决,分别为new和delete

二、C++中的内存管理

前面我们说到,为了解决初始化等一系列问题,祖师爷在C++中新加了两个操作符,分别为new和delete

1.new

new,是关键字,是操作符,并不是一个函数,它既可以用于动态内存管理,也可以用于普通变量的定义声明,它的用法如下:

当我们需要定义一个在堆上创建的变量或者对象的时候,可以直接用new去定义,int* a = new int;,这就相当于定义了一个整型指针类型的变量a

如果我们想要对a进行初始化,需要用到(),如:int* a = new int()这代表将指针a指向的地址的值初始化为10

如果我们想要申请多个空间的数组,就可以用int* a = new int[10],这代表创建一个有十个字节空间的数组,空间大小可以由[]中的数字控制,这也属于动态申请内存资源,并且我们还可以对数组进行初始化,如:int* a = new int(1),这代表开辟一个字节空间的数组并将其初始化为1,如果有多个数据,那么就是int* a = new int[10]{1,2,3}这代表开辟一个十个字节大小的空间,并且将前三个数据分别初始化为1、2、3,剩下的全部初始化为0

同样,当我们new内置类型的时候,与我们在C语言中普通创建内置类型变量的规则相同,编译器不会自动对其进行初始化,只会找一个固定的随机值

而当我们new自定义类型的时候,比如说类类型,如:

c 复制代码
A* a1 = new A[3];

这代表我们创建了一个存储对象的数组,里面可以存储三个对象,分配的是 能装3个 A 对象的连续堆内存"(对象数组),并且当A类的构造函数需要显式传参时,我们也必须显式传参,如下代码例子:

c 复制代码
struct ListNode
{
    int _val;
    struct ListNode* _next;

    ListNode(int x)
        :_val(x)
        ,_next(NULL)
    {}
};

struct ListNode* BuyListNode(int x)
{
    // 单纯开空间
    struct ListNode* newnode = (struct ListNode*)malloc(sizeof(struct ListNode));
    // 检查
    newnode->_next = NULL;
    newnode->_val = x;

    return newnode;
}

针对这段链表代码,我们通常创建节点的方式都是:
struct ListNode* n1 = BuyListNode(1)struct ListNode* n2 = BuyListNode(2)这样通过调用函数去开辟空间,创建节点

而对于new来说,我们不需要调用函数,如:ListNode* n1 = new ListNode(1)

它的效果等同于struct ListNode* n1 = BuyListNode(1),有一点不同的地方是,我们使用new,并没有调用函数去显式使用malloc开辟空间,而是隐式地调用了malloc函数开辟了空间(后面会讲为什么是调用了malloc函数去开辟空间),然后再将参数1传给构造函数,调用构造函数去初始化,同样,如果构造函数不需要传参,那么我们就可以像之前A类一样,直接写为A* a1 = new A;

而且,对于类来讲,我们传参的方式也有很多种,因为类创建对象可以进行隐式类型转换,那么比如对于日期类来讲,我们就可以这么传参:Date* d1 = new Date[3]{2024,2025,2026},也可以Date* d2 = new Date[3]{Date(1),Date(2),Date(3)},前者是通过隐式类型转换完成,仅调用构造函数,后者是通过创建临时对象,调用构造+拷贝构造来完成

我们再注意一点!

new的核心是在堆上分配内存并返回地址(指针),而非直接返回对象本身,因此必须用指针去接收这个地址,也就是说,new的作用就是动态申请堆上的内存资源并进行初始化,随后返回地址,因此必须用指针类型去接收new的返回值

2.delete

前面我们讲到,祖师爷在C++中添加了new操作符来替代malloc,用指针去接收new的返回值,那么对应的,指针使用完后需要释放,祖师爷也添加了一个delete操作符来替代free函数

delete,也是一种操作符,一种关键字,它的用法和new类似,譬如当我们使用new创建了一个内置类型的指针变量a时int* a = new int,便可以使用delete去释放空间delete a,同样,如果该指针开辟的空间是多个字节,那么就是int* a = new int[10]delete[] a

而delete和free也有一点不同,那就是在释放类类型对象资源的时候,delete会调用类中的析构函数,并且开几个空间,就会调用几次,如:

3.new和delete的底层实现

我们前面提到过,new是隐式调用了malloc函数进行开空间的,而delete则是隐式调用free函数进行资源释放的,那么是怎么实现的呢?我们来看:

我们查看反汇编代码,发现该语句下先调用了operator new[]这个函数,我们根据地址07FF686BB1212h去寻找,找到地址为:

那么我们再去寻找07FF686BB2C90h

我们找到了operator new[]这个函数的函数体,发现函数体调用了operator new这个函数,我们继续根据地址07FF686BB104Bh去寻找

我们找到了函数operator new的地址07FF686BB3B00h继续寻找

如图,我们可以发现,operator new函数体内部是通过调用malloc函数来开辟空间的,那么,这也就证明了我们前面说的是正确的

并且我们还可以发现,我们使用malloc开辟空间的操作是在operator new函数里完成的,也就是说,operator new函数将使用malloc开辟空间的操作封装在了自己的函数体内,我们使用new时,编译器是通过调用operator new函数来实现malloc函数的隐式调用,进而完成空间开辟的功能

并且,如果我们需要开辟多个空间,就会转而调用operator new[]函数,在operator new[]函数体内调用operator new函数,来实现多个空间的开辟

至于delete1的底层实现,与new类似,也会通过一个operator delete函数来封装free函数

如果我们需要申请一个堆上的栈:

c 复制代码
Stack* st1 = new Stack;
delete st1;


至于为什么要对malloc和free进行封装,这是因为C++兼容C语言,并且malloc和free本身就具有开辟空间和释放资源的功能,因此我们只需要补全初始化等功能即可,所以我们可以直接复用malloc和free

但是C++是面向对象的语言,它也兼容面向过程,而C语言只是一个面向过程语言,因此对于C++来讲,当扩容失败或者出现错误,他不会像C语言一样采取根据返回值返回0或者返回1的方法来判断代码执行的结果,而是抛出异常try catch通过异常来识别代码到底出现了什么错误,因此我们实现一个函数,比如operator new,将malloc的调用以及异常抛出的功能实现全部封装在里面,只需要调用该函数即可实现一切功能

同时也要注意,operator new 和 operator delete这两个函数仅实现开辟空间和释放资源的功能,而调用析构函数和构造函数则是由delete和new两个操作符后面自行调用实现的

也就是说,new的实现过程是先调用operator new函数开辟空间,再调用构造函数进行初始化

而delete的实现过程是先调用析构函数将资源释放,再调用operator delete函数进行堆上的资源释放

如:

我们通过operator new和operator delete去开辟空间和释放资源,并没有调用构造函数和析构函数

并且这两个函数并不是类的成员函数,而是全局函数,也就是说,它不属于运算符重载,但是可以被重载

并且,我们尽量配套使用new和delete,malloc和free,因为不配套使用,某些情况下会报错,虽然偶尔不会报错,但尽量避免

4.new、delete和malloc、free的区别

最后,我们来总结一下new、delete和malloc、free的区别

1.new可以进行初始化,malloc不能进行初始化
2.malloc动态申请内存资源之后需要显式检查是否申请成功,因为失败会返回空,new不需要,只需要捕获异常
3.对于自定义类型,new在开辟空间后会调用构造函数初始化对象,delete会先调用析构函数释放对象内部申请的资源,再释放对象所在位置的资源
4.new和delete是操作符,malloc和free是函数
5.malloc需要手动计算申请空间的大小,new不需要,只需要通过[]中的数字去控制,指定对象个数即可
6.malloc的返回值为void*,使用时必须经过强转,比如(int*)malloc(),但是new不需要,因为new后面跟着开辟空间的类型

5.定位new

c 复制代码
int main()
{
//p1现在指向的只不过是与A对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行
A* p1 = (A*)malloc(sizeof(A));
//显示调用构造函数
new(p1)A(1);  // 注意:如果A类的构造函数有参数时,此处需要传参
p1->~A();
free(p1);
return 0;
}

定位new的作用主要就是不在堆上分配新内存,而是直接在p1指向的已有内存地址上,构造一个 A 类对象,并传入构造参数 1

也就是说new(p1)A(1)的作用就是在已有空间的基础上,构造(实例化并且初始化)一个对象,并不负责开辟空间

只会我们需要显式调用析构函数

定位new经常用于内存池中,也就是分配了内存但是却没有构造对象的内存,即分配后未被使用过的内存(后面会讲解内存池是何原理),使用内存池会提高我们申请内存资源的效率

相关推荐
持梦远方2 小时前
算法剖析1:摩尔投票算法 ——寻找出现次数超过一半的数
c++·算法·摩尔投票算法
{Hello World}2 小时前
Java抽象类与接口深度解析
java·开发语言
AI视觉网奇2 小时前
ue5 自定义 actor ac++ actor 用法实战
java·c++·ue5
明洞日记2 小时前
【软考每日一练002】进程调度机制详解
c++·ai·操作系统·进程
光明顶上的5G2 小时前
本地缓存面试重点
java·缓存·面试
haluhalu.2 小时前
深入理解Linux线程机制:线程概念,内存管理
java·linux·运维
jiaguangqingpanda2 小时前
Day22-20260118
java·开发语言
雪碧聊技术2 小时前
1、LangChain4j 名字的寓意
java·大模型·langchain4j
风生u3 小时前
bpmn 的理解和元素
java·开发语言·工作流·bpmn