前言
自从前两次博客写完以后,感觉对于我本人来说,收获很大,尤其是将学过的知识再度温习一遍,感觉基础扎实了很多,所以就养成了一个习惯,每学完一个模块,都会写一篇博客,不仅仅是写个我自己的,也是想通过这篇博客,与大家分享一些我的见解。本来周四就学完了动态内存分配,但是周末博主去玩了,于是忘记了写博客,现在加班奉上。
一、为什么存在动态内存分配
1、已掌握的内存开辟方式
在C语言中,我们将内存分为了4个区间: 代码区,全局变量与静态变量区,局部变量区即栈区,动态存储区,即堆(heap)区或自由存储区(free store)。
为了方便大家理解,图解如下: 通过之前的学习,我们了解了一些内存的使用方法:
(1)创建一个变量 当我们想要使用单一变量的时候,我们可以通过创建一个变量,来使用内存。
int a = 10;
int g_a = 10;
(2)创建一个数组 当我们需要使用多个相同类型变量的时候,我们可以通过创建一个数组,来使用内存。
int arr[10];
int g_arr[10];
2、上述开辟空间方式的特点
以上两种使用内存的方式是我们学过的,也是常用的,但是在某些情况下,仅仅有这两种方法是不足的。
例如:我们需要创建一个数组来存放一个班级的学生信息的时候。
我们在创建这个arr数组的时候,当我们直接给定数组的长度arr[50]的时候,这样是很简单,但是这样合理吗? 例1:
#include<stdio.h>
struct s
{
char name[20];
int age;
};
int main()
{
struct s arr[50];
return 0;
}
假设这个班级只有30个人,那么我们是不是就浪费了一部分的空间;假设这个班级有60个人,那么我们给定的50又不够。所以说这里给定多少都是不合理的。
这里有人又会说了,很简单啊:要多少给多少就好了嘛!就像这样 例2:
#include<stdio.h>
struct s
{
char name[20];
int age;
};
int main()
{
int n = 0;
scanf_s("%d", &n);
struct s arr[n];
return 0;
}
运行结果为:报错
事实证明,我们的想法很美妙,但是现实却很残酷: 这里的错误名称叫:表达式必须含有常量值; 说明对于 struct s arr[n]; 这里的n是变量,那就不行了。
这里延伸一下:例2这种代码的写法叫做变长数组。 对于变长数组这种写法目前仅对于C99是可运行通过的。
总结:上述开辟空间方式的特点 (1)开辟空间的大小是固定的; (2)数组在声明的时候,必须制定数组的长度,它所需的内存在编译时分配。
3、为什么存在动态内存分配
我们对于内存开辟空间的需求,不仅仅局限于这些方式,有时候我们需要的空间大小在程序运行的时候才能知道,这时上述方式就不能达成目的了,所以动态内存分配就应运而生了。
二、动态内存函数的介绍
1、malloc
C语言提供了一个动态内存开辟的函数:
void* malloc (size_t size);
malloc的全称是memory allocation,中文叫动态内存分配,用于申请一块连续的指定大小的内存块区域以void*类型返回分配的内存区域地址,当无法知道内存具体位置的时候,想要绑定真正的内存空间,就需要用到动态的分配内存,且分配的大小就是程序要求的大小。
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。 (1)如果开辟成功,则返回一个指向开辟好空间的指针; (2)如果开辟失败,则返回一个NULL指针,因此 malloc 的返回值一定要做检查; (3)返回值的类型是 void*,所以 malloc 函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定; (4)如果参数 size 为 0 ,malloc 的行为是标准是未定义的,取决于编译器。
举一个例子 例3:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
printf("%s\n", strerror(errno));
}
else
{
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", *(p + i));
}
}
return 0;
}
运行结果为: 0 1 2 3 4 5 6 7 8 9
在例3中,我们如果采用 “ int* p = malloc(10 * sizeof(int)); ” 的方式来开辟空间,在大部分检测严格的编译器中,会报错,这是因为变量类型的不同,从这里我们也可以看出,malloc 开辟空间的返回值是 void* 类型;
上面我们也提到了:如果 malloc 开辟失败,则返回一个NULL指针,所以malloc 的返回值一定要做检查,所以我们用了一种特殊的方式来打印错误原因——“ printf("%s\n", strerror(errno)); ” ,这样如果开辟失败,编译器就不会报错了,而是在运行后将错误的原因打印出来。
易错提示: 因为我们计算机的内存也是有限的,所以我们不能为所欲为的开辟空间,当我们需要开辟的空间不够时,打印错误就会出现“Not enough space”。
2、free
紧接上文,我们不能为所欲为的开辟空间,因为空间是有限的,所以应当有借有还,我们在前边向系统借用了这么多内存,当我们用完以后,我们应该把这块内存还给系统,那么怎么还呢?这里就需要用到我们的 free 函数了。
C语言为我们提供了另外一个函数,专门用来做动态内存的释放和回收的:
void free(void *ptr)
free函数用来释放动态开辟的内存: (1)如果参数 ptr 指向的空间不是动态开辟的,那 free 函数的行为是未定义的; (2)如果参数 ptr 是NULL指针,则函数什么操作都不进行。
先来看一个例子 例4:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int* p = (int*)malloc(10 * sizeof(int));
if (p == NULL)
{
printf("%s\n", strerror(errno));
}
else
{
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
for (i = 0; i < 10; i++)
{
printf("%d ", *(p + i));
}
}
free(p);
p = NULL;
return 0;
}
运行结果为: 0 1 2 3 4 5 6 7 8 9
对比例3和例4,例4只是多了两行代码: free( p ); p = NULL;
有人会疑惑了,例3和例4的运行结果明明是一样的啊,那为什么我们还要多此一举,加上这两行代码呢? 没错看上去运行结果是一样的,但这仅仅只是对于我们代码量很少的情况下,我们申请的内存够用了,所以目的达到了;但是假设我们要做一项任务量巨大的工程的时候,我们只借不还,系统的内存在不断减少,我们还能继续写程序吗?所以应该从现在养成一个习惯,申请的内存,用完以后一定要进行 free()操作。
这里有人又有疑问了,那我们用完了内存,释放了不就好了吗?为什么还要把这个指针p置为空指针呢? 其实当我们free(p)操作结束以后,这块空间是释放了,但是p的值并没有改变,如果有人找到了这个p,进行了破坏,我们的程序就有可能出问题,所以我们不妨主动将p置为空指针,让有非分之想的人断绝这些念想。
光说不练,是学习编程语言的大忌,我们趁热打铁,来做一道练习题: 正确答案为: 例5:
#include "string.h"
#include <stdio.h>
#include<stdlib.h>
int main()
{
char* src="hello,world";
char* dest=NULL;
int len=strlen(src);
dest=(char*)malloc(len+1);
char* d=dest;
char* s=src+len-1;
while(len--!=0){
*(d++)=*(s--);
*d ='\0';
}
printf("%s",dest);
free(dest);
dest = NULL;
return 0;
}
3、calloc
C语言还提供了一个函数叫 calloc ,calloc 函数也用来动态内存分配:
void* calloc(size_t num,size_t size)
(1)函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把这块空间的每个字节初始化为0; (2)与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0.
举个例子: 例6:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int* p = (int*)calloc(10 , sizeof(int));
if (p == NULL)
{
printf("%s\n", strerror(errno));
}
else
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", *(p + i));
}
}
free(p);
p = NULL;
return 0;
}
运行结果为: 0 0 0 0 0 0 0 0 0 0
由此可见:calloc 函数会将动态开辟空间的每个字节初始化为0
4、realloc
回归今天的核心问题,如果我们在使用内存的过程中需要对内存的大小进行调整怎么办呢?
C语言同样为我们提供了一个函数叫 realloc ,realloc 函数可以让动态内存管理更加灵活:
void* realloc(void* ptr, size_t size);
(1)ptr 是要调整的内存地址; (2)size 是调整后的新大小; (3)返回值为调整之后的内存起始位置;
举个例子: 例7:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(20);
for (int i = 0; i < 5; i++)
{
*(p + i) = i;
}
for (int i = 0; i < 5; i++)
{
printf("%d ", *(p + i));
}
int* p2 = (int*)realloc(p, 40);
for (int j = 5; j < 10; j++)
{
*(p + j) = j;
}
for (int j = 5; j < 10; j++)
{
printf("%d ", *(p + j));
}
free(p);
p = NULL;
return 0;
}
运行结果为: 0 1 2 3 4 5 6 7 8 9
(4)这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到 新 的空间;
(5)realloc 在调整内存空间的过程中存在两种情况 ①原有空间之后有足够大的空间 此时,直接在原有内存之后追加空间,原来空间的数据不发生变化。
②原有空间之后没有足够大的空间 在堆空间上另找一个合适大小的连续空间来使用,这样函数返回的是一个新的内存地址。
图解如下:
三、常见的动态内存错误
1、对NULL指针的解引用操作
例8:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(40);
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
free(p);
p = NULL;
return 0;
}
对于例8,如果 malloc 开辟空间失败,此时 p 被赋值为NULL,而下面对于空指针进行操作, *(p + i) 始终为非法地址,我们的操作始终为非法操作,所以我们一定要在使用前记得判断p是否为空。
2、对动态开辟内存的越界访问
例9:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if (p == NULL)
{
return 0;
}
int i = 0;
for (i = 0; i <= 10; i++)
{
*(p + i) = i;
}
free(p);
p = NULL;
return 0;
}
对于例9,我们使用 malloc 向系统申请了 10个int 类型,但是我们在后边访问了 11个int 类型,运行程序的时候就会出现假死的情况,虽然是动态内存,但是也是有边界的,一但越界访问,程序就会出现问题。
3、对非动态开辟内存使用free释放
例10:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int a = 10;
int* p = &a;
free(p);
p = NULL;
return 0;
}
对于例10,a的空间是存放与栈区的,它并不是动态开辟的空间,free函数释放的一定是堆区上开辟的空间,如果对非动态开辟内存使用free释放,程序就会出现假死的情况。
4、使用free释放动态开辟内存的一部分
例11:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if (p == NULL)
{
return 0;
}
int i = 0;
for (i = 0; i < 10; i++)
{
*p++ = i;
}
free(p);
p = NULL;
return 0;
}
对于例11,我们有这样一个操作 “*p++ = i;” ,当这个操作结束的时候,我们的指针p指向的空间已经不是我们动态开辟的完整空间了,不仅仅局限指向末尾,只要这里的p不再指向空间的初始位置,都会导致程序的崩溃。
5、对同一块动态内存的多次释放
例12:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if (p == NULL)
{
return 0;
}
free(p);
free(p);
return 0;
}
对于例12,我们在使用完空间后,释放了空间,在很多行代码过后,又释放了一次空间,这样程序同样会假死,那么我们如何改进呢? 例13:
#include<stdio.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(40);
if (p == NULL)
{
return 0;
}
free(p);
p = NULL;
free(p);
return 0;
}
像例13这样,每次释放完空间,主动将p置为空指针,这样就可以有效避免了上述情况,因为我们之前提到过: 对于free函数:如果参数 ptr 是NULL指针,则free函数什么操作都不进行。
6、动态开辟内存忘记释放(内存泄漏)
例14:
#include<stdio.h>
#include<stdlib.h>
int main()
{
while (1)
{
malloc(1);
}
return 0;
}
对于例14,当我们开辟内存忘记释放的时候,就会造成内存泄漏。我们的电脑可能就会出现死机的情况,遇到这种情况我们一般都会重启,但是当我们写程序达到几万行的时候,出现了这种问题,那将是一个十分恐怖的事情。
四、几个经典的笔试题
1、题目1
void GetMemory(char* p)
{
p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
请问:运行 Test 函数会有什么样的结果? 答案为:程序崩溃
对于本题,很多人的注意力会集中于 “printf(str);” ,实际上这里并没有问题,它等价于 “printf("%s\n",str);” 。
解析代码: 看到 “GetMemory(str);” ,我们在这里传递的是 str 本身的值,而不是 str 的地址,进入 GetMemory 函数以后,我们在堆上开辟了100个空间,我们将这些空间放置在 p 中,这里的 p 作为一个形参变量,在 GetMemory 函数结束以后,这个 p 就销毁了, 实际上 str 仍然是NULL,而接下来我们想要将 “hello world” copy 到 str 中去,但是 str 作为NULL,它并没有指向一个有效的空间,进行操作的时候,无法避免的进行了非法访问,虽然后边的 printf 操作没有问题,但是程序在 strcpy 操作时就已经崩溃了。
总结: (1)运行代码程序会出现崩溃现象; (2)程序存在内存泄漏问题: str 以值传递的形式给 p p 是 GetMemory 函数的形参,只在函数内有效 等 GetMemory 函数返回之后,动态开辟内存尚未释放 并且无法找到,所以会造成内存泄漏
2、题目2
“返回栈空间地址问题”
char* GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char* str = NULL;
str = GetMemory();
printf(str);
}
请问:运行 Test 函数会有什么样的结果? 答案为:随机值(或者崩溃)
解析代码: 看到 “str = GetMemory();” ,进入 GetMemory 函数的时候,p[] 这个数组是GetMemory 函数内的形参,它申请了一个空间,这个空间只在 GetMemory 函数内存在,在 GetMemory 函数结束的时候,的确将 p 的地址返回了,放置在 str 中,但是当 GetMemory 调用完成之后,p 这个数组开辟的空间返还给操作系统了,这个空间里存放的值,我们是不清楚的,接下来 “printf(str);” 打印出来的值我们不清楚,所以结果为随机值。
3、题目3
void* GetMemory(char** p, int num)
{
*p = (char*)malloc(num);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
请问:运行 Test 函数会有什么样的结果? 答案为: (1)输出hello (2)但是有内存泄漏
解析代码: 看到 “GetMemory(&str, 100);” ,将 str的地址传入 GetMemory 函数,用二级指针p 来接收,那么 *p 指向的地址即为 str ,然后将 “hello” copy 到 str 当中,再打印出来,这些操作都没有问题,但是当我们使用完 str 以后,忘记释放动态开辟的内存,导致了内存泄漏。
4、题目4
void Test(void)
{
char* str = (char*)malloc(100);
strcpy(str, "hello");
free(str);
if (str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
请问:运行 Test 函数会有什么样的结果? 答案为: (1)world (2)非法访问内存(篡改动态内存区的内容,后果难以预测,非常危险)
解析代码: 首先,我们向系统申请了100个字节,地址存放在 str 中;然后,我们把 “hello” copy 到 str 当中去;接下来,我们释放了这块空间,之后, str 指向的这块空间已经还给操作系统了;然后,进行判断: str 是否为空指针,虽然之前我们对申请的动态内存进行了释放,但是 str 的值并没有改变,仍然是 “hello”,所以它不为空指针;进入if语句后,将 “world” copy 到 str 当中,world 就把 hello 给覆盖了;所以打印 str 以后结果为 world。
虽然打印了world,但是这个程序依然出了问题,对于 “free(str);” 操作:已经把空间释放掉了,这表明这块空间已经不属于我们了,我们已经不能再使用这块空间了,但是接下来我们还将 world 放进去,并且打印,这就属于非法访问内存了。
提示:free(p)和p=NULL一定要连贯使用!
总结
关于动态内存分配的讲解就到此结束了,动态内存分配其实并不困难,更多的还是一些概念的背诵,只要我们牢记这些易错点,拿捏起来,还是轻轻松松的!加油,冲冲冲! ps:动态内存分配拖了蛮久的,关于文件的博客,博主会快马加鞭的肝的(doge)
|