动态内存管理【详解】
本期介绍🍖
主要介绍:为什么会存在动态内存管理,动态内存函数:malloc、calloc、realloc、free,什么是内存泄漏,什么是内存池,常见的动态内存错误有哪些,还有几道经典笔试题👀。
文章目录
- 一、为什么会存在动态内存管理?🍖
- 二、动态内存管理函数🍖
- 2.1 malloc🍖
- 2.2 calloc🍖
- 2.3 realloc🍖
- 2.4 free🍖
- 三、内存泄漏&&内存池🍖
- 四、常见的动态内存错误🍖
- 4.1 对NULL指针解引用操作🍖
- 4.2 对动态开辟空间越界访问🍖
- 4.3 对非动态开辟内存使用free释放🍖
- 4.4 使用free释放动态开辟内存的一部分🍖
- 4.5 对同一块动态内存多次释放🍖
- 4.6 动态开辟内存忘记释放(内存泄漏)🍖
- 五、经典笔试题🍖
- 5.1 题目一:🍖
- 5.2 题目二:🍖
- 5.3 题目三:🍖
一、为什么会存在动态内存管理?🍖
现在我们已学了两种开辟空间的方式,其一:int a = 0;
这种一次开辟一小块空间,其二:int arr[10] = { 0 };
这种一次开辟一大块空间。但这两种开辟方式是有局限性的,一旦开辟就无法再更改开辟空间的大小了。举个例子:现创建了一个结构体数组,数组每个元素都是一个人的信息,而该数组能存放100个人的信息。但我仅需要存放3个人的信息,对于这个结构体数组来说,剩余的97个空间不就浪费了嘛;同理我需要存放120个人的信息,对于该数组来说是放不下的,而我又不能对其大小进行修改。
可见这两种对于空间的开辟方式是多么的不灵活,那是否存在一个功能,能够使得我们自主的来管理空间,想大就大想小就小?C语言给出了动态内存管理,且在C语言中是通过下面这几个函数来实现的:malloc、calloc、realloc、free,要使用这几个函数就必须先引用一个头文件<stdlib.h>
才行。
然后让我们回过头再来看一看那个案例,其动态版本相较于静态版有哪些提升了。当我们用动态内存管理来实现后,就可以做到如下:先给你三个空间让你放,你放满了不够,我再给你开辟3个空间,你又放满了不够,那再给你开辟3个空间,如此往复下去。那么这种使得空间不断变化,对于空间的利用率不就更高了嘛!所以可见动态内存管理的存在是具有有一定的必然性的。
注意:动态内存是在堆区上开辟空间的,而之前哪两种方式是在栈区上开辟空间的。堆区中申请的空间是可以被修改的,而栈区中申请的空间是固定死的无法修改。
二、动态内存管理函数🍖
2.1 malloc🍖
void* malloc( size_t size );
malloc函数会向内存申请一块连续可用的空间,并返回一个指向该空间的指针(指针的类型为void*
),如果开辟失败,则返回一个NULL,因此malloc的返回值一定要做检查。而参数size
决定了malloc所要开辟空间的字节大小。举个例子:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
//在栈区中开辟一个大小为40个字节的空间
int arr[10] = { 0 };
//在堆区中开辟一个大小为40个字节的空间
int* ptr = (int*)malloc(sizeof(int) * 10);
if (ptr == NULL)
{
//打印错误信息
printf("%s\n", strerror(errno));
//返回异常
return 1;
}
int i = 0;
for (i = 0; i < 10; i++)
{
*(ptr + i) = i + 1;
}
for (i = 0; i < 10; i++)
{
printf("%d ", *(ptr + i));
}
//释放动态内存空间
free(ptr);
ptr = NULL;
return 0;
}
注意:
1. 当ptr指向的空间被释放后,ptr必须赋值为NULL,因为当ptr指向空间被释放后,ptr就成了野指针,这是很危险的。
2. 在C语言中有一个历史遗留问题,也可以说是一个习惯,return 0;
表示正常返回,return 1;
表示异常返回。
3. 至于为什么malloc的返回值会设置成void*
,这是因为malloc函数自己也不知道开辟空间之后是给什么类型用的,所以在实际应用时需要使用者自己来决定该指针的类型。
4. 如果参数size
为0,这种malloc行为是标准未定义的,取决于编译器。
2.2 calloc🍖
void* calloc( size_t num, size_t size );
calloc和malloc一样都是用来向内存申请空间的函数,但还是有所区别的,calloc有两个参数分别为:元素个数num
和大小size
,由这两个参数共同决定所开辟空间的大小。
注意:calloc函数有一个特点,在开辟完一个空间后,会将所开辟的空间全部初始化为零,然后再返回起始位置的地址,但malloc并不会如此。举个例子:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
//在栈区中开辟一个大小为40个字节的空间
int arr[10] = { 0 };
//在堆区中开辟一个有10个元素,每个元素大小为10的空间
int* ptr = (int*)calloc(10, sizeof(int));
//判断是否开辟成功
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
return 0;
}
2.3 realloc🍖
void* realloc( void* ptr, size_t size );
有时我们会发现之前申请的空间太小了,有时我们又会觉得申请的太大了,为了能够更有效的使用内存,就需要对申请空间的大小做灵活的调整。这是就需要用到realloc()
函数了,它可以做到调整动态开辟内存的大小。realloc有两个参数分别为:
ptr
:指向将要被调整大小的空间的指针size
:调整后新的大小(单位:字节)
若realloc成功调整大小,则会返回现动态开辟内存的起始位置(类型为void*
);如若调整失败,则返回NULL。如果我们直接用维护被调空间的指针ptr
来接收调整后新空间的地址,必然会存在一种情况:当调整失败后,指向原先空间指针ptr
被赋值为NULL,也就是说当调整失败后我既没有得到新空间的地址又丢失了我原先动态开辟的空间的地址,这是非常严重的错误。
所以我们一般使用realloc时会先创建一个中间变量来接收realloc的返回值,然后对其进行判断,如果不为NULL再将其赋值给ptr
。如下所示:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
//申请空间
int* ptr = (int*)malloc(40);
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
//调整动态开辟空间大小
int* tmp = (int*)realloc(ptr, 16);
//判断是否开辟失败
if (tmp != NULL)
{
ptr = tmp;
}
//下面就是使用了
return 0;
}
还值得注意的是,realloc在扩展内存时存在两种情况: 1. 原有空间后有足够大的空间。2. 原有空间后没有足够大的空间。
情况1
当原有空间后存在足够大的空间时,在原空间的基础上直接向后追加空间,原空间内的数据不发生任何变化。调整前与调整后动态开辟内存的起始位置不发生任何变化,如下图所示:
情况2
当原有空间后存在的空间不够用时,内存空间扩展的方法为:先向后寻找看是否存在一个能够存放的下调整后大小的连续空间,若存在则直接向内存申请,然后将原空间中的数据拷贝到新空间中,最后将新开辟空间的起始地址返回(注意:realloc在开辟完新空间后,会自动释放掉原先的那块空间,不需要我们手动释放)。如下图所示:
2.4 free🍖
void free( void* ptr );
free是专门用来回收或释放动态内存开辟的空间的函数,其参数ptr
为那块动态开辟空间的起始地址。那问题来了,当我用free(ptr);
释放指针ptr
维护的那一块空间时,free会连同将指针ptr
置为NULL吗?举个例子:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int* ptr = (int*)malloc(40);
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
int i = 0;
for (i = 0; i < 10; i++)
{
*(ptr + i) = i + 1;
}
free(ptr);
return 0;
}
由上图可知,ptr
指向的那块空间确实被释放了(也就是还给操作系统了),但值得注意的是free函数不能不会顺便将指针ptr
赋为NULL,也就是说此时的指针仍然指向那个位置,而那个位置的空间已经不是我们的了,故此时的指针ptr
为野指针。所以我们应该在free释放完一块空间后,紧接着加一步将指向该空间的指针赋为NULL。
注意:
1. 如果ptr
指向的空间不是动态内存开辟的,那么此时free的行为是标准未定义的。举个例子:
#include<stdlib.h>
int main()
{
int arr[10] = { 0 };
int* ptr = arr;
free(ptr);
return 0;
}
2. 如果ptr
是NULL指针,则free什么事都不做。
三、内存泄漏&&内存池🍖
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int* ptr = (int*)malloc(sizeof(int) * 10);
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
int i = 0;
for (i = 0; i < 10; i++)
{
*(ptr + i) = i + 1;
}
return 0;
}
如上代码所示,一定会有同学说上面这个是错误代码嘛,会造成内存泄漏,原因是结尾没有用free
释放掉之前向内存申请的空间。但我想告诉你通过结尾有没有free来判断是否内存泄漏,这是对内存泄漏的错误认知。
那到底什么是内存泄露呢? 通俗点来说就是,你向内存申请了一块空间,用完后不还,别人想用还用不到,如果你一直不还,那么这块空间不就相当于不存在了,也就是理论上泄漏掉了。可上面这个案例中那块被申请的空间可不会一直不还,当程序结束时,操作系统会自动回收那块空间的。内存泄漏会带来的影响:系统内存浪费、程序运行缓慢、甚至系统崩溃等严重后果。
根据之前讲述realloc函数时,我们知道在堆区上开辟空间,空间之间是有间隙(内存碎片)的。如果在内存中频繁的使用malloc,就会使得内存中存在非常多的内存碎片,如果这些内存碎片在之后没能有效的利用的话,就会导致内存利用率和效率的下降。
而想要解决上述问题,我们可以引用一个新概念内存池。那什么是内存池呢? 内存池就是一种通过程序来维护内存空间的方法。是怎么实现的呢? 首先我们会向内存申请一块相对来说能够满足我们当前需求的空间,然后程序内部用内存池的方式来维护这块空间,如此就不用应频繁的申请而打扰操作系统了,且解决了内存碎片的问题。
四、常见的动态内存错误🍖
4.1 对NULL指针解引用操作🍖
该问题的出现,就是由于之前说的使用malloc
或calloc
函数后没有对其返回值进行判断所导致的。因为当malloc
或calloc
向内存申请空间失败后,就会返回空指针NULL,而如果你这时没有对其进行判断直接上手使用,自然就会伴随着一定的风险。举个例子:
void test()
{
int *p = (int *)malloc(INT_MAX/4);
*p = 20;//如果p的值是NULL,就会有问题
free(p);
p = NULL;
return 0;
}
所以我们在向内存申请完空间后,一定要记得做一次判断看其是否申请失败了,若失败了就直接结束返回吧,若成功则继续往下使用。故上述案例可以被改成如下正确形式:
void main()
{
int *p = (int *)malloc(INT_MAX/4);
if(p == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
*p = 20;
free(p);
p = NULL;
return 0;
}
4.2 对动态开辟空间越界访问🍖
之前在学习数组、指针时经常会编写一些越界访问的程序,而如今对于动态开辟出来的空间自然也会出现,由于操作不挡而导致的越界访问。(所谓的越界访问,就是访问的过程中访问了不属于我们的空间)举例如下:
void main()
{
int i = 0;
int *p = (int *)malloc(10*sizeof(int));
if(NULL == p)
{
printf("%s\n", strerror(errno));
return 1;
}
for(i=0; i<=10; i++)
{
*(p+i) = i;//当i是10的时候越界访问
}
free(p);
p = NULL;
return 0;
}
可见对于动态开辟内存的越界访问的操作是不被允许的,操作系统会直接报错。
4.3 对非动态开辟内存使用free释放🍖
总是有些人喜欢一些奇奇怪怪的事,就譬如拿用来释放动态开辟空间的free
函数去释放静态开辟的空间。如下所示:
int main()
{
int arr[10] = { 0 };
int* p = arr;
free(p);//是否可行?
return 0;
}
我们发现如果动态内存越界访问了,操作系统会立即报错,而对于静态内存的越界访问操作系统并不会给予及时的提醒。
4.4 使用free释放动态开辟内存的一部分🍖
有时在使用维护动态开辟空间的指针来编写代码时,会在无意中移动这个指针,当想要用该指针释放动态内存时,该指针已然不指向该内存的起始位置,如此不就造成了部分内存释放了嘛。举个例子:
int main()
{
int* ptr = (int*)malloc(40);
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
int i = 0;
for (i = 0; i < 5; i++)
{
ptr++;
}
free(ptr);//ptr不再指向动态内存的起始位置
ptr = NULL;
return 0;
}
内存布局如下所示:
运行结果如下所示:
可见对于动态内存的部分释放是不被允许的,就算你想如此操作系统也不会承认的,你给free
的指针必须是指向动态开辟空间的起始地址,这一点一定要注意了。
4.5 对同一块动态内存多次释放🍖
int main()
{
int* ptr = (int*)malloc(40);
//...
free(ptr);
//...
free(ptr);//重复释放
return 0;
}
可见在C语言中同一块动态内存是不允许被多次释放的,只能被释放一次,不然就报错。为了避免这种情况,我们其实可以在每次free
释放完一块空间后立马将维护这块空间的指针置为NULL,这样下一次无意中再次对其释放就不会报错了,因为free(NULL);
是不做任何操作的。
4.6 动态开辟内存忘记释放(内存泄漏)🍖
在不考虑程序结束时操作系统自动回收内存的情况下,下面的代码就是一种内存泄漏。虽然test()
函数中我申请动态内存后也记得free
了,但就是架不住有些人在中途return
。就如下所示当flag
输入为5时,直接返回没有free
释放,并且之后再也释放不了那块空间了。因为当test
函数调用完后,原先维护那块空间的ptr
指针就释放掉了。这就是一种内存泄漏的场景。
void test()
{
int* ptr = (int*)malloc(40);
if (ptr == NULL)
{
printf("%s\n", strerror(errno));
}
//...
int flag = 0;
scanf("%d", &flag);
if (flag == 5)
return;//当flag=5时就会导致内存没有被释放
//...
free(ptr);
}
int main()
{
test();
//...
return 0;
}
下面我们再来介绍一种内存泄漏的情况,代码如下所示:
int* test()
{
int* ptr = (int*)malloc(40);
if (ptr == NULL)
{
return ptr;
}
return ptr;
}
int main()
{
test();
//忘记释放
return 0;
}
上面test
函数的功能是动态开辟一块空间,并返回这块空间的地址。但是调用test
函数的使用者容易忘记free
释放这块空间的,因为使用者会以为你搞定了,不需要他再去释放。
五、经典笔试题🍖
5.1 题目一:🍖
请问运行Test 函数会有什么样的结果?
void GetMemory(char *p)
{
p = (char *)malloc(100);
}
void Test(void)
{
char *str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
首先创建了一个指针str
且赋值为NULL,接着将其作为参数传给GetMemory
函数,用形参p
来接收,然后向内存申请一块100个byte的空间,用指针p
来维护它。该代码的内存布局如下所示:
所以让形参p
指向动态开辟内存时,GetMemory
函数外部的实参str
不会有改变,其任然是一个NULL的指针,对于之后的strcpy
操作是无意义的,因为传过去的目的地地址是NULL。当函数调用结束后,维护那块空间的指针p被释放掉了,而动态开辟的空间并没有被释放,所以造成了内存泄露问题。
5.2 题目二:🍖
注意:这里的所有例题都不考虑操作系统自动回收空间的情况。
请问运行Test 函数会有什么样的结果?
void GetMemory(char **p, int num)
{
*p = (char *)malloc(num);
}
void Test(void)
{
char *str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
首先创建了一个指针str
且赋值为NULL,接着将其地址作为参数传给GetMemory
函数,用二级指针p
来接收,然后向内存申请一块100个byte的空间,用指针*p
来维护它。该代码的内存布局如下所示:
如此执行完GetMemory(&str, 100);
后,使得指针str
维护一块大小为100byte的空间,接着对该空间进行strcpy
操作,然后打印,打印的结果为:hellow。但是这里忘记用free
释放空间了,自然导致了内存的泄漏。
5.3 题目三:🍖
请问运行Test 函数会有什么样的结果?
void Test(void)
{
char *str = (char *) malloc(100);
strcpy(str, "hello");
free(str);
//...
if(str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
可以看出当free
释放完动态开辟内存后,并没有接着对维护这块空间的指针str
进行置NULL操作,所以导致之后对于野指针str
的操作,而我们知道此时的str
指向的空间不再是属于我们了,甚至有可能已经被别的东西所占用,而你硬是要对这块空间进行操作,必然破坏了别人的内部数据,这是很严重的错误。所以,每次free释放完必须将那个指针置NULL。
这份博客👍如果对你有帮助,给博主一个免费的点赞以示鼓励欢迎各位🔎点赞👍评论收藏⭐️,谢谢!!!
如果有什么疑问或不同的见解,欢迎评论区留言欧👀。