目录
🔥个人主页:艾莉丝努力练剑
🍓专栏传送门:《C语言》
🍉学习方向:C/C++方向
⭐️人生格言:为天地立心,为生民立命,为往圣继绝学,为万世开太平
前言:前面几篇文章介绍了c语言的一些知识,包括循环、数组、函数、VS实用调试技巧、函数递归、操作符、指针、字符函数和字符串函数、C语言内存函数、数据在内存中的存储、结构体、联合和枚举等,在这篇文章中,我将开始介绍动态内存管理的一些重要知识点!以后我会介绍数据结构相关的知识,要想学好数据结构,最重要的就是学好指针、结构体和动态内存管理,所以动态内存管理非常重要,友友们要足够重视。对动态内存管理感兴趣的友友们可以在评论区一起交流学习!
一、为什么要有动态内存管理
我们现在已经掌握了一些在内存开辟空间的方式,比如:
int val = 20;//在栈空间上开辟四个字节
char arr[10] = {0};//在栈空间上开辟10个字节的连续空间
不过上述的两种内存空间开辟方式有两个特点:
1、空间开辟大小是固定的;
2、数组在申明时,必须指定数组的长度,数组空间一旦确定了大小就不能调整了。
但是对于空间的需求,不仅仅是上述的两种情况。有时候我们需要的空间大小在程序运行的时候才能知道,那么数组的编译时开辟空间的方式就不能满足了。
C语言中引入了动态内存开辟,让程序员自己就可以申请和释放空间,就相对比较灵活了。
二、malloc和free
(一)malloc
C语言提供了一个动态内存开辟的函数:
void* malloc(size_t size);
这个函数向内存申请了一块连续可用的空间,并返回指向这块空间的指针。
1、如果开辟成功,则返回一个指向开辟好空间的指针;
2、如果开辟失败,则返回一个NULL指针(空指针),因此malloc的返回值一定要做检查;
3、返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候由使用者来决定;
4、如果参数size为0,malloc的行为是标准未定义的,取决于编译器。
(二)free
C语言提供了另外一个函数free,专门是用来做内存动态的释放和回收的,其函数原型如下:
void free (void* ptr);
free函数就是用来释放动态开辟的内存的。
1、如果参数ptr指向的空间不是动态开辟的,那free函数的行为是未定义的;
2、如果参数ptr是NULL指针,则函数什么事都不做。
malloc和free都声明在头文件stdlib.h中。
举个例子,像是这样:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int num = 0;
scanf("%d", &num);
int arr[num] = { 0 };
int* ptr = NULL;
ptr = (int*)malloc(num * sizeof(int));
if (NULL != ptr)//判断ptr指针是否为空
{
int i = 0;
for (i = 0; i < num; i++)
{
*(ptr + i) = 0;
}
}
free(ptr);//释放ptr所指向的动态内存
ptr = NULL;//是否有必要?
return 0;
}
(三)应用
画图:
三、calloc和realloc
(一)calloc
C语言还提供了一个函数叫calloc,calloc函数也用来动态内存分配,以下是原型:
void* calloc(size_t num, size_t size);
1、函数的功能是为num个大小为size的元素开辟一块空间,并且把空间的每个字节初始化为0;
2、 与函数malloc的区别只在于calloc会在返回地址之前把申请的空间的每个字节初始化为全0。
我们举个例子:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p = (int*)calloc(10, sizeof(int));
if (NULL != p)
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", *(p + i));
}
}
free(p);
p = NULL;
return 0;
}
输出结果:
因此如果我们对申请的内存空间的内容要求初始化,就可以很方便的使用calloc函数完成任务。
(二)realloc
1、realloc函数的出现让动态内存管理更加灵活;
2、有时候我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,为了合理的使用内存,我们一定会对内存的大小做灵活的调整,而realloc函数就可以做到对动态开辟内存的大小的调整。
函数原型:
void* realloc(void* ptr, size_t size);
1.ptr是要调整的内存地址;
2.size调整之后有了新大小;
3.返回值为调整之后的内存起始地址;
4.这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。
注意,realloc函数在调整内存空间时存在两种情况:,分别是:
1、原有空间之后有足够大的空间;
2、原有空间之后没有足够大的空间。
我们写出这个代码,然后把两种情况用画图的形式表示出来,结合代码和图再来感受一下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
//申请一块空间,用来存放1~5的数字
int* p = (int*)malloc(5 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
//使用内存空间
int i = 0;
for (i = 0; i < 5; i++)
{
p[i] = i + 1;
}
//继续存放6~10的数字
//空间不够了,需要进行扩容
int* p2 = realloc(p, 10 * sizeof("int"));
if (p2 == NULL)
{
perror("realloc");
free(p);
p = NULL;
return 1;
}
p = p2;
//接下来可以继续使用p来维护新的空间
for (i = 5; i < 10; i++)
{
p[i] = i + 1;
}
free(p);
p = NULL;
return 0;
}
两种情况,我们把图画出来:
就像这样:
我们调试一下,看看这里属于是情况1还是情况2:
(1)x86环境下:
p和p2的地址一样,这里演示的就是情况1;
(2)x64环境:
地址是一样的。
(三)思考
1、calloc和malloc的区别
calloc也是动态申请内存的,和malloc有什么区别哩?
那我们向内存申请空间的时候用malloc还是calloc呢?
答案是:想用哪个用哪个。
1、malloc:申请空间但不想初始化;
2、calloc:申请的空间里面默认是0。
因为malloc申请空间不初始化,而calloc申请空间还要初始化,所以calloc的效率肯定要比malloc要低一些,根据其特点来决定就可以了,这里我们可以得到一个等式:
calloc == malloc + memset
//malloc是申请空间,memset是设置空间,calloc就是申请到空间之后一并设置了,calloc就等于malloc和memset两者功能的叠加。
因此我们以后在动态申请内存的时候malloc和calloc这两个随便选一个用就可以了。
2、realloc函数也可以用来直接申请空间
//realloc函数也可以用来直接申请空间
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)realloc(NULL, 40);
//...
return 0;
}
四、常见的动态内存的错误
(一)对NULL指针的解引用操作
产生问题的原因:
没有对malloc、calloc、realloc函数的返回值做判断
比如说这个代码:
int main()
{
int* p = malloc(INT_MAX);
if (p == NULL)
{
perror("malloc");
return 1;
}
int i = 0;
for (i = 0; i < 10; i++)
{
p[i] = i + 1;
}
free(p);
p = NULL;
return 0;
}
当前这里如果开辟失败了,这里p为空指针,return 1直接返回了,后面的代码就不执行了。但是一旦这里没有判断,代码写成了这个样子了:
int main()
{
int* p = malloc(INT_MAX);
int i = 0;
for (i = 0; i < 10; i++)
{
p[i] = i + 1;
}
free(p);
p = NULL;
return 0;
}
这个代码这样写就有问题了,因为如果malloc返回空指针的话,p里面是空指针(没有指向任何有效空间),p[i]就形成非法访问的可能性了。一旦出问题,这里就可能产生对空指针的解引用操作,编译器自己也会自动报一个警告:
把判断加上就不会报这个错误了。
这里能不能用assert断言一下哩?
理论上是可以的,但是这里如果想处理一下自己想要的逻辑最好还是用 if 来判断一下 ,因为assert是人家封装好的,aseert也不是什么情况都能用的,assert是在你觉得那个地方你只是想要断言一下——判断出来为空就行了——的时候用assert,但是像我们这里知道它为空还要做一些处理的这类情况,就不是用assert了,而且assert是一旦发现出问题了程序就崩溃了,如果发现出问题了你还想正常处理一下不让它崩掉,最好还是用 if 语句。
最后给大家看一下代码块:
void test()
{
int* p = (int*)malloc(INT_MAX / 4);
*p = 20;//如果p的值是NULL,就会有问题
free(p);
}
(二)对动态开辟空间的越界访问
int main()
{
int* p = (int*)malloc(5*sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
int i = 0;
for (i = 0; i < 10; i++)//i==5的时候,就越界访问了
{
p[i] = i + 1;
}
free(p);
p = NULL;
return 0;
}
即使是动态申请空间也是有范围的,这里我们只申请了5个整型的空间,下标走到“5”的时候就已经越界访问了:
只申请了5个整型的空间,就只能访问这5个空间,一旦超出就越界访问了。
(三)对非动态开辟内存使用free释放
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = arr;
//...
free(p);//free只能释放动态开辟来的空间
//非动态开辟的空间不能用free来释放
p = NULL;
return 0;
}
注意:free只能释放动态开辟来的空间,非动态开辟的空间不能用free来释放;
free只能释放malloc、calloc、realloc申请的空间。
这里我们留意一下,arr数组是进入main函数的作用域后自动创建变量,自动向内存申请空间,arr数组是栈区上的数组,栈区创建是自动创建自动回收的。像free手动回收的空间一定是手动申请的。
这里调试我们发现程序崩了,一直点关不掉,我们等它出现【未响应】 ,等它出现关闭程序这个窗口时就能关掉了:
动态内存管理的报错很奇怪,它是直接卡死了。
(四)使用free释放一块动态开辟内存的一部分
int main()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
int i = 0;
for (i = 0; i < 5; i++)
{
*p = i + 1;
p++;
}
free(p);
p = NULL;
return 0;
}
free释放空间的时候,一定要给这块空间的起始位置 。
这里一运行,程序又崩了:
这里我们怎么改就行了呢?这样改:
int main()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
int i = 0;
for (i = 0; i < 5; i++)
{
//*p = i + 1;
//p++;
*(p + i) = i + 1;
}
free(p);
p = NULL;
return 0;
}
这样就可以了。
(五)对同一块动态内存多次开辟
int maiin()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
//使用
free(p);
//...
free(p);
p = NULL;
return 0;
}
运行一下,这里系统会有“登登”一声的系统音:
改成这样就没问题了:
int main()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
//使用
free(p);
p = NULL;
//...
free(p);
p = NULL;
return 0;
}
给free函数传一个空指针,它什么事都不干。
所以我们这里为了避免这种错误重复发生,我们free完尽量给p置为空指针,这样下面再释放就不会出问题,即使重复释放也不会出问题。
这里还有一种情况是无意中二次释放:
int* test()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
//...
free(p);
return p;
}
int main()
{
int* ptr = test();
free(ptr);//二次释放了
ptr = NULL;
return 0;
}
这里确实是有可能无意中二次释放的,因为有可能没有把这个代码的来龙去脉彻底搞清楚才出现的多次释放的错误 。
(六)动态开辟内存忘记释放(内存泄漏)
void test()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
//使用
//...
}
int main()
{
test();
//...
//...
return 0;
}
注意:
谁申请的空间,谁去释放;
不使用的空间,及时释放;
自己(函数1)不方便释放的空间,要告诉别人(函数2)释放。
一般情况下:
建议malloc/free成对出现——即使成对出现也可能导致问题。
还有一种情况,代码如果在中途由于一个条件满足提前返回了,下面来不及free释放了,也会造成内存泄漏,所以即使malloc/free成对出现了,也得要free这个代码有机会执行才可以:
void test()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
int n = 0;
//使用
//...
if (n != 1)
return;
//释放
free(p);
p = NULL;
}
int main()
{
test();
//...
//...
return 0;
}
看明白了吧。
五、动态内存经典笔试题分析
我们通过四道笔试题来对动态内存有更深入的理解:
1、题(1):
问:Test函数运行会有什么样的结果?
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void GetMemory(char* p)
{
p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(str);
strcpy(str, "hello worid");
printf("str");
}
int main()
{
Test();
return 0;
}
能打印出hello world吗?结果:
程序崩溃了,因为存在对空指针的解引用操作。
程序都崩溃了,当然打印不出来hello world了。
画个图就是:
这个代码存在程序崩溃和内存泄漏 :
1、程序崩溃的原因:str传递给GetMemory函数的时候,采用的值传递形参变量p其实是str的一份拷贝,当我们把malloc申请的空间的起始地址存放在p中时,不会修改str,str依然为NULL,所以当GetMemory函数返回后,再去调用strcpy函数需要将“hello world”拷贝到str指向的空间时,程序就崩溃了;
2、内存泄漏:因为malloc申请的空间没有free释放掉,所以存在内存泄漏。
那怎么改呢?我们这样改:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void GetMemory(char** p)
{
*p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str);
strcpy(str, "hello world");
printf(str);//
//释放
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
打印一下看看结果:
还有一种改法:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
char* GetMemory(char* p)
{
p = (char*)malloc(100);
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory(str);
strcpy(str, "hello world");
printf(str);//
//释放
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
当然,这样写,str = GetMemory( str ) 这里的传参已经没什么意义了,不传也无所谓,反正是接受返回值,所以直接这样也没有问题:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
char* GetMemory()
{
char* p = (char*)malloc(100);
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory();
strcpy(str, "hello world");
printf(str);//
//释放
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
2、题(2):
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
char* GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory();
printf(str);
}
int main()
{
Test();
return 0;
}
这里就是返回栈空间的地址
我们直接打印会出来一堆随机值:
画个图:
通过str还是能够得到地址,但是得到已经没有什么用了,出了函数这块空间就还给OS(操作系统)了,有可能就被别人使用了,这个内容就可能被改掉了,我们再次通过printf打印str指向的空间的时候,结果就不一定是hello world了,有可能已经被改掉了,和前面不同:
前面的空间是malloc申请来的,malloc申请的空间只有free才能释放它,这块空间是不会销毁的 ,所以我再次通过str找到空间,我可以把hello world拷贝放进去的,但是如果是数组就不行了。
有没有什么办法呢?有的:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
char* GetMemory(void)
{
static char p[] = "hello world";
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory();
printf(str);
}
int main()
{
Test();
return 0;
}
static修饰的数组是存放在内存的静态区的
这样就可以了:
3、题(3):
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void GetMemory(char** p, int num)
{
*p = (char*)malloc(num);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
int main()
{
Test();
return 0;
}
看看这道题,能不能打印出来“hello” ?
是可以的,但是它唯一的问题就是没有释放,内存泄漏了 。
画图:
这块空间是malloc申请的。
释放了就没问题了:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void GetMemory(char** p, int num)
{
*p = (char*)malloc(num);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
//释放
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
4、题(4):
#include<stdio.h>
#include<stdlib.h>
void Test(void)
{
char* str = (char*)malloc(100);
strcpy(str, "hello");
free(str);
if (str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
int main()
{
Test();
return 0;
}
两个问题:
1、野指针问题,非法访问内存,程序崩溃;
2、结果:什么都打印不出来。
str变成了野指针,strcpy(str,"world")这里变成了非法访问内存,导致程序崩溃。
画个图:
到这儿程序就已经崩溃了,下面就不会打印了。
有人这样改:
#include<stdio.h>
#include<stdlib.h>
void Test(void)
{
char* str = (char*)malloc(100);
strcpy(str, "hello");
free(str);
if (str != NULL)
{
strcpy(str, "world");
printf(str);
str = NULL;
}
}
int main()
{
Test();
return 0;
}
这种改法不太好,前面都没判断str为不为空指针,不为空才去拷贝它,这里都拷贝完了你又去判断,逻辑上说不通。因此应该是先置为空,下面再来判断,如果为不为空,我就能使用它,为空我就不能使用,这样判断才有意义。
注意:要手动把它置为空,防止它变成野指针。
六、柔性数组
C99中,结构中的最后一个元素允许时未知大小的数据,这就叫做【柔性数组】成员,所以柔性数组一定是在结构体里面的。
总结一下:
1、首先是在结构体里面;
2、其次,是最后一个元素;
3、最后,是未知大小的数组。
struct st_type
{
int i;
int a[0];//柔性数组成员
};
如果编译器报错无法编译可以改成:
struct st_type
{
int i;
int a[];//柔性数组成员
};
(一)特点
1、结构中的柔性数组成员前面必须至少有一个其他成员;
#include<stdio.h>
#include<stdlib.h>
struct S
{
int i;
char c;
int arr[];//柔性数组成员
};
int main()
{
printf("%zu\n", sizeof(struct S));
return 0;
}
这里打印的结果是:
这里计算大小是不包含柔性数组的大小的,我们去掉char c再打印,就变成:
2、sizeof返回的这种结构大小不包括柔性数组的内存;
3、 包含柔性数组成员的结构用malloc () 函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。
多开辟出来的空间都是arr的。
(二)柔性数组的使用
1、结构体中包含柔性数组的方案
#include<stdio.h>
#include<stdlib.h>
struct S
{
int n;
int arr[];//柔性数组成员
};
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S) + 10 * sizeof(int));
if (ps == NULL)
{
perror("malloc");
return 1;
}
//直接使用这块空间
ps->n = 100;
int i = 0;
for (i = 0; i < 10; i++)
{
ps->arr[i] = i + 1;
}
//调整空间
struct S* tmp = realloc(ps, sizeof(struct S) + 20 * sizeof(int));
if (tmp == NULL)
{
perror("realloc");
return 1;
}
ps = tmp;
//继续使用空间
//释放
free(ps);
ps = NULL;
return 0;
}
熟悉前面知识的友友们可能能想到另一种写法:
2、结构体中包含了一个指针的方案
#include<stdio.h>
#include<stdlib.h>
struct S
{
int n;
int* arr;
};
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S));
if (ps == NULL)
{
perror("malloc");
return 1;
}
ps->n = 100;
int* ptr = (int*)malloc(10 * sizeof(int));
if (ptr == NULL)
{
perror("malloc2");
return 1;
}
ps->arr = ptr;
//存放1~10
int i = 0;
for (i = 0; i < 10; i++)
{
ps->arr[i] = i + 1;
}
//空间不够,扩容
ptr = realloc(ps->arr, 20 * sizeof(int));
if (ptr == NULL)
{
perror("realloc");
return 1;
}
ps->arr = ptr;
//可以使用更多的空间了
// ......
//释放
free(ps->arr);
free(ps);
ps = NULL;
return 0;
}
这两种方案其实是柔性数组这个方案更好一点,第二种方案是malloc几次就要free几次,malloc次数越多,free次数也就越多,出错的可能就越高。
malloc次数越多,内存碎片就越多,内存碎片在以后开辟空间的时候很难利用上的,所以:
malloc次数越多,内存碎片越多,内存利用率越低。
3、柔性数组的好处
(1)方便内存释放;
(2)有利于访问速度:连续的内存有利于提高访问速度,也有利于减少内存碎片(也没多高,毕竟跑不了你要用做偏移量的加法来寻址)。
这里推荐一位已故的前辈大佬陈皓的文章 :
向大佬致敬!!!
七、总结C/C++中程序内存区域划分
C/C++程序内存分配的几个区域:
1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内 存容量有限。栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
2. 堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS(操作系统) 回收。分配方式类似于链表。
3. 数据段(静态区):(static)存放全局变量、静态数据。程序结束后由系统释放。
4. 代码段:存放函数体(类成员函数和全局函数)的二进制代码。
能给局部变量分配的空间是有限的。
这里都只是简单了解一下,以后会详细讲解。
void fun(int* p)
{
}
void test()
{
int n = 10;
fun(&n);
printf("hehe\n");
//...
}
int main()
{
test();
return 0;
}
test函数暂时执行到fun(&n);就不动了,先去执行fun,所以这个时候test函数压根没结束,n就不会销毁,这里这个n要到这个test函数走完才销毁,所以这里和传值和传址没有关系的。
结尾
往期回顾:
【详解自定义类型:联合和枚举】:联合体类型的声明、特点、大小的计算,枚举类型的声明、优点和使用
【自定义类型:结构体】:类型声明、结构体变量的创建与初始化、内存对齐、传参、位段
结语:本篇文章就到此结束了,本文为友友们分享了动态内存管理相关的一些重要知识点,如果友友们有补充的话欢迎在评论区留言,下一篇博客,我们将介绍文件操作的内容,敬请期待,感谢友友们的关注与支持!