动态内存分配

本文介绍了C语言动态内存管理的相关知识。首先阐述了动态内存管理存在的原因,接着详细讲解了malloc、calloc、realloc、free四个管理函数,还提及了内存泄漏、内存池概念,分析了常见动态内存错误,最后介绍了动态内存实现“通讯录”动态扩容及经典笔试题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、为什么存在动态内存管理

       局部变量以及数组(开辟连续空间)的定义是我们比较熟悉的开辟内存空间方式,有两个特点:一是空间开辟的大小是固定的;二是数组在申明的时候必须指定数组的长度,所需内存都是在编译时分配。

       实际上对于空间的需求不仅限于上述情况,有时候我们需要的空间大小在程序运行的时候才能知道,比如结构体数组存放通讯录信息,如果定义固定大小空间的结构体数组,那么存放的通讯录信息数是固定的,局限性很大,要么浪费空间要么空间不够。显然我们需要更灵活的空间开辟方式,可以随时调整大小,这就需要用到动态内存分配。C语言允许建立内存动态分配区域,以存放一些临时用的数据,这些数据不必在程序的声明部分定义,也不必等到函数结束时才释放,而是需要时随时开辟,不需要时随时释放。这些数据是临时存放在一个特别的自由存储区——堆区。可以根据需要,向系统申请所需大小的空间。由于未在声明部分定义它们为变量或数组,因此不能通过变量名或数组名去引用这些数据,只能通过指针来引用。

       由此可见,动态内存的管理是具有一定的必然性的,动态内存是在堆区上开辟空间,可以被修改;而栈区中申请的空间是无法被修改的。

数据结构三大基础:结构体、指针、动态内存!

二、动态内存管理函数

       动态内存分配的核心就是malloc,calloc,realloc,free这4个函数。

2.1 malloc

#include<stdlib.h>                //使用此函数需要包含的头文件

void  *malloc(size_t size);

size:需要分配的内存大小,以字节为单位。

返回值:返回值为void *类型,如果申请分配内存成功,将返回一个指向该段内存的指针,void *并不是说没有返回值或者返回空指针,而是返回的指针类型未知,所以在调用malloc()时通常需要进行强制类型转换,将void *指针类型转换成我们希望的类型;如果分配内存失败(比如系统堆内存不足)将返回NULL, 如果参数size为0,返回值也是NULL。

        malloc的正确使用例程:

图2.1 malloc使用例程

       malloc使用时有三处注意事项,见上图标记:一是malloc返回值为void*型,使用时需要强制类型转换为我们需要的类型;二是申请失败返回空指针判断机制,无论是malloc还是realloc和calloc;三是空间使用完毕后要free释放掉,并将指针赋值为NULL,防止产生野指针。

       下图是free释放内存空间后指针不赋值NULL的调试结果:

图2.2 free释放空间指针不赋值NULL调试结果

       在free处添加断点,程序运行至free(p)释放内存后p指向的地址还是之前申请的内存起始地址,虽然free释放掉这块空间,但p的值没变,还是能通过p访问到这块内存空间(野指针),这是很危险的,为了防止P成为野指针,在free(p)后必须给p赋空指针,即p = NULL。

需注意动态内存分配与变长数组概念上的区别,变长数组不是指长度可变,而是数组在指定大小时可以用变量即int arr[ n ](C99标准支持变长数组)。 

2.2 calloc

#include<stdlib.h>                //使用此函数需要包含的头文件

void  *calloc(size_t num,size_t size);

参数:calloc()在堆中动态地分配num个长度为size的连续空间,并将每一个字节都初始化为0。

返回值:返回值为void *类型,如果申请分配内存成功,将返回一个指向该段内存的指针,同样在调用calloc()时需要进行强制类型转换,将void *指针类型转换成我们希望的类型;如果分配内存失败(比如系统堆内存不足)将返回NULL。

       calloc与malloc的区别就是calloc由两个参数共同决定所开辟空间的大小,并且calloc在开辟空间后会将所开辟的空间全部初始化为0,calloc = malloc + memset。calloc的正确使用例程及其初始化特性如下图所示:

图2.3 calloc使用例程

        编程时malloc和calloc的选择就看你是否想初始化了。

2.3 realloc

       如果已经通过malloc函数或calloc函数获得了动态空间,想改变其大小,可以用realloc函数调整空间大小,realloc函数让动态内存管理更加灵活。

#include<stdlib.h>                //使用此函数需要包含的头文件

void  *realloc(void* ptr,size_t size);

ptr:指向将要被调整大小的空间的指针。

size:调整后新的大小,单位为字节。

返回值:返回值为void *类型,如果调整成功,将返回指向新空间内存的指针(可能还是原地址也可能是新地址),同样在调用realloc()时需要进行强制类型转换,将void *指针类型转换成我们希望的类型;如果调整失败将返回NULL。

realloc(NULL,40) = malloc(40)。

       首先我们要知道堆区中内存分配是一块一块的,有些可能已经被占用,这就导致realloc扩展内存空间有两种情况:

       (1)原有空间后存在足够大的空间:realloc扩展机制是在原空间的基础上直接向后追加空间,也还是原来的指针维护这块空间,如下图所示:

图2.4 原空间后有足够空间情况

       (2)原先申请的内存空间后面没有足够的空间来扩展,realloc的处理机制是在堆区重新找一块足够大的内存空间,把原空间存放的数据拷贝到新空间,后面追加新的空间,realloc也会释放原来的内存空间(不需要程序员释放),最后返回新空间首地址,所以一般要用新的指针来接收realloc返回值,而避免使用原来malloc或者calloc申请内存使用的指针。内存申请示意图如下所示:

图2.5 原空间后没有足够空间情况

       realloc正确使用例程:

图2.6 realloc使用例程

       一般使用一个中间指针变量来接收realloc函数的返回值,不为NULL时再将其赋值给指向之前用malloc或calloc申请的空间,也可以再定义一个新的指针变量来维护调整后的内存空间,但是要注意先前指针是否会成为野指针。

2.4 free

       free是专门用来回收释放动态内存开辟的空间的函数,需要注意的是free释放一个指针维护的那一块空间时不会连同将该指针置为NULL,这个在上面malloc章节已经调试验证过,因此为避免野指针的出现程序员应该在free后紧接着将指针赋为NULL,参考图2.1例程。

#include<stdlib.h>                //使用此函数需要包含的头文件

void  free(void* ptr);

ptr:指向需要被释放的堆内存对应的指针。

三、内存泄漏、内存池概念

       内存泄漏就是:向内存申请了一块空间,用完后不释放还给操作系统,别人想用也用不到,如果一直不还,那么这块空间就相当于不存在了,也就是理论上泄漏掉了。当然这块被申请的空间不会一直不还,当程序结束时操作系统会自动回收这块空间。内存泄漏会造成系统内存浪费、程序运行缓慢、甚至系统崩溃等严重后果。

调用free还是不调用free?

       对于内存来说,当进程终止时,内核会将其占用的所有内存都返还给操作系统,这包括在堆内存中由malloc()等函数所分配的内存空间。基于内存的这一自动释放机制,很多应用程序通常会省略对free()函数的调用。

       这在程序中分配了多块内存的情况下可能会特别有用,因为加入多次对free()的调用不但会消耗大量的CPU时间,而且可能会使代码趋于复杂。

       虽然依靠终止进程来自动释放内存对大多数程序来说是可以接受的,但最好能够在程序中显式调用 free()释放内存,首先其一,显式调用free()能使程序具有更好的可读性和可维护性;其二,对于很多程序来说,申请的内存并不是在程序的生命周期中一直需要,大多数情况下,都是根据代码需求动态申请、释放的, 如果申请的内存对程序来说已经不再需要了,那么就已经把它释放、归还给操作系统,如果持续占用,将会导致内存泄漏,也就是人们常说的“你的程序在吃内存”!

       在堆区上开辟空间,空间之间是有间隙(内存碎片)的。如果在内存中频繁的使用malloc,就会使得内存中存在非常多的内存碎片,如果这些内存碎片在之后没能有效利用的话,就会导致内存利用率和效率的下降。为了解决这个问题,引入“内存池”概念:通过程序来维护内存空间,即首先向内存申请一块相对来说能够满足当前需求的空间,然后程序内部用内存池的方式来维护这段空间,如此就不用频繁的申请而打扰操作系统了,且解决了内存碎片的问题。

四、常见动态内存错误

4.1 对NULL的解引用操作

       使用malloc、calloc、realloc申请空间时没有对返回值是否为NULL添加判断机制,导致对NULL解引用操作。解决办法就是申请空间后判断返回值是否为NULL再操作内存空间,正确代码2.2.1已经介绍,编写相关代码时都要按此格式形成良好习惯。

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

       数组越界访问时编译器不会报错,且越界访问能成功,有时会造成意想不到的结果(一个利用数据越界造成死循环的案例)。而对于动态内存开辟空间的越界访问时不被允许的,操作系统会直接报错。

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

       属于醉酒狂暴操作,不被允许,程序运行出错。

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

       有时在使用维护动态开辟空间的指针来编写代码时,会在无意中移动这个指针,当想要用该指针释放动态内存时,该指针已然不指向该内存的起始位置,就很容易造成释放动态开辟内存的一部分,依然是不被允许的,程序运行出错。要注意给free的指针必须是指向动态开辟空间的起始地址。编程时用(p+i)而不用p++来移动指针,就可以使p不变,前者才是正确写法。

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

       如果使用free对一块动态内存开辟空间释放一次后没有将其置NULL,再使用free释放一次程序会运行报错,为了避免这种情况就要养成良好的习惯,老生常谈,就是要在free释放空间后紧接着将指针置NULL,这样free什么事都不做。

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

       代码逻辑不够严谨,可能写了free释放内存,但是代码可能根本执行不到那里去就会导致内存泄漏;或者是直接忘记使用free释放内存,所以涉及到申请动态内存空间时一定要仔细检查逻辑是否会造成没有释放的窘境。

五、动态内存实现“通讯录”动态扩容

       没有掌握如何使用动态内存相关函数开辟动态内存空间之前,想要实现一个通讯录必然只能在栈区定义一个固定大小的结构体数组,也只能存放固定人数的通讯信息,就势必会存在空间浪费或者不够用的情况。而使用动态内存管理就可以解决这个窘境,且提高内存的利用率。

       动态内存版通讯录代码的设计思想是:先开辟一个不那么大的空间例如只能存放5个人的信息,因为后面随时要扩容,肯定不能是定义一个结构体数组来存放通讯信息,而要定义一个结构体指针来接收malloc初试开辟和realloc随时调整动态内存空间的起始地址;然后还要定义变量存放已保存的通讯信息个数和总容量,因为要知道什么时候需要realloc进行扩容,在已经保存满了也就是已保存信息个数等于总容量时就要扩容了,假设每次扩容2个人的信息。

完整代码实现:

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

typedef struct Peo
{
	char name[20];
	char sex[5];
	int age;
	char tel[12];
	char addr[30];
}Peo;

typedef struct Contact
{
	Peo* ptr;			//动态空间指针
	int num;			//通讯录已存放人员个数
	int cap;			//通讯录总容量
}Contact;

void menu()
{
	printf("************************************************\n");
	printf("****************  1.添加成员    ****************\n");
	printf("****************  2.打印成员    ****************\n");
	printf("************************************************\n");
}

void print(Contact* p)
{
	int i = 0;
	printf("%-3s %-5s %-3s %-12s %-20s\n", "姓名", "性别", "年龄", "电话", "地址");
	for (i = 0; i < p->num; i++)
	{
		printf("%-3s %-5s %-3d %-12s %-20s\n", (p->ptr + i)->name, (p->ptr + i)->sex, (p->ptr + i)->age, (p->ptr + i)->tel, (p->ptr + i)->addr);
	}
	printf("\n");
	printf("总容量 = %d\n",p->cap);
	printf("已存放个数 = %d\n",p->num);
}

void add_cont(Contact* p)
{
	if (p->num == p->cap)
	{
		Peo* ptr = (Peo*)realloc(p->ptr, (p->cap + 2) * sizeof(Peo));		//扩容两个
		if (ptr == NULL)
		{
			printf("%s\n", strerror(errno));
			return -1;
		}
		p->ptr = ptr;
		p->cap += 2;
		printf("扩容成功\n");
	}
	printf("开始添加成员\n");
	printf("请输入姓名:\n");
	scanf("%s", (p->ptr + p->num)->name);
	printf("请输入性别:\n");
	scanf("%s", (p->ptr + p->num)->sex);
	printf("请输入年龄:\n");
	scanf("%d", &((p->ptr + p->num)->age));
	printf("请输入电话:\n");
	scanf("%s", (p->ptr + p->num)->tel);
	printf("请输入地址:\n");
	scanf("%s", (p->ptr + p->num)->addr);
	p->num += 1;
	printf("添加成员成功\n");
}

int main()
{
	Contact con;
	int i = 0;
	int input = 0;

	con.ptr = (Peo*)calloc(5, sizeof(Peo));		//开辟能够存放5个人信息的动态空间
	if (con.ptr == NULL)
	{
		printf("%s\n", strerror(errno));
		return -1;
	}
	con.cap = 5;
	con.num = 4;
	for (i = 0; i < 4; i++)			//初始化4个人信息
	{
		strcpy((con.ptr + i)->name, "abc");
		strcpy((con.ptr + i)->sex, "nan");
		(con.ptr + i)->age = i + 18;
		strcpy((con.ptr + i)->tel, "123456789");
		strcpy((con.ptr + i)->addr, "jiangxishengjiujiang");
	}
	do
	{
		menu();
		printf("请选择:>\n");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			add_cont(&con);
			break;
		case 2:
			print(&con);
			break;
		case 0:
			printf("退出\n");
			break;
		default:
			printf("选择错误,请重新选择\n");
			break;
		}
	} while (input);

	free(con.ptr);
	con.ptr = NULL;

	return 0;
}

写代码时犯的错误(耗时较长才解决):

       代码86~93行初始化动态内存空间写出了两种错误,对于我这种编程小菜鸟应该是很容易犯的错误,特在此记录一下,谨防再犯。

(con.ptr + i)->name  = "abc";
(con.ptr + i)->sex = "nan";
(con.ptr + i)->age = i+18;
(con.ptr + i)->tel = "123456789";
(con.ptr + i)->addr = "jiangxishengjiujiang";

       第一种写法一直报错左边值不可修改,后来想到左边是地址,确实不可修改恍然大悟,想到字符数组初始化长这样的:char str[10] = "abcdefgh",于是写出了第二种错误:

(con.ptr + i)->name[20] = "abc";
(con.ptr + i)->sex[5] = "nan";
(con.ptr + i)->age = i + 18;
(con.ptr + i)->tel[12] = "123456789";
(con.ptr + i)->addr[30] = "jiangxishengjiujiang";

       这种写法编译不报错但是有警告,提示左边与右边类型不符,程序运行也没达到预期结果。后来想到结构体引用成员时怎么能带上[ ]呢。还好经过短暂的思考,终于想到了strcpy字符串拷贝函数,瞬间不困惑了。

测试结果: 

图2.7 程序运行结果

六、动态内存开辟的几个经典笔试题

6.1 题一

       以下例程运行Test()函数会有什么样的结果?

void Getmemory(char* p)
{
	p = (char*)malloc(100);
}
void Test()
{
	char* str = NULL;
	Getmemory(str);
	strcpy(str, "hello world");
	printf(str);
}

       分析:代码第8行将str指针变量作为实参传给Getmemory函数,形参p接收,然后用p维护malloc开辟的100字节空间,此题容易造成的错误认知就是p被修改会让str也跟着被修改。形参p只是str的一份临时拷贝,修改p并不会对str造成任何影响,str仍为空指针,所以调用strcpy函数时会报错。并且执行完Getmemory函数后形参p被销毁,会导致动态开辟空间无法释放造成内存泄漏。

       不要以为传过去的是指针改变形参就会改变实参的值,而是解引用形参才会改变实参的值。所以此题的一个正确写法就是:

void Getmemory(char** p)
{
	*p = (char*)malloc(100);
}
void Test()
{
	char* str = NULL;
	Getmemory(&str);
	strcpy(str, "hello world");
	printf(str);
}

       还有一种改法就是利用函数的返回值返回开辟动态内存的起始地址,此处不作详述。另外代码中对printf的这种使用不要陌生,以下三种写法是等效的:

图2.8 printf的三种写法

6.2 题二

       以下例程会成功打印出“hello,Jiangxi”吗?

char* Getmemory(void)
{
	char p[] = "hello,Jiangxi";
	return p;
}
void Test(void)
{
	char* str = NULL;
	str = Getmemory();
	printf(str);
}

       此题是对野指针的考察,Getmemory函数创建字符数组并将首地址p返回,但是函数执行完毕后字符数组那块空间就被销毁了,str成了野指针,仍然指向那块空间,但是内存空间可能已经被别的进程占用,所以打印出来的一般是乱码。

6.3 题三

       以下例程有什么问题?

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

       此题是对free释放动态开辟空间后要紧接着置NULL以及野指针的考察:free释放后指针若不置NULL就会变成野指针,后面执行strcpy时就会造成内存非法访问。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值