【逐步剖C】-第十一章-动态内存管理

一、为什么要有动态内存管理

从我们平常的学习经历来看,所开辟的数组一般都为固定长度大小的数组;但从很多现实需求来看需要我们开辟一个长度"可变"的数组,即这个数组的大小不能在建立数组时就指定,需要根据某个变量作为标准。

如比较常见的就是一些编程题中,输入一个变量n来作为数组的长度等(PS:虽C99支持变长数组,但我们这里主要讨论数组共性的标准 );可能还有一种情况就是在往数组中放数据时,由于一开始空间大小指定不合适,出现了空间不足的情况,此时就需要进行 "扩容"操作。

由此一来,动态内存管理应运而生。

二、动态内存函数

1、malloc和free

(1)malloc函数介绍

  • 函数的声明为
cpp 复制代码
void* malloc (size_t size);
  • 函数的作用
    这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针,通过相应类型的指针变量接收返回的指针后,即可通过该指针变量使用已开辟的内存空间
  • 函数的特性
    如果开辟成功 ,则返回一个指向开辟好空间的指针。
    如果开辟失败 ,则返回一个NULL指针,因此malloc的返回值一定要做检查。
    返回值的类型是void* ,因为malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
    如果参数 size为0,malloc的行为是标准是未定义的,取决于编译器

使用示例:

cpp 复制代码
    int n = 0;
    scanf("%d", &n);

    int* a = (int*)malloc(sizeof(int) * n);//开辟大小为n个整型的空间
  
    if (a != NULL)	//判断空间是否开辟成功
    {
        a[0] = 1;	//对空间进行使用
        //...
    }

可以看到,我们在使用时需要通过强制类型转换"告诉"编译器我们开辟空间的类型(返回指针的类型)(PS:其实空间并没有所谓的"类型"的概念,仅是通过指针的不同类型而有不同的看待空间的视角

(2)free函数介绍

  • 函数声明为
cpp 复制代码
void free (void* ptr);
  • 函数的作用
    释放动态开辟的内存
  • 函数的特性
    如果参数 ptr 指向的空间不是动态开辟 的,那free函数的行为是未定义 的。
    如果是动态开辟 的,则该函数会将ptr指向空间的使用权进行归还 ,其中有两部分,大部分归还给操作系统(真正释放),另一部分未归还操作系统的已释放内存被恢复到空闲池 (free pool)中,并可再次进行分配
    如果参数 ptrNULL指针,则函数什么事都不做。

使用示例:

cpp 复制代码
    int n = 0;
    scanf("%d", &n);

    int* a = (int*)malloc(sizeof(int) * n);//开辟大小为n个整型的空间
  
    if (a != NULL)	//判断空间是否开辟成功
    {
        a[0] = 1;	//对空间进行使用
        //...
    }

	free(a);	//释放动态开辟的内存

使用注意事项

从函数的特性中我们可以看出,在free一块动态开辟的内存后,那块空间按理来说已经不能再访问了,虽然的确可以故意去访问,当访问到的是空闲池中的内存时 可能不会有什么大问题,但若访问到了操作系统的内存程序就会崩溃 ;又因为free仅负责将动态开辟的空间的使用权进行归还,并不对用于接收这块空间起始地址的指针进行处理 (示例中a再free之后仍指向那块空间),故为了内存访问的安全,在free之后需对相应的指针进行置空操作 。(示例中最后还需加上a = NULL

2、calloc

(1) 函数的声明为

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

(2)函数的作用

作用和malloc一样,唯一不同的是它可以将动态开辟好的空间进行初始化,即将开辟好的num个空间的值都初始化为0。

(3)函数的特性

同malloc,但多了一个初始化功能。

使用示例:

cpp 复制代码
    int n = 0;
    scanf("%d", &n);

    int* a = (int*)calloc(n,sizeof(int));//开辟大小为n个整型的空间

3、realloc

(1) 函数的声明为:

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

(2)函数的作用

对动态开辟内存大小进行调整,一般用于给已开辟的空间进行扩容。

(3)函数的特性

参数ptr是要调整的内存地址,size是调整之后的新大小(一般即为新空间的大小为原空间大小+需要扩展的空间的大小)。

若参数size为0或空间开辟失败时,函数返回NULL

若参数ptrNULL,该函数等价于malloc,如:

cpp 复制代码
int*p = NULL;
p = realloc(ptr, 1000);

空间开辟成功会分为两种情况

  • 原开辟空间后有足够空间可以扩容 ,那么函数直接在原有内存之后直接追加空间,原来空间的数据不发生变化,返回旧的起始地址(ptr的值)

  • 原开辟空间空间无法满足扩容需求 ,函数会先在堆空间上另找一个新的合适大小的连续空间来使用,接着把原空间的数据拷贝至新空间前面的位置,并把原空间释放 ,最后返回一个新空间的内存地址

从特性中我们得到使用时的重要一点:
我们在对原空间进行扩容时,需用一个同类型的新指针来接收扩容之后返回的指针,以防分配失败而造成原有数据的丢失,如:

cpp 复制代码
int *ptr = (int*)malloc(100);
ptr = (int*)realloc(ptr, 1000);

//如上代码中,若realloc分配失败放回空指针,ptr所指向的原空间数据丢失
//正确应写为:

int *ptr = (int*)malloc(100);
int *tmp = (int*)realloc(ptr, 1000);
if(tmp != NULL)
{
	ptr = tmp;
}

补充一点

realloc函数一般仅用于对内存进行扩展 ,也就是扩容;很少会用于缩容,并且缩容会存在一些问题,并且可能不能达到我们预期的效果。如下通过VS2022下的调试说明一下:

用于调试的代码:

cpp 复制代码
int main()
{
    int* p = (int*)malloc(10 * sizeof(int));
    p[5] = 1;
    p = (int*)realloc(p, 5*sizeof(int));
    p[5] = 2;
    return 0;
}

分配10个整型的空间后,将第6个整型空间的值改为1,没问题;接下来将p的空间缩减为5个整型空间:

可以看到,后5个整型空间好像确实是回收给系统了,那么接下来执行a[5] = 2;应为越界访问 的行为,系统按理来说会报错 ,但实际是:

仍完成了对原空间第6个整型空间的值的更改,这就与我们的预期效果大相径庭了。故一般不用realloc来进行缩容。

三、常见动态内存错误

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

若对动态开辟返回的指针不做检查就可能发生,如:

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

2、对动态开辟的内存空间进行了越界访问

和数组越界访问的问题类型:

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

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

cpp 复制代码
int a = 10;
int *p = &a;
free(p);

如上代码运行后系统崩溃:

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

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

如上代码运行后系统崩溃:

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

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

如上代码运行后系统崩溃:

6、使用完动态开辟的内存后忘记释放

若使用完动态开辟的空间后没有通过free函数进行内存释放,就会造成恐怖的内存泄露 问题,体现在我们的程序中可能没什么大问题(程序会结束或关闭,顺带着内存就会回收);但若体现在一些长时间不停机服务器中,就会造成服务器越用越卡直至死机的严重后果。

四、关于内存管理的经典题目

了解完动态内存管理的基础知识后,可以看看一些关于内存管理的经典题目来趁热打铁,请看:
1、运行Test 函数的结果是

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

结果

程序会崩溃。
原因
解引用了空指针 。在GetMemory函数中动态分配内存后返回的指针赋值给了p,但由于形参对实参的临时拷贝,故改变了p并不影响str,故在GetMemory函数调用结束后,str的值仍为NULL,在进行strcpy时发生了错误。

2、运行Test 函数的结果是

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

结果

程序会打印"烫烫烫烫烫烫..."的乱码
原因
返回栈空间地址问题 。GetMemory中p为局部变量,其在出了GetMemory函数作用域后会销毁 ,销毁后其原指向的空间的值就为随机值 ,也就是说用str接收的指针所指向的空间随机值,故再以打印字符串的方式去打印str的内容时就会乱码。

3、运行Test 函数的结果是

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

结果

正常输出hello
原因

其实这才是相对于题目1的正确写法,由于实参本身就是一个指针,故需要一个二级指针来实现改变形参而改变实参

4、运行Test 函数的结果是

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

结果

看似正常输出了world
原因

在介绍free函数时有说到,free仅将动态开辟的空间的使用权还给系统,但并不改变用于"接收"(指向)这块空间的指针变量的值。也就是说,代码中的str在free后的值仍未原空间的起始地址,不为空,但此时对原空间已没有使用权 ,故在进行strcpy时本质上已经是非法访问了内存空间,只是可能访问到的是空闲池而程序没有崩溃。

五、C/C++内存区域划分

C/C++内存区域主要划分为如下几个区域:

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

2、 堆区(heap):一般由程序员申请分配与释放, 若程序员不释放,程序结束时可能由操作系统回收 。

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

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

六、拓展:柔性数组

1、定义

(1)概念 :C99 中,结构中的最后一个元素 允许是未知大小的数组,该数组就称为柔性数组成员

(2)定义方式

cpp 复制代码
typedef struct st_type
{
	int i;
	int a[0];//柔性数组成员
}type_a;

cpp 复制代码
typedef struct st_type
{
	int i;
	int a[];//柔性数组成员
}type_a;

2、柔性数组的特点

其实包含柔性数组成员的结构体的特点,主要有如下三点:

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

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

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

3、柔性数组的使用及优势

(1)柔性数组的使用

柔性数组的使用主要在于对需要开辟的空间大小的把握,空间正确开辟后,和正常作为结构体成员的数组一样使用即可。下面是使用示例,请看:

cpp 复制代码
type_a *p = (type_a*)malloc(sizeof(type_a)+10*sizeof(int));
//这里的柔性数组成员相当于获得了10个连续的整型空间

//和正常数组一样使用即可
for(int i=0; i<10; i++)
{
	p->a[i] = i;
}

free(p);

(2)与指针成员相比的优势

如上的结构体type_a 也可设计为:

cpp 复制代码
typedef struct st_type
{
	int i;
	int* p_a;
}type_a;

这样在分配和释放空间时就会相对麻烦一些:

cpp 复制代码
type_a *p = (type_a *)malloc(sizeof(type_a));
//先为整个结构体分配空间

p->p_a = (int *)malloc(p->10 *sizeof(int));
//才能为里面的指针成员p分配空间

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

//要先释放指针成员的空间
free(p->p_a);
p->p_a = NULL;
//再释放整个结构体的空间
free(p);
p = NULL;

相比之下,我们可以得到柔性数组成员的两个优势:

(1)方便内存释放
有柔性数组成员的结构体 在释放空间时仅需将为整个结构体分配的内存释放即可;而有指针成员的结构体 在释放空间时需先释放指针为指针成员开辟的空间,才能释放为整个结构体开的空间,否则就会造成内存泄漏。

此时若是我们自己写的代码可能知道要先释放结构体指针成员的空间,但如果写的代码是给别人用时,用户可能就想着释放结构体就行,而不再会去在意结构体里有什么。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉,降低了内存泄漏的风险。

(2)一定程度上提高了内存访问速度
柔性数组成员 的地址空间相对于整个结构体成员的空间地址是连续 的,而指针成员 则是碎片化的;如下示意图:

  • 柔性数组成员
  • 指针成员

连续的内存有益于提高访问速度。

本章完。

看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或看不懂的地方或有可优化的部分还恳请朋友们留个评论,多多指点,谢谢朋友们!🌹🌹🌹

相关推荐
Kisorge4 分钟前
【C语言】指针数组、数组指针、函数指针、指针函数、函数指针数组、回调函数
c语言·开发语言
xiaoshiguang34 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡4 小时前
【C语言】判断回文
c语言·学习·算法
别NULL4 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇4 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
CYBEREXP20085 小时前
MacOS M3源代码编译Qt6.8.1
c++·qt·macos
yuanbenshidiaos5 小时前
c++------------------函数
开发语言·c++
yuanbenshidiaos5 小时前
C++----------函数的调用机制
java·c++·算法
唐叔在学习5 小时前
【唐叔学算法】第21天:超越比较-计数排序、桶排序与基数排序的Java实践及性能剖析
数据结构·算法·排序算法
ALISHENGYA6 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(switch语句)
数据结构·算法