这篇文章,我们一起来学习C语言中的动态内存管理!!!
1.为什么存在动态内存分配
我们先来想一下,我们现在掌握的开辟内存的方式是什么:
是不是就是直接创建一个变量或者数组,然后操作系统给我们分配空间:
代码语言:javascript
复制
int main()
{
int val = 20;//在栈空间上开辟4个字节
int arr[10] = { 0 };//在栈空间上开辟40个字节的连续空间
return 0;
}
大家思考一下这样的方式有没有什么弊端:
我们这样定义一个数组int arr[10]
,开辟的空间大小是固定的。int arr[10]
就只能存的下10个整型,我们想多存一个都不行。 我们想存11个整型,用int arr[10]
这个数组就不行了,除非我们再定义一个数组。 其次:数组在声明的时候,需要指定数组的长度,它所需要的内存在编译时分配。 但是,对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道。 那这时候,这样开辟空间的方式就不行了。 这个时候就需要动态开辟内存空间了。
那怎么实现动开辟内存呢?
C语言给提供了一些函数使得我们可以实现对内存的动态开辟。
2.动态内存函数的介绍
接下来我们就来一起学习一下这些函数:
2.1 malloc
看一下它的参数:
void* malloc (size_t size);
那它是用来干嘛的呢?
接下来再来给大家详细解释一下:
- 参数
size_t size
接收我们想要开辟的内存空间的大小,单位是字节,返回指向该内存块开头的指针。
代码语言:javascript
复制
int main()
{
void* p = malloc(40);
return 0;
}
- 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
malloc
给我们返回的指针类型是void*
,但我们知道void*
是不能直接解引用的,注意使用时要先转换为我们需要的指针类型。 比如我们想再申请的空间里放整数,就应该这样搞:int* p = (int*)malloc(40);
然后,我们就可以往里面放整型数据了。 当然,你想用来放其他数据,就转换成其它相应的类型。
- 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
当然用malloc
开辟空间也有可能开辟失败,当请求失败的时候,它将会返回空指针(NULL)。 我们知道空指针是不能直接解引用的。 所以,对于malloc的返回值,使用之前,我们一定要检查一下。 如果为空,那就是失败了,就不能使用了。 那什么时候又可能失败呢,比如当我们开辟的空间特别大的时候,就有可能失败返回空指针。 如果开辟失败我们可以做一个相应处理,打印一下错误信息,然后return一下,让程序结束。
代码语言:javascript
复制
int* p = (int*)malloc(40);
if (p == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}
函数strerror
我们在之前的文章里介绍过。
当然我们也可以断言一下:
assert(p);
如果不为空,那就是开辟成功了。
开辟成功,我们就可以使用了。 举个例子,我们现在就在上面开辟好的P指向的40字节的空间里放一些整型数据。
代码语言:javascript
复制
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
40个字节,我们可以放10个整型,0到9。 我们也可以通过内存观察一下:
使用前:
这里再给大家提一点: 我们发现开辟好的空间里面放的这些其实是一些随机值
这也是malloc的一个特性:
- 新分配的内存块的内容不做初始化,仅保留不确定的值。
使用后:
我们初始化之后,里面放的就是0到9了。
- 如果参数
size_t size
为0,则返回值取决于特定的库实现(它可能是也可能不是空指针),但返回的指针不应被解引用。 此时malloc的行为是标准是未定义的,取决于编译器。
所以我们尽量不要这样试,况且这样做也没什么意义,申请一个大小为0的空间?
那申请的空间使用完之后,我们是不是什么都不用管了呢?
不是的,对于像malloc这些函数动态开辟的内存,使用完之后我们是需要将这些空间释放掉的,不及时释放,有可能会造成内存泄漏。
那怎么释放呢?
2.2 free
C语言提供了另外一个函数free,专门是用来做动态内存的释放和回收的。
接下来我们就来一起学习一下函数free:
它的参数是这样的:
怎么用呢?
- 参数
void* ptr
接收一个指针,这个指针指向我们使用malloc这些动态开辟内存函数分配的内存块,无返回值。
比如,上面例子中的指针P:
代码语言:javascript
复制
int* p = (int*)malloc(20);
/*if (p == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}*/
assert(p);
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
在上述循环的过程中,p 的指向并没有发生改变,还是指向分配的内存块的起始地址,所以我们就可以这样做:
代码语言:javascript
复制
free(p);
这样,就把malloc
申请的空间释放掉了。
那释放掉之后,是不是就万事大吉了呢?
不,我们还应该做一件事情: 把p
置空
代码语言:javascript
复制
p = NULL;
为什么要这样做呢?
大家想一下,我们现在虽然已经把p
指向的那块空间给释放掉了。 但是,p是不是还保存着那块空间的地址啊。 那么一个指针指向了一块被释放掉的空间,那它是不是一个典型的野指针啊。 要知道如果对一个野指针解引用那程序就会出错的。
- 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
也就是说参数 ptr 指向的空间必须是动态开辟的。 如果指向其它的空间,那么free函数会怎么处理是标准未定义的。
比如:
代码语言:javascript
复制
int main()
{
int num = 10;
int* p = #
free(p);
p = NULL;
return 0;
}
你写一个这样的代码,肯定是不行的,因为p指向的空间不是动态开辟的。 这里的num是一个局部变量,要知道局部变量是保存在栈区的,再来复习一下:
而我们这些动态开辟的内存,是堆区分配的。
- 如果参数 ptr 是NULL指针,则函数不执行任何操作。 像这样:
代码语言:javascript
复制
int* p = NULL;
free(p);
函数不执行任何操作。
2.3 calloc
C语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。
我们一起来学习一下:
函数calloc 有两个参数,无返回值,那它的作用是什么呢?这两个参数分别接收什么呢?
- 函数的功能是为 num 个大小为 size 的元素开辟一块空间,同样返回指向该内存块开头的指针,类型为(void*)
参数size_t num
接收我们想要分配空间的元素个数;size_t size
接收每个元素的大小,单位为字节。
那我们就可以这样用:
代码语言:javascript
复制
int main()
{
int* p = (int*)calloc(10,sizeof(int));
/*if (p == NULL)
{
printf("%s\n", strerror(errno));
return 1;
}*/
assert(p);
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
free(p);
p = NULL;
return 0;
}
当然calloc分配的空间使用完也应该使用free释放并将指向空间起始地址的指针置空。
- 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
对于malloc 来说,它不会对开辟好的空间初始化,里面放的是随机值。 但是,calloc 会把申请的空间的每个字节都初始化为0。
就拿上面那段代码,我们来调式看一下:
- 和malloc 一样,calloc 函数如果开辟内存块失败,则返回空指针void*。
所以对于calloc 的返回值,我们也有必要做一下检查,判断是否为空指针。
- 和malloc一样,如果参数size_t size为0,则返回值取决于特定的库实现(它可能是也可能不是空指针),但返回的指针不应被解引用。 标准未定义的,取决于编译器。
总的来说,malloc和calloc 区别不大:
1. calloc 会在返回地址之前把申请的空间的每个字节初始化为全0,而malloc不会,里面放的是随机值。 2. 它们的参数不同。
2.3 realloc
realloc函数的出现让动态内存管理更加灵活。
有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的使用内存,我们就要对开辟的内存的大小做出灵活的调整。 那 realloc 函数就可以做到对动态开辟的内存大小进行灵活的调整。
一起来学习一下:
两个参数分别接收什么呢?
void* ptr
接收一个指针,该指针指向我们想要调整大小的内存块,当然这块内存块也应该是我们之前动态开辟的空间。size_t size
接收我们想要为内存块调整的新大小,以字节为单位。
返回值又是什么呢?
返回指向重新分配的内存块的指针
举个例子吧,我们再来看一段上面的代码:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(40);
assert(p);
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
free(p);
p = NULL;
return 0;
}
还是这段代码:
我们使用malloc申请了40个字节空间,放了10个整型。 那假设我们现在想再放10个整型,那原来的空间就不够用了,那我们现在就可以使用realloc 进行扩容。
怎么搞呢?这样写:
代码语言:javascript
复制
int* p = (int*)malloc(40);
assert(p);
int i = 0;
for (i = 0; i < 10; i++)
{
*(p + i) = i;
}
int* ptr = (int*)realloc(p, 80);
if (ptr != NULL)
{
p = ptr;
ptr = NULL;
}
//使用
free(p);
p = NULL;
变成这样,我们再中间又加了一些代码。
我们看到上面代码中我们扩容后返回的指针赋给指针变量ptr ,那为什么不直接给p呢? 因为,realloc开辟空间也有可能会失败的,它失败同样返回空指针。 所以我们先赋给ptr ,然后判断一下,不为空,再赋给p,让p继续管理扩容后的空间。 然后,不使用ptr ,最好将其也置空。
然后,没什么问题,我们就可以使用扩容后的空间了。
但是,在扩容的时候,又存在存在两种情况:
- 原地扩 什么时候是原地扩呢?
就还拿刚才的例子来说: int* ptr = (int*)realloc(p, 80);
p原来指向的空间是40个字节,现在我们想要使用realloc将p指向的空间扩容为80个字节。 那这时realloc就会从原空间向后看,如果后面有足够大的空间能够再增加40个字节,那么realloc就会在原地向后扩容40个字节,使得p指向的空间变为80字节。
当然这样realloc返回的地址还是原来p指向的地址。
- 异地扩 那什么时候异地扩呢?
假设现在还是相把p指向的空间扩容为80个字节。 但是,原空间后面没有足够大的空间,那这时候怎么办? 这时候: realloc会在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址,不再指向原空间。 而且: realloc会将原空间的数据拷贝到新空间,并会将旧空间释放掉。然后返回指向该内存块起始地址的指针。
还要一点需要注意:
我们要知道realloc的第一个参数void* ptr 也可以接收一个空指针,当它接收的是空指针的时候,就相当于malloc了。 此时它就开辟size个字节的空间,并返回指向该空间起始地址的指针。
比如:
代码语言:javascript
复制
int* p = (int*)realloc(NULL, 40);
那这句代码就相当于:
代码语言:javascript
复制
int* p = (int*)malloc(40);
以上就是对这4个动态内存函数的介绍,它们包含的头文件都是#include <stdlib.h>
。
3. 常见的动态内存错误
在进行动态内存管理时,有很多需要注意的,一旦我们使用不当,就有可能会导致错误的发生。
接下来我们就来总结一下,哪些操作可能会引发动态内存错误。
3.1 对NULL指针的解引用操作
通过上面的学习我们已经知道了,malloc,realloc,calloc
在开辟空间时,一旦开辟失败,就会返回空指针,如果我们不小心对这些空指针进行了解引用,就会导致错误的发生。
举个例子:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(4);
*p = 20;//如果p的值是NULL,就会有问题
free(p);
p = NULL;
return 0;
}
大家看这段代码有问题吗?
因为malloc有可能返回空指针,所以像上面这样不做判断,直接对malloc返回的指针,解引用,就可能会导致问题出现。 我们写出这样的代码,有的编译器可能就直接会报警告:
不过上面的代码中我们申请的空间比较小,只有4个字节,可能不会申请失败。
如果我们要申请一块特别大的空间,很有可能就会开辟失败:
我们来试一下:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(INT_MAX);
*p = 20;//如果p的值是NULL,就会有问题
free(p);
p = NULL;
return 0;
}
开辟INT_MAX字节的空间,INT_MAX是整型的最大值:
这次,很有可能就会失败,所以我们最后加一个判断:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(INT_MAX);
if (p == NULL)
{
perror("malloc");
return 1;
}
else
{
*p = 20;
}
free(p);
p = NULL;
return 0;
}
这里的perror也是一个打印错误信息的函数(和strerror差不多),不过它可以在前面加上我们自定义的信息。 我们看结果是什么:
说明,这里malloc就开辟失败了,返回的是空指针。
所以,对于malloc,realloc,calloc
的返回值,我们一定要进行一个检查,防止对空指针解引用。
3.2 对动态开辟空间的越界访问
对动态开辟空间的越界访问,也会发生错误。 举个例子:
代码语言:javascript
复制
int main()
{
int i = 0;
int* p = (int*)malloc(5 * sizeof(int));
assert(p);//断言,防止p为空指针
for (i = 0; i <= 10; i++)
{
*(p + i) = i;//越界访问
printf("%d ", *p);
}
free(p);
p = NULL;
return 0;
}
上面的代码中,我们使用malloc开辟了5个整型大小的空间,即20个字节,那p 作为1个整型指针,加1跳过4个字节,那我们循环10次,是不是就越界访问了啊。 这时我们运行就出错了:
因为我们越界访问了。 所以,也注意不能越界访问。
3.3 对非动态开辟内存使用free释放
这个我们在上面其实也已经提到过了。
我们要知道,free是用来释放动态开辟的内存空间的, 如果我们用free去释放非动态开辟的内存,此时free的行为是标准未定义的。
比如:
代码语言:javascript
复制
int main()
{
int num = 10;
int* p = #
free(p);
p = NULL;
return 0;
}
num 是我们定义的局部变量,是保存在栈区的,而堆区才是用来动态内存分配的。 这样的代码运行,可能是会出错的。
所以我们不要用free去释放非动态开辟的内存。
3.4 使用free释放一块动态开辟内存的一部分
什么意思呢?
我们在使用free释放一块动态开辟的内存空间的时候,传给free那个指针必须是指向这块空间的起始位置。 如果在使用过程中,原本指向内存块起始位置的指针发生了改变,不再指向该空间的起始位置,那我们最后用free去释放这块空间的时候,就不能再传这个指针了。
举个例子:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(10);
assert(p);
p++;
free(p);//p不再指向动态内存的起始位置
p = NULL;
return 0;
}
这样的操作就是错误的,我们free(p)
的时候,p 已经不再指向这块动态内存的起始位置了。 运行这样的代码,程序就出错了。
如果确实需要改变:
我们可以再创建一个指针变量,保存一下最初指向起始地址的指针,这样最后释放的时候,我们依然能找到起始位置的地址。
像这样:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(10);
assert(p);
int* ptr = p;//用ptr保存malloc开辟空间的起始位置
p++;
free(ptr);//释放的时候传ptr
ptr = NULL;
p = NULL;
return 0;
}
这样,程序就不会出错了。
3.5 对同一块动态内存多次释放
什么意思呢?
我们动态开辟一块内存空间,使用完直接释放了,并且没有将指向该内存块起始位置的指针(假设是指针p)置空,过了一会儿可能忘记已经释放过了,然后再后面又把p传给free,又对这块空间进行一次释放。 这样的话我们运行代码就也会出错的。
像这样:
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(100);
free(p);
///.....;
free(p);//重复释放
return 0;
}
这样程序是会崩掉的。
为了避免这种情况发生:
我们在释放掉p指向的空间之后,要及时将p置空。
代码语言:javascript
复制
int main()
{
int* p = (int*)malloc(100);
free(p);
p = NULL;
///.....;
free(p);//重复释放
return 0;
}
这样,虽然我们释放了两次,但因为我们第二次传的是空指针,所以不会有问题。 因为如果free的参数 ptr 接收的是NULL指针,不执行任何操作。
所以:
在使用free释放一块动态内存空间后,及时将指向起始位置的指针置空是一个好习惯。
3.6 动态开辟内存忘记释放(内存泄漏)
就是我们动态开辟的空间在使用完之后,一定要记得释放,不释放的话有可能会造成内存泄漏。
切记:
动态开辟的空间一定要释放,并且正确释放 。
4.经典笔试题讲解
下面,我们一起来看几个动态内存管理相关的经典笔试题。
4.1 题目1
我们来看这段代码:
代码语言:javascript
复制
#include <stdio.h>
void GetMemory(char* p)
{
p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
int main()
{
Test();
return 0;
}
请问运行 Test 函数会有什么样的结果?
经过上面的学习,我们相信大家应该很容易能够看出来,上面这段代码存在一些比较严重的问题。
我们一起来分析一下:
首先,test函数进去,定义了一个字符指针char* str
,给它赋了一个空指针NULL,然后,调用GetMemory
函数,实参传的是str
,GetMemory
函数用了一个字符指针p
接收,在GetMemory
内部,用malloc
动态开辟了100个字节的空间,返回值强制类型转换为char*
赋给了p
。
走到这一步我们其实就能发现一个小问题:
这里没有对malloc
进行一个判断或者断言,因为malloc
有可能开辟空间失败返回空指针。 当然这还不算这段代码中最严重的问题。
那我们接着往下看:
GetMemory(str);
调用结束,下一句代码是:strcpy(str, "hello world");
看到这里我们应该能猜出来这段代码的目的: 应该是想把字符串"hello world"
拷贝到函数GetMemory(str)
中动态开辟的那100个字节空间里,然后打印出来。
那到这里第二个问题就出来了。
strcpy(str, "hello world")
既然是想把"hello world"
拷贝到函数GetMemory(str)
中动态开辟的那100个字节空间里,那第一个参数str
是不是应该指向malloc
开辟的那100个字节才对啊。 但是,上面代码里面传参传的是指针变量str
,形参p
实际只是str
的一个临时拷贝。我们把malloc
的返回值赋给了p
,让p
指向了这100个字节空间的首地址,但是str
是不是并没有改变啊,Test 函数中的 str 一直都是 NULL。 而strcpy
在拷贝是应该是会对str
解引用的,这样会导致程序崩溃的!!!
还有一个问题:
malloc
开辟的空间使用完是需要使用free
释放的,但是上述代码并没有释放,这样就可能导致内存泄漏,因此,这也是一个比较严重的错误。
那接下来我们就来修改一下这段代码,将它变成正确的:
代码语言:javascript
复制
#include <stdlib.h>
#include <string.h>
void GetMemory(char** p)
{
*p = (char*)malloc(100);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str);//不传地址,将p作为返回值赋给str也可以
strcpy(str, "hello world");
printf(str);
free(str);
str = NULL;
}
int main()
{
Test();
return 0;
}
这样代码就正确了:
4.2 题目2(返回栈空间地址的问题)
代码语言:javascript
复制
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还是一个char*
的指针,给它赋值为NULL,然后调用GetMemory()
,GetMemory()
内部创建了一个字符数组p
,放了一个字符串"hello world"
,p
是数组名,是首字符’h’的地址,将p作为返回值赋给str,那我们是不是就可以通过str访问数组p了,printf(str)
就把"hello world"
打印出来了。
是这样吗? 如果这样想,那就错了。
为什么呢? 数组p是我们在函数内部创建的一个局部的数组,当函数调用结束就被销毁了,数组所在的这块空间就还给操作系统了,那这时候我们再去打印这块空间里的内容,是不是就非法访问内存了。 这样也是不行的。
4.3 题目3
代码语言:javascript
复制
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 mani()
{
Test();
return 0;
}
再来看这段代码,有什么问题:
这段代码前面都没有什么大问题,当然这里还是没有对malloc
进行是否为空指针的判断。 传的是str
的地址,GetMemory
调用结束后,str
指向的就是malloc
开辟的那100字节的空间,那strcpy(str, "hello");
就能够成功把字符串"hello"拷贝到str
指向的空间,然后打印出来,这都没什么问题。 但是: 是不是没有对malloc
开辟的空间进行释放,还是存在一个内存泄漏问题。
我们可以来修改一下:
代码语言:javascript
复制
void GetMemory(char** p, int num)
{
*p = (char*)malloc(num);
assert(*p);
}
void Test(void)
{
char* str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
free(str);
str = NULL;
}
int mani()
{
Test();
return 0;
}
这样就没什么问题了。
4.4 题目4
代码语言:javascript
复制
void Test(void)
{
char* str = (char*)malloc(100);
strcpy(str, "hello");
free(str);
if (str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
来看这段代码,有没有问题:
首先,第一个问题还是没有对malloc
的返回值进行一个判断,有可能是空指针。 其次,我们发现,在strcpy(str, "hello");
之后,就直接free(str)
了,这时str
指向的空间已经被释放了,但是str
还保留这块空间的地址,因为释放后我们并没有将它置空,那此时的str
是不是已经成为野指针了,因为它指向了一块已经被释放的空间。 那下面的if (str != NULL)
判断结果为真,就会进入if语句,而在if语句里面又有strcpy(str, "world")
,把"world"
拷贝到已经不属于我们的动态内存区,篡改动态内存区的内容,后果难以预料,非常危险。
所以这段代码也是有问题的。
以上就是对C语言动态内存管理的讲解及一些笔试题练习,欢迎大家指正!!!