【C语言进阶】从入门到入土(动态内存管理)

前言:
在c/c++语言中,编写程序有时不能确定数组应该定义为多大,因此这时在程序运行时要根据需要从系统中动态多地获得内存空间。所以今天我们来学习一下动态内存分配是怎样的。

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

我们来看一下这个代码:

int main()
{
	int a = 10;// 在栈空间上开辟四个字节
	int arr[10] = { 1,2,3 };// 在栈空间上开辟10个字节的连续空间
	return 0;
}

有同学可能会说:什么?我都进来了,你给我看这个?没错,就是给你看这个。我们仔细观察一下可以看出,int a占用了4个字节,而arr数组占用了40个字节,却只有12个字节是在使用存储了数据的,那么在暂时不做更改的情况下,是浪费空间的。

上述的开辟空间的方式有两个特点:

  1. 空间开辟大小是固定的。
  2. 数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配。

但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。 这时候就只能试试动态内存开辟了。所以我们就有了动态内存分配的概念:

所谓动态内存分配,就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。

其实,一个由C/C++编译的程序占用的内存分为以下几个部分


1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数名,局部变量的名等。其操作方式类似于数据结构中的栈。

2、堆区(heap)— 由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。

3、静态区(static)—全局变量和局部静态变量的存储是放在一块的。程序结束后由系统释放。

4、文字常量区—常量字符串就是放在这里的,程序结束后由系统释放 。

5、程序代码区— 存放函数体的二进制代码。


而我们的动态内存分配,就是存在于堆区,并且有几个动态内存函数去维护,接下来再介绍各个函数:


二.动态内存函数的介绍

1.malloc

我们先在cplusplus上看一下malloc的定义:

void * malloc(size_t size)

实际上,malloc就是一个C语言提供了一个动态内存开辟的函数,这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。注意返回的指针是void *类型。

所以我们可以定义一个函数试试:

int main()
{
	int * p = (int *)malloc(40);
    //向内存申请40字节空间,返回指针强转为int * 
	return 0;
}

因为malloc的返回指针是void *的,如果我们直接接收是无类型的,所以我们如果打算是用来存储整形的,就强制类型转换为int *的指针存储。我们还要注意malloc中的说明:

1.如果开辟成功,则返回一个指向开辟好空间的指针。
2.如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
3.返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
4.如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。

所以我们要对malloc开辟后的返回值进行检查,以及类型的选择:

int main()
{

	int * p = (int *)malloc(40);
	if (p == NULL)
	{
		return -1;//判断是否开辟成功
	}

	int i = 0;
	for (i = 0; i < 10; i++)//初始化
	{
		*(p + i) = i;
	}
	return 0;
}

2.free

既然有申请空间,那么也就需要释放空间,所以我们就有了free这个函数:

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

void free (void* ptr);

简单来说:free函数就是用来释放动态开辟的内存。

接着上面的代码,当我们申请完且开辟成功之后,我们使用完这一段空间了,我们就要释放这一段内存空间:

#include <stdlib.h>

int main()
{

	int * p = (int *)malloc(40);
	if (p == NULL)
	{
		return -1;//判断是否开辟成功
	}
	int i = 0;
	for (i = 0; i < 10; i++)//初始化
	{
		*(p + i) = i;
	}

	free(p);
	p = NULL;//注意!
    //free释放的是p所指向的空间,但p仍然指向了这块空间,是十分危险的
    //所以需要在free释放空间后,将p也同时变为NULL。
	return 0;

同时,free函数也有需要注意的点:

1.如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
2.如果参数 ptr 是NULL指针,则函数什么事都不做。

所以当我们使用free函数的时候,不能指向不是动态开辟的空间。并且malloc和free一起使用更好。


3.calloc

我们再来学习calloc函数:

实际上calloc函数同样也是开辟动态内存空间,那他和malloc有什么区别呢?我们来看一下定义和参数:

void* calloc (size_t num, size_t size);

malloc区别的是,这里的参数有两个,我们可以创建若干个内存单元,每个单元分配多少空间,更清晰的分配了空间。但是malloccalloc最主要的区别是:

malloc函数只负责在堆区中申请空间,并且返回起始地址,不初始化内存空间。
calloc函数在堆区中申请空间,并且初始化为0,返回起始地址。

#include <errno.h>
#include <string.h>

int main()
{
	int* p = (int *)calloc(10, sizeof(int));
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));//报错函数
		return -1;
	}

	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", *(p + i));
        //打印结果:0 0 0 0 0 0 0 0 0 0
	}

	free(p);
	p = NULL;
	return 0;
}

如果当我们的参数非常大,那么申请内存就不能成功申请,然后导致报错:

int main()
{
	int* p = (int *)calloc(1000000000, sizeof(int));
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));
		return -1;
	}

	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", *(p + i));
	}

	free(p);
	p = NULL;
	return 0;
}

所以如果我们对申请的内存空间的内容要求初始化,那么可以很方便的使用calloc函数来完成任务。也就是说,当我们需要初始化的时候,就用calloc函数,不用初始化就用malloc函数。


4.realloc

那么如何展现出动态性呢,就是在realloc函数中展现,realloc函数的出现让动态内存管理更加灵活:

有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了。那为了合理的时候内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小的调整。

int main()
{
	int* p = (int *)calloc(10, sizeof(int));
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));
		return -1;
	}
	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", *(p + i));
	}

	int* ptr = (int*)realloc(p, 20 * sizeof(int));//修改
	//增加空间到20个int
	p=ptr;
	free(p);
	p = NULL;
	return 0;
}

realloc需要注意的点是:

1.ptr 是要调整的内存地址
2.size 调整之后新大小
3.返回值为调整之后的内存起始位置。
4.这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。

所以realloc在调整内存空间的是存在两种情况:

情况1:原有空间之后有足够大的空间

当是情况1 的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。

情况2:原有空间之后没有足够大的空间

当是情况2 的时候,原有空间之后没有足够多的空间时,扩展的方法是:在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址。 由于上述的两种情况,realloc函数的使用就要注意一些。

所以当realloc对动态开辟内存大小的调整的时候,如果修改增加空间太大,无法增加,那么也会导致代码bug。所以我们也要考虑realloc修改失败时,失败返回值也是NULL,所以我们前面的代码应该修改为:

int main()
{
	int* p = (int *)calloc(10, sizeof(int));
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));
		return -1;
	}

	int i = 0;
	for (i = 0; i < 10; i++)
	{
		printf("%d ", *(p + i));

	}

	int* ptr = (int*)realloc(p, 20 * sizeof(int));
	if (ptr != NULL)//增加一个判断
	{
		p = ptr;//如果ptr为空,p还能指向原地址
	}
	else
	{
        return -1;//如果为空,则退出不继续下去
    }
	
    for(i = 10;i < 20; i++)//同时我们也可以初始化后面增加的10位
    {
     * (p + i) = i;
    }
    for(i=0;i<20;i++)
    {
        printf("%d ",*(p + i));
    }

	free(p);
	p = NULL;
	return 0;
}

这就是动态内存分配的几个函数。


三.常见的动态内存错误

对于动态内存分配中,我们存在着一些常见的错误。接下来我们来分析几个常见错误以及注意事项。

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

第一种就是不检测指针指向是否是NULL,然后就直接解引用。

int main()
{
	int* p = (int*)malloc(40000000000);
	//如果p为空指针,则下面的代码是有问题的
	
	*p = 0;
	//这样写代码是有风险的!!
	return 0;
}

在vs2019底下会产生C6011警告,此警告指示代码取消引用可能为 null
的指针。如果该指针的值无效,则结果是未定义的。

所以我们一定要记得增加一个判断:

int main()
{

	int* p = (int*)malloc(40000000000);
	if (p == NULL)//增加判断
		return -1;
	*p = 0;
	
	return 0;
}

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

对动态开辟空间的越界访问就是说我们访问的内存以及超过了我们所开辟的内存,导致越界访问。

像下面的代码,我们开辟的内存空间是40,也就是10个整形的大小,但是当我们在赋值的时候访问到20个元素,就造成越界访问:

void test()
{
	int i = 0;
	int* p = (int*)malloc(40);
	if (NULL == p)
	{
		return -1;
	}
	for (i = 0; i <= 20; i++)
	{
		*(p + i) = i;
	}
	free(p);
	p = NULL;

	return 0;
}

代码运行起来就会挂掉的,如果没有挂掉,只能说明编译器这次没有检测出来,但原则上代码还是错误的。


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

前面已经讲过,free函数释放的时候是对动态开辟内存释放的,而不能用free去释放非动态开辟的内存。

比如:

int main()
{
	int a = 0;
	int* p = &a;
	//p不想用了

	free(p);
	p = NULL;

	return 0;
}

这样子代码也是直接会挂掉的。


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

当free释放一块动态开辟内存的时候,应该是整体释放的,但是也有可能因为我们的一些错误导致代码出现错误,比如释放的是一块动态开辟内存的一部分:

int main()
{
	int* p = (int*)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		return -1;
	}
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		*p++ = i;
	}
	//由上面的代码++之后,p不再指向起始开辟空间
	free(p);
	p = NULL;

	return 0;
}

除了对一部分释放外,没有释放,释放错误等都是错误的。


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

同样的,对于一块动态内存多次释放也是错误的,比如:

int main()
{
	int* p = (int*)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		return -1;
	}

	free(p);
	free(p);//多次释放 err
	p = NULL;

	return 0;
}

但是如果释放后,将p设为空指针,即使再次释放,也不会产生问题,所以我们在free释放后,一定要记得接上将p设为空指针。比如:

int main()
{
	int* p = (int*)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		return -1;
	}

	free(p);
	p = NULL;
	free(p);//p已经是空指针,再次释放也没有问题
	p = NULL;

	return 0;
}

6.动态开辟内存忘记释放(内存泄漏)

对于动态内存开辟的释放有两种方法:

1.free主动释放
2.程序退出的时候,申请的空间也会回收。

比如:

int main()
{
	int* p = (int*)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		return -1;
	}
	getchar();
    //没有内存释放

	return 0;
}

当我们使用完没有释放内存的时候,就会造成内存浪费,如果是大型服务器在跑的话,比如王者荣耀,那么只有它在维护的时候才能停下来,那么我们浪费的内存空间就可能会导致运行起来没有那么流畅。

所以:

忘记释放不再使用的动态开辟的空间会造成内存泄漏。 切记: 动态开辟的空间一定要释放,并且正确释放 。


四.几个经典的笔试题

1.题目一

请问运行Test 函数会有什么样的结果?

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函数并创建str变量之前,都是没有错误的,然后当把str的值传参给GetMemory的时候,也可以说是没有错误的。但是当它将malloc开辟的空间的地址给p的时候,就出现错误了。这是因为:

1.str传给p的时候,是值传递,p是str的临时拷贝,所以当malloc开辟的空间起始地址放到p中的时候,不会影响str,str依然是NULL。

然后就会导致str实际上没有改变,所以仍然是NULL的时候给他拷贝的话就会:

2.当str是NULL时,strcpy想把hello world拷贝到str指向的空间的时候,程序就会崩溃。因为NULL指向的空间是不能直接访问的。

然后我们再把目光回到p身上,在这里我们是不是malloc开辟之后并没有释放,而且是存储起来之后返回到Test函数了,这就意味着p已经随着GetMemory销毁了,我们甚至找不到这块空间(p销毁了,没有指向):

3.当使用完动态内存后,忘记释放不再使用的动态开辟的空间会造成内存泄漏。

然后我们对这段代码补救一下,修改一下,让他没有问题,其实这里就两个问题,一个是传参传的是值,另一个是没有释放空间,所以我们传地址过去再加上释放就好拉:

//解法:传地址过去
void GetMemory(char** p)
{
	*p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(*str);//传地址过去
	strcpy(str, "hello world");
	printf(str);

	//使用完释放
	free(str);
	str = NULL;
}

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

2.题目二

请问运行Test 函数会有什么样的结果?

char* GetMemory(void)
{
	char p[] = "hello world";
	return p;
}

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

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

同样的我们来分析一下代码运行的顺序:

答案:烫烫烫烫烫烫烫烫镑Z(随机值)

解析:

在这里代码中实际上是犯了一个非法访问,不安全访问的错误。因为在进入GetMemory函数执行后,返回的指针p存放到str里,这时候函数GetMemory已经销毁了,也就是说char p[]创建的常量字符串也已经销毁了,这时候就算记住p的地址,访问到的也不是字符串的内容了,所以访问的时候得到的是随机值。

一个生动形象的例子:

我今天心情不好出去住酒店,然后住的是305的房(创建内存空间),然后想着心情不好都怪那个张三,然后打电话给张三(将创建的地址赋给str),说开了一个五星级酒店,但是明天不住,然后叫他来住。张三听了之后欣喜若狂(接收到了地址),第二天把全家东西都带过来了(访问该地址)。但是我早上早早的就退了房,回学校了(释放该地址空间)。然后张三在那不乐意了,一直拧着305的房想要进去住,撞啊打啊终于进去了,但是这种行为是错误的(非法访问内存),然后被警察叔叔抓走了。

类似的代码也有:

int* fun()
{
	int n = 10;
	return &n;
}


int main()
{
	int* str = fun();
	printf("&d", *str);
	
	return 0;
}

上面的代码可能可以打印出10,但是本质上也还是错误的,可以打印只是说明虽然函数被销毁了,但是放置10的空间没有被覆盖和修改,所以当地址找到的时候,还是打印10。但代码再复制一点,就有可能被覆盖掉了:

//变成随机值
int* fun()
{
	int n = 10;
	return &n;
}


int main()
{

	int* str = fun();
	printf("123\n");//加个顺便打印点东西

	printf("%d", *str);
	return 0;

}

3.题目三

请问运行Test 函数会有什么样的结果?

void GetMemory(char **p, int num)
{
 *p = (char *)malloc(num);
}

void Test(void)
{
 char *str = NULL;
 GetMemory(&str, 100);
 strcpy(str, "hello");
 printf(str);
}

这题就比较水了,如果我们前几题都会的话,这里只是一个最后没有释放内存的错误,只需要在使用完之后也就是 printf(str);后面加上free(str)以及str = NULL就可以啦。


4.题目四

请问运行Test 函数会有什么样的结果?

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;
}

这题的解析直接在图里面拉,实际上就是由于释放了空间但是没有将指针设为NULL,导致野指针继续非法访问内存,所以在释放完动态内存之后,一定要记得将指针修改为NULL。


五.柔性数组

也许你从来没有听说过柔性数组(flexible array)这个概念,但是它确实是存在的。 C99中,结构中的最后一个元素允许是未知大小的数组,这就叫做柔性数组成员

也就是结构体中最后一个元素是数组,而且可以不规定大小,比如:

struct fun
{
 int i;
 int a[0];//柔性数组成员
}
//或者直接为空
struct fun2
{
 int i;
 int a[];//柔性数组成员
}

而柔性数组存在以下特点:

1.结构中的柔性数组成员前面必须至少一个其他成员。

2.sizeof 返回的这种结构大小不包括柔性数组的内存。

3.包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。

首先是必须至少有一个成员,比如这样子是不行的:

 struct fun3
{
	int a[];//前面没成员,err
};

然后sizeof 返回的这种结构大小不包括柔性数组的内存,也就是我们在计算内存的时候是不计算柔性数组的内存的,比如:

struct fun
{
	int i;//4
	int a[];//柔性数组成员
};

int main()
{
	printf("%d", sizeof(struct fun));
    //结果是4
	return 0;
}

在这里,我们的结构体中有i和a数组两个成员,但是只计算了i的4个字节,同时也是有内存对齐后的结果。而第三点中,包含柔性数组成员的结构用malloc ()函数配合进行内存的动态分配使用。我们可以看一下代码:

#include <string.h>
#include <errno.h>

struct fun
{
	int i;
	int a[];//柔性数组成员
};

int main()
{
	struct fun* ps = (struct fun*)malloc(sizeof(struct fun) + 10 * sizeof(int));
	//这段代码什么意思呢?
		if (ps == NULL)
		{
			printf("%s\n", strerror(errno));
		}
	return 0;
}

我们来分析一下这段代码:
struct fun* ps = (struct fun*)malloc(sizeof(struct fun) + 10 * sizeof(int));

首先当我们创建柔性数组的结构体的生活不是直接struct fun ps这样子的,需要配合malloc使用。然后初始大小就是sizeof(struct fun)所以malloc(sizeof(struct fun))就是结构体不包含柔性数组的大小,然后后面的就是柔性数组的大小了,比如我这里想创建的是10个整形大小的数组,就在后面加上10 * sizeof(int),然后既然是malloc创建就应该有指针接收,所以前面就用结构体指针struct fun* ps接收,然后最后malloc开始是无类型的,所以应该先把他强制类型转换为struct fun* ps再赋值过去。这样子就得到柔性数组啦。


既然说明说柔性,那么我们来写完整一点,看看他是如何柔性的:

//方法一
int main()
{
	struct fun* ps = (struct fun*)malloc(sizeof(struct fun) + 10 * sizeof(int));
		if (ps == NULL)
		{
			printf("%s\n", strerror(errno));
			return -1;
		}
		//开辟成功
		ps->i = 100;
		int i = 0;
		for (i = 0; i < 10; i++)
		{
			ps->a[i] = i;
		}

		//数组a空间不够用,调整内存
		struct fun* ptr = (struct fun*)realloc(ps,sizeof(struct fun) + 20 * sizeof(int));
		if (ptr == NULL)
		{
			printf("扩容空间失败\n");
			return -1;
		}
		else
		{
			ps = ptr;
			//继续使用
			for (i = 10; i < 20; i++)
			{
				ps->a[i] = i;
			}

			//打印查看
			for (i = 0; i < 20; i++)
			{
				printf("%d ", ps->a[i]);
			}
		}

		//释放
		free(ps);
		ps = NULL;
		return 0;
}

这里我们同样用到的是mallocrealloc函数,让他变得柔性起来,也就是动态调整内存,可以当数组的内存变化,如果你想要一个数组但是又不确定他的大小,就可以使用柔性数组。


而其实我们也可以使用指针指向数组去首先柔性数组的概念,在这里就是先在结构体内创建指针成员变量,然后再让指针指向的那一块空间动态伸缩,就也可以达到柔性数组的概念,但是同时我们开辟的空间和增容的空间也就是相当于两个动态内存,所以释放的时候要释放两次:

//方法二
struct fun
{
	int i;
	int *a;
};

int main()
{
	struct fun* ps = (struct fun*)malloc(sizeof(struct fun));
	if (ps == NULL)
	{
		printf("%s\n", strerror(errno));
		return -1;
	}
	//开辟成功
	ps->i = 100;
	ps->a = (int*)malloc(10 * sizeof(int));

	int i = 0;
	for (i = 0; i < 10; i++)
	{
		ps->a[i] = i;
	}

	//数组a空间不够用,调整内存
	int* ptr = (int*)realloc(ps->a, 20 * sizeof(int));
	if (ptr == NULL)
	{
		printf("扩容空间失败\n");
		return -1;
	}
	else
	{
		ps = ptr;
		
	}

	//释放
	free(ps->a);
	ps->a = NULL;
	free(ps);
	ps = NULL;
	return 0;
}

上面的两种写法都可以实现这样的功能,但是方法1 的实现有两个好处:

第一个好处是:方便内存释放
第二个好处是:这样有利于访问速度

1.如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。

2.连续的内存有益于提高访问速度,也有益于减少内存碎片。因为我们第一种方法就是内存是连续存放的,而对于第二种是创建了不同的空间,所以第一种提高了访问速度,同时也减少了散放的内存碎片。


好啦,本篇的内容就到这里,关于动态内存管理就到这里了,有什么问题欢迎互相关注,共同进步。

还有一件事:

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

恒等于C

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

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

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

打赏作者

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

抵扣说明:

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

余额充值