C语言动态内存分配详解

前言

自从前两次博客写完以后,感觉对于我本人来说,收获很大,尤其是将学过的知识再度温习一遍,感觉基础扎实了很多,所以就养成了一个习惯,每学完一个模块,都会写一篇博客,不仅仅是写个我自己的,也是想通过这篇博客,与大家分享一些我的见解。本来周四就学完了动态内存分配,但是周末博主去玩了,于是忘记了写博客,现在加班奉上。

一、为什么存在动态内存分配

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];// 50个struct s 类型的数据
	// 30 :不够
	// 60 :浪费
	
	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];//错误(活动)	E0028	表达式必须含有常量值

	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()
{
	// 向内存申请10个整形的空间
	int* p = (int*)malloc(10 * sizeof(int));
	// malloc -> #include<stdlib.h>

	//int* p = malloc(10 * sizeof(int));
	// 错误	 C2440	 “初始化” : 无法从“void * ”转换为“int* ”
	if (p == NULL)
	{
		// 打印错误原因的一个方式
		printf("%s\n", strerror(errno));
		// strerror -> #include<string.h>
		// errno -> #include<errno.h>
	}
	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()
{
	// 向内存申请10个整形的空间
	int* p = (int*)malloc(10 * sizeof(int));
	// malloc -> #include<stdlib.h>

	//int* p = malloc(10 * sizeof(int));
	// 错误	 C2440	 “初始化”  :  无法从“void * ”转换为“int* ”
	if (p == NULL)
	{
		// 打印错误原因的一个方式
		printf("%s\n", strerror(errno));
		// strerror -> #include<string.h>
		// errno -> #include<errno.h>
	}
	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);// 要为\0分配空间
	char* d=dest;
	char* s=src+len-1;// 指向最后一个字符
	while(len--!=0){ 
		*(d++)=*(s--);// 注意不要丢掉*号
		*d ='\0';// 字符串的结尾不要忘记'\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(10sizeof(int));
	// calloc -> #include<stdlib.h>

	if (p == NULL)
	{
		// 打印错误原因的一个方式
		printf("%s\n", strerror(errno));
		// strerror -> #include<string.h>
		// errno -> #include<errno.h>
	}
	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()
{
	// 1.对NULL指针解引用操作
	int* p = (int*)malloc(40);
	// 万一malloc失败了,p就被赋值为NULL
	// 不安全
	// 记得判断p是否为空
	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()
{
	// 2.对动态开辟内存的越界访问
	int* p = (int*)malloc(40);// 10个int -> 0-9
	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()
{
	// 3.对非动态开辟内存使用free释放
	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()
{
	// 4.使用free释放动态开辟内存的一部分
	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()
{
	// 5.对同一块动态内存的多次释放
	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()
{
	// 5.对同一块动态内存的多次释放
	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()
{
	// 6.动态开辟内存忘记释放(内存泄漏)
	while (1)
	{
		malloc(1);//警告	C6031	返回值被忽略 : “malloc”。
	}
	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)

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WE-ubytt

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值