C语言动态内存管理详解

目录

引言:

1.为什么要有动态内存管理

2.malloc和free

2.1.malloc

2.2.free

3.calloc和realloc

3.1.calloc

3.2.realloc

4.常见的使用动态内存错误

4.1.对NULL指针的解引用操作

4.2.对动态开辟空间的越界访问

4.3.对非动态开辟内存使用free释放

4.4.使用free释放一块动态开辟内存的一部分

4.5.对同一块动态内存多次释放

4.6.动态开辟内存忘记释放(这也是最常见的,俗称内存泄漏)

5.动态内存经典笔试题分析

5.1.题目1

5.2.题目2

5.3.题目3

5.4.题目4

6.柔性数组(拓展)

6.1.柔性数组是什么

6.2.柔性数组的特点

6.3.柔性数组的使用

6.4.柔性数组的优势

7.总结C/C++中程序内存区域划分

结语:


引言:

该篇我们来讲讲在C语言中如何动态开辟内存,以及动态开辟内存相比静态开辟内存的优劣,然后再讲解一下跟动态内存有关的笔试题,最后再拓展一下柔性数组的概念以及讲解下内存区域划分。那么接下来就进入正文------------------------>


1.为什么要有动态内存管理

在讲动态开辟内存前,我们来讲讲我们已经掌握的非动态内存分配的方式,代码如下

cpp 复制代码
char arr[10] = {0};

我们可以发现,非动态内存分配的方式有俩个特点

1.空间开辟的大小是固定的,因为已经确定了数组的长度

2.数组在声明的时候,必须指定数组的长度,而数组空间一旦确定了大小便不能调整了,即使是我们先前提到的加长数组,也符合这一条件,因为虽然那个可以控制数组的长度,但是在声明数组后,数组的长度也就不能更改了,本质上来说还是属于非动态内存分配。

那么,我们可以发现,非动态内存分配的方式对空间的控制过于死板,很容易产生浪费空间的事情,又或者我们有时需要的空间大小需要在程序运行的时候才会知道,那么通过非动态内存分配的方式就不能满足了

此时,C语言就引入了动态内存分配,让程序员可以自己申请和释放空间,这样就让程序灵活了很多。

注:如果在算法比赛里不要用动态内存分配,还是用先前的非动态内存分配,因为动态内存分配的过程中申请和释放空间是花时间的,会让本来能过的题TLE!!!!动态内存分配多用于做项目,而不是打算法题


2.malloc和free

在C语言中,动态内存管理函数的头文件都是stdlib.h,那么,我们开始讲讲如何动态开辟内存

2.1.malloc

C语言提供了一个动态内存开辟的函数:malloc,这也是最基础的一个函数

cpp 复制代码
void* malloc(size_t size);

这个函数会想内存申请一块连续可用的空间,并返回只想这块空间首地址的指针

如果开辟成功:返回一个指向空间首地址的指针

如果开辟失败:返回一个NULL指针(所以我们可以通过判断返回的指针是否为空来检验空间有没有开辟成功)

malloc函数的返回类型是void*,所以我们在接收malloc返回值的时候需要对返回的指针进行强转

如果参数size为0,那么malloc这个行为就是标准未定义的,具体会发生什么取决于编译器

那么,我们来试用一下这个malloc函数

首先是最简单的malloc函数的使用(开辟一个一维数组),如下

cpp 复制代码
char* a = (char*)malloc(4 * sizeof(char));

首先malloc(4*sizeof(char))就是动态开辟了四个大小为char的也就是4个字节的连续空间,返回的就是这块空间的首地址,因为我们开的这个空间是为了char,所以就要用char*来接收,所以返回的地址也要强转为char*类型

接下来是比较复杂的malloc函数的使用(开辟一个二维数组),代码如下

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
	int hang = 0;
	int lie = 0;
	scanf("%d %d", &hang, &lie);
	int** p = (int**)malloc(hang * sizeof(int*));
	for (int i = 0; i < hang; i++)
	{
		p[i] = (int*)malloc(lie * sizeof(int));
	}
	for (int i = 0; i < hang; i++)
	{
		for (int j = 0; j < lie; j++)
		{
			printf("%d行%d列\t", i + 1, j + 1);
		}
		printf("\n");
	}
	for (int i = 0; i < hang; i++)
	{
		free(p[i]);
	}
	free(p);
	return 0;
}

开辟二维数组,首先我们要把二维数组当成许多个一维数组作为元素组成的数组,这样二维数组也可以当成一维数组看待,那么二维数组的每个元素既然是一维数组,自然就是一维数组的首地址,所以我们要开辟的就是指针类型的空间,那么开辟的是一级指针类型,所以接收就需要是接收指针类型的指针,也就是二级指针即这行代码

cpp 复制代码
int** p = (int**)malloc(hang * sizeof(int*));

那么开辟完一维数组的个数后,接下来就要开辟每个一维数组内的元素个数,这个时候我们就可以用一个for循环来开辟因为p[i]在这里本质上就是int*(p+i),也就是int*类型的,所以我们只需要开辟一维数组有几个就OK了,开辟完后就可以像正常数组访问一样去使用这个动态开辟的二维数组空间了,运行结果如下

当然动态内存开辟了,不用了自然要释放,所以下面的free语句就是释放空间用的,当然,释放空间需要有顺序释放,而不是胡乱释放,下面我们来细致的讲讲free函数如何释放动态开辟的空间

2.2.free

C语言提供的这个free函数是专门用来做动态内存的释放和回收的,函数原型如下

cpp 复制代码
void free(void* ptr);

注:如果参数ptr指向的空间不是动态开辟的,那free函数的行为是未定义的(这是不合法的)

如果参数ptr是NULL指针,那么函数什么事都不做(这是合法的

所以free函数只能释放动态开辟的空间,当然释放空间的地址必须是malloc,realloc,calloc这些函数动态申请空间后的首地址,这也是很重要的,free函数释放的空间需要是动态开辟的首地址,如果从动态开辟的一个空间的中间开始释放,这种行为是未定义的,也是不合法的

在释放动态空间时候也要注意释放的先后顺序,如果释放的先后顺序不对,就会导致有些空间无法实现释放,就以我们上面讲的malloc函数开辟二维数组那个代码为例,那个代码的free函数的释放顺序是这样的,如下

cpp 复制代码
for (int i = 0; i < hang; i++)
{
	free(p[i]);
    p[i] = NULL;  
}
free(p);
p = NULL;

在二维数组的释放过程中,我们先释放了二维数组中一维数组的每个元素,随后再释放二维数组的每个元素,这是正确的。

但如果我们把顺序调换之后,就会出很大的问题,因为先释放完p的话,p中每个一维数组的首地址就找不到了,那么就无法对一维数组中的空间进行释放了。

然后为了防止我们之后又去使用这些变量,我们可以将这个变量置为空,表示这块空间已经被释放了


3.calloc和realloc

除了malloc外,还有俩个函数可以实现动态内存管理

3.1.calloc

calloc函数实现的功能与malloc相比就是多了会给开辟空间的每个字节初始化为0,函数原型如下

cpp 复制代码
void* calloc (size_t num, size_t size);

num便是要开辟的元素的个数,size便是要开辟的元素类型的大小,calloc与malloc一样,返回的都是开辟空间的首地址,我们来试用一下calloc,代码如下

cpp 复制代码
int* p = calloc(10,sizeof(int));

这个代码便是开辟了10个大小为4个字节的空间,也就是40个字节的空间,随后将空间内的字节都初始化为0

所以如果我们需要对申请的内存空间的内容初始化,那么用calloc函数来开辟就会方便很多

3.2.realloc

这个函数的出现让动态内存管理更加灵活,malloc和calloc函数都是向内存中申请一块空间,但不能对这块空间再进行改造了,但是realloc的出现就发生了质的改变,有了realloc函数,就可以做到对动态开辟内存大小的调整,函数原型如下

cpp 复制代码
void* realloc (void* ptr, size_t size);

ptr是要调整的内存地址,也就是动态内存的首地址

size就是调整后的内存大小

realloc函数和malloc以及calloc函数一样,都会返回开辟内存的首地址

此时,就有人会想了,那realloc执行完后的地址是不是就是和先前的ptr地址是一样的,其实不然,是有可能不一样的,因为会有俩种情况

第一种:原有空间之后有足够大的空间

若是这种情况的时候,realloc执行完后返回的地址还是先前ptr的地址,因为更新后的空间大小完全可以塞得下,所以不管是扩大空间亦或是减小空间都不需要改变空间的位置,此时原有空间的数据也不会发生变化

第二种:原有空间之后没有足够大的空间

这种情况下,因为原有空间那块区域没有足够多的空间可以进行拓展,此时的拓展方式就是:在堆空间上另外找一个何时的连续空间来使用,此时函数返回的就是一个新的内存地址,当然,老的那块区域自然也被释放掉了,老的那块区域的元素也会拷贝到新的区域中

当然,realloc也存在调整失败的情况,这种时候跟malloc和calloc一样,返回的是NULL指针

所以,只要是涉及动态内存的,如果是开辟,每次开辟完后都要判断一下是不是为NULL,防止使用未开辟空间,如果是释放,每次释放后,要及时的把对应的指针置为NULL


4.常见的使用动态内存错误

接下来,我们来讲讲常见的使用动态内存错误的几个案例

4.1.对NULL指针的解引用操作

cpp 复制代码
void test()
{
	int* p = (int*)malloc(INT_MAX / 4);
	*p = 20;//如果p的值是NULL,就会有问题 
	free(p);
}

在这个代码中,我们通过p来接收动态开辟空间的首地址,但是我们要考虑这个动态开辟空间有没有被开辟成功,所以在对p解引用前,我们需要先判断p这个指针是否为空,若为空,就代表开辟失败了,这个时候我们就要给出对应提示信息,而不是继续往下走,所以这个代码改善后如下

cpp 复制代码
void test()
{
	int* p = (int*)malloc(INT_MAX / 4);
	if(p==NULL)
    {
        perror("mallocerr");
        free(p);
        return 1;
    }
    *p = 20;
	free(p);
}

4.2.对动态开辟空间的越界访问

cpp 复制代码
void test()
{
	int i = 0;
	int* p = (int*)malloc(10 * sizeof(int));
	if (NULL == p)
	{
		exit(EXIT_FAILURE);
	}
	for (i = 0; i <= 10; i++)
	{
		*(p + i) = i;//当i是10的时候越界访问 
	}
	free(p);
}

这个错误就很明显了,我们动态开辟了10个int大小的空间,那么自然只有十个元素,所以访问的时候自然就是0到9,这和数组一样,也是不能越界的,下面循环里循环到10了,越界了,这是不合法的

4.3.对非动态开辟内存使用free释放

cpp 复制代码
void test()
{
	int a = 10;
	int* p = &a;
	free(p);//ok?
}

这也是我们先前在讲free函数时强调过的,free函数只能释放动态开辟的空间,而p指向的是非动态开辟的空间,所以free(p)这个操作是不合法的

4.4.使用free释放一块动态开辟内存的一部分

cpp 复制代码
void test()
{
	int* p = (int*)malloc(100);
	p++;
	free(p);//p不再指向动态内存的起始位置 
}

这个代码中,p指向的不是动态空间的首地址,所以free也是不合法的

4.5.对同一块动态内存多次释放

cpp 复制代码
void test()
{
	int* p = (int*)malloc(100);
	free(p);
	free(p);//重复释放 
}

这个代码中第一次free(p),p所指的动态空间已经被释放了,再使用一次free(p)的话,p所指向的空间因为已经被释放了,所以没有空间给他释放,所以这也是不合法的,连续使用free只有free(NULL)的时候才是合法的,但是动态分配地址是不会分配到0地址去的,所以对同一块动态内存多次释放也是不合法的

4.6.动态开辟内存忘记释放(这也是最常见的,俗称内存泄漏)

cpp 复制代码
void test()
{
	int* p = (int*)malloc(100);
	if (NULL != p)
	{
		*p = 20;
	}
}

int main()
{
	test();
	while (1);
}

在这个代码中,进入test函数后,开辟了一个动态空间,但是在test函数结束前也没有对这个动态空间free,那么当test函数结束后我们再想要去释放p的空间就做不到了,要等到程序结束后,才会释放内存,那么,这也就造成了只要程序还在运行,没释放的动态空间就一直占着一块区域,你无法使用它,也无法释放这块空间,这也就是我们所谓的内存泄露。

内存泄漏在项目里就会有影响,如果一直不释放空间,那么,堆区总共的大小就那么大,一直不释放一直叠加,程序自然就很容易出现问题了,所以切记:动态开辟的空间一定要释放,并且正常释放


5.动态内存经典笔试题分析

我们知道了动态内存常见的错误后,我们就来分析分析代码题中动态内存分配的问题

5.1.题目1

cpp 复制代码
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;
}

这串代码显示创建了一个char*类型的变量str,随后将这个变量当作形参传给了GetMemory函数,在GetMemory函数中,使用malloc函数动态开辟了一块空间,然后被p接收,因为传过去的是形参,所以p的改变不会造成str的改变,那么此时就会发现第一个问题,因为GetMemory函数运行结束后,就找不到动态开辟的内存的位置了,此时就无法进行释放,就导致了内存泄漏,在GetMemory函数执行结束后,接下来就是strcpy函数的使用,但是str并没有被更改,所以这个拷贝函数其实是在0地址处进行拷贝,因为我们都知道0地址我们没有权限对他进行操作,所以这里是第二处错误,这是不合法的,那么自然输出0地址开始的字符串自然也是不合法的,此为第三处错误

5.2.题目2

cpp 复制代码
char* GetMemory(void)
{
	char p[] = "hello world";
	return p;
}

void Test(void)
{
	char* str = NULL;
	str = GetMemory();
	printf(str);
}

int main()
{
    Test();
    return 0;
}

这个代码在GetMemory函数运行完后,返回的是p指针,然后让str接收,但是p是在GetMemory函数内部开辟的空间,那么当GetMemory函数结束后,自然这块空间也就没了访问权限,所以虽然str能找到p的位置,但是那块空间里不一定是hello world了,所以,这是这个代码的问题

5.3.题目3

cpp 复制代码
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;
}

这块代码主要问题没有,就是没有在不用str后主动释放这块空间,因为如果Test运行完后就没有地方可以让这块动态开辟的区域释放了,这也就导致了内存泄漏,有隐患

5.4.题目4

cpp 复制代码
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;
}

这块代码就是free释放早了,然后缺少了str的判断,正确的顺序应该是这样的

cpp 复制代码
void Test(void)
{
	char* str = (char*)malloc(100);
    if (str != NULL)
	    strcpy(str, "hello");
	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
    free(str);
}

int main()
{
	Test();
	return 0;
}

首先在strcpy之前,我们要先判断一下malloc有没有成功开辟内存,随后再是上面的拼接输出代码,最后才是free释放空间,因为先释放再写后面的代码明显不符合逻辑


6.柔性数组(拓展)

6.1.柔性数组是什么

这是拓展的一个知识,柔性数组是在C99后添加进去的,那什么是柔性数组呢,简单的说就是结构体的最后一个元素如果是数组的话,是允许不给这个数组附大小的,没给数组大小的我们就称之为柔性数组

例如:

cpp 复制代码
//第一种
struct node
{
    int a;
    char b;
    int c[0];//柔性数组
}

//第二种
struct node
{
    int a;
    char b;
    int c[];//柔性数组
}

这俩种都为柔性数组,第二种更常用一点

6.2.柔性数组的特点

那么,接下来我们来讲讲柔性数组的特点

首先,我们知道sizeof是可以统计类型所占字节大小的,但是如果sizeof要统计的是包含柔性数组的结构体的大小时,柔性数组的大小不包含在内的,统计的是其余元素所占内存空间的大小

通过这个我们也能知道存在柔性数组的必要条件:结构体中的柔性数组成员前面必须至少有一个其他成员

这个必要条件可以通过先前的讲述论证,接下来需要强调的一点是:包含柔性数组成员的结构体必须要用动态内存分配函数进行内存的动态分配,并且分配到内存至少要大于结构体自身的大小,以适应柔性数组的预期大小

那么,我们应该怎么使用柔性数组呢,接下来我们来具体讲讲怎么使用

6.3.柔性数组的使用

首先我们来看怎么开辟空间,因为柔性数组的存在,所以是不能非动态开辟内存的,正确的开辟空间方式如下

cpp 复制代码
struct node
{
	int a;
	short b[];
};

int main()
{
	struct node* p = (struct node*)malloc(sizeof(struct node) + 100 * sizeof(short));
	free(p);
	p = NULL;
	return 0;
}

sizeof(struct node)是node结构体中除柔性数组外所占空间的大小,随后加上100*sizeof(short),相当于就是给柔性数组分配个100个元素的空间,之后只需要把这个柔性数组当成一个正常的数组看就可以了

6.4.柔性数组的优势

如果说我们不用柔性数组,能不能实现柔性数组的效果呢,其实也是可以的,代码如下

cpp 复制代码
struct node
{
	int a;
	short* b;
};

int main()
{
	struct node* p = (struct node*)malloc(sizeof(struct node));
	p->a = 100;
	p->b = (short*)malloc(p->a * sizeof(short));
	free(p->b);
	p->b = NULL;
	free(p);
	p = NULL;
	return 0;
}

那么,我们来看看用柔性数组实现的好处

首先:方便内存释放

如果是使用柔性数组的方式,我们释放内存的时候只需要释放1次就可以了,而且很清晰,但是如果是用下面这种方式,不仅释放内存要释放俩次,这俩次的先后顺序还不能有错,不然还会发生内存泄漏的问题,所以柔性数组的优势之一便是方便内存释放释放

其次:有利于访问速度

我们可以发现,如果是用柔性数组开辟的地址,他们开辟出来是一块,但是如果是自用指针来开辟的话,很可能中间就会有一定的间隔,这也被称为内存碎片,用柔性数组开辟就有益于减少内存碎片,提高访问速度

当然最主要的优势还是第一点


7.总结C/C++中程序内存区域划分

总体如下图(数据段就是常说的静态区)

我们来讲讲常见的几个区域的特点:

栈区:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。

堆区:一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。分配方式类似于链表。

静态区(数据段):存放全局变量、静态数据。程序结束后由系统释放。

代码段:存放函数体(类成员函数和全局函数)的二进制代码。


结语:

希望以上内容对你有所帮助,感谢观看,若觉得写的还可以,可以分享给朋友一起来看哦,毕竟一起进步更有动力嘛,当然能关注一下就更好啦

相关推荐
haokan_Jia5 小时前
【java使用LinkedHashMap进行list数据分组写入,顺序并没有按照原始顺序,原因分析】
java·开发语言·list
凯子坚持 c5 小时前
C++大模型SDK开发实录(三):流式交互协议SSE解析与httplib实现原理
开发语言·c++·交互
ghie90905 小时前
基于MATLAB的多旋翼无人机多机编队仿真实现
开发语言·matlab·无人机
少控科技5 小时前
QT新手日记026
开发语言·qt
就是有点傻5 小时前
C#中如何和西门子通信
开发语言·c#
液态不合群5 小时前
如何提升 C# 应用中的性能
开发语言·算法·c#
布局呆星5 小时前
面向对象中的封装-继承-多态
开发语言·python
柏林以东_5 小时前
异常的分类与用法
java·开发语言
专注API从业者5 小时前
淘宝商品 API 接口架构解析:从请求到详情数据返回的完整链路
java·大数据·开发语言·数据库·架构