【C进阶】——动态内存管理详解 及 经典笔试题解析

C/C++
30
0
0
2024-05-04
这篇文章,我们一起来学习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); 那它是用来干嘛的呢?

在这里插入图片描述

接下来再来给大家详细解释一下:

  1. 参数size_t size接收我们想要开辟的内存空间的大小,单位是字节,返回指向该内存块开头的指针。

代码语言:javascript

复制

int main()
{
	void* p = malloc(40);
	return 0;
}
  1. 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
malloc给我们返回的指针类型是void*,但我们知道void*是不能直接解引用的,注意使用时要先转换为我们需要的指针类型。 比如我们想再申请的空间里放整数,就应该这样搞: int* p = (int*)malloc(40); 然后,我们就可以往里面放整型数据了。 当然,你想用来放其他数据,就转换成其它相应的类型。
  1. 如果开辟失败,则返回一个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的一个特性:

  1. 新分配的内存块的内容不做初始化,仅保留不确定的值。

使用后:


在这里插入图片描述

我们初始化之后,里面放的就是0到9了。

  1. 如果参数size_t size为0,则返回值取决于特定的库实现(它可能是也可能不是空指针),但返回的指针不应被解引用。 此时malloc的行为是标准是未定义的,取决于编译器。
所以我们尽量不要这样试,况且这样做也没什么意义,申请一个大小为0的空间?

那申请的空间使用完之后,我们是不是什么都不用管了呢?

不是的,对于像malloc这些函数动态开辟的内存,使用完之后我们是需要将这些空间释放掉的,不及时释放,有可能会造成内存泄漏。

那怎么释放呢?

2.2 free
C语言提供了另外一个函数free,专门是用来做动态内存的释放和回收的。

接下来我们就来一起学习一下函数free:

它的参数是这样的:

在这里插入图片描述

怎么用呢?

  1. 参数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是不是还保存着那块空间的地址啊。 那么一个指针指向了一块被释放掉的空间,那它是不是一个典型的野指针啊。 要知道如果对一个野指针解引用那程序就会出错的。
  1. 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
也就是说参数 ptr 指向的空间必须是动态开辟的。 如果指向其它的空间,那么free函数会怎么处理是标准未定义的。

比如:

代码语言:javascript

复制

int main()
{
	int num = 10;
	int* p = &num;
	free(p);
	p = NULL;
	return 0;
}
你写一个这样的代码,肯定是不行的,因为p指向的空间不是动态开辟的。 这里的num是一个局部变量,要知道局部变量是保存在栈区的,再来复习一下:

在这里插入图片描述

而我们这些动态开辟的内存,是堆区分配的。

  1. 如果参数 ptr 是NULL指针,则函数不执行任何操作。 像这样:

代码语言:javascript

复制

	int* p = NULL;
	free(p);
函数不执行任何操作。
2.3 calloc
C语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。

我们一起来学习一下:

在这里插入图片描述

函数calloc 有两个参数,无返回值,那它的作用是什么呢?这两个参数分别接收什么呢?

  1. 函数的功能是为 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释放并将指向空间起始地址的指针置空。
  1. 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
对于malloc 来说,它不会对开辟好的空间初始化,里面放的是随机值。 但是,calloc 会把申请的空间的每个字节都初始化为0。

就拿上面那段代码,我们来调式看一下:

在这里插入图片描述

  1. 和malloc 一样,calloc 函数如果开辟内存块失败,则返回空指针void*。
所以对于calloc 的返回值,我们也有必要做一下检查,判断是否为空指针。

在这里插入图片描述

  1. 和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 ,最好将其也置空。

然后,没什么问题,我们就可以使用扩容后的空间了。

但是,在扩容的时候,又存在存在两种情况

  1. 原地扩 什么时候是原地扩呢?
就还拿刚才的例子来说: int* ptr = (int*)realloc(p, 80); p原来指向的空间是40个字节,现在我们想要使用realloc将p指向的空间扩容为80个字节。 那这时realloc就会从原空间向后看,如果后面有足够大的空间能够再增加40个字节,那么realloc就会在原地向后扩容40个字节,使得p指向的空间变为80字节。

在这里插入图片描述

当然这样realloc返回的地址还是原来p指向的地址。

  1. 异地扩 那什么时候异地扩呢?
假设现在还是相把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 = &num;
	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函数,实参传的是strGetMemory函数用了一个字符指针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语言动态内存管理的讲解及一些笔试题练习,欢迎大家指正!!!