Bootstrap

【C语言】动态内存管理

1、为什么存在动态内存分配?

❔首先,让我们思考一下,为什么需要动态内存管理,它有什么优势吗?

我们当前使用的内存开辟方式

int var;//在栈区开辟四个字节
char str[10];//在栈区开辟10个字节的连续内存单元
  • 通过观察可以发现,上面开辟空间的方式有以下特点:
  1. 开辟空间大小固定
  2. 必须在声明时给定大小,在编译时分配内存

❔那么这样的特点会造成什么样的缺陷呢?

  • 例如我需要统计班上n名同学的期末考试成绩,对于这种情况,我们不知道需要开辟多少空间以供使用,如果开辟空间少了会造成数组越界,开辟多了会造成空间浪费。
#include<stdio.h>
#include<stdlib.h>

int stu[30];//空间大小固定,当数据量超过开辟空间大小,会造成数组越界
int main()
{
	int n; 
	scanf("%d", &n);
	for (int i = 0; i < n; i++)
	{
		int score; scanf("%d", &score);
		stu[i] = score;
	}

	return 0;
}

☑️对于这种情况,就需要动态内存管理出手了。

2、动态内存管理函数介绍

在这里,我们将介绍malloccallocrealloc三个函数,以及内存释放函数free

(1)malloc

  • mallocmemory allocation的简写,意为内存分配。

  • 让我们来看看malloc函数的函数原型文档信息

函数原型

void* malloc (size_t size);

🔸返回类型void*
🔸参数类型size_t(无符号整形)

文档信息

在这里插入图片描述

  • 通过阅读函数原型和文档信息,我们可以得出以下特性。
  1. malloc函数返回类型为void*,不能识别开辟空间的类型,因此需要用户自主决定类型。

✔️这里我们通常会使用强制类型转换来决定开辟空间的类型

  • 例如这里我想开辟10个整形的连续内存空间单元,int*malloc开辟的内存空间进行强制类型转换。
int *a = (int*)malloc(10);

这10个内存空间都存储了什么呢,malloc有对其进行初始化操作吗?
👇🏼那么就引出了第二条特性。

  1. malloc函数只能分配内存空间,并不能对内存空间的元素初始化。
  • 可以看到malloc成功开辟出10个int类型的内存空间单元,但开辟的空间元素均为随机值
    在这里插入图片描述
    ✔️因此对于malloc申请的空间,需要我们手动初始化。
#include<stdio.h>
#include<stdlib.h>

int main()
{
	//开辟十个整形大小的内存空间
	int* a = (int*)malloc(sizeof(int)*10);
	//手动初始化
	for (int i = 0; i < 10; i++)
	{
		*(a + i) = i + 1;
	}
	return 0;
}
  • 调试可以看到我们开辟的10个整形内存空间存放了10个整形数据
    请添加图片描述

那么问题来了,malloc是否会申请空间失败呢,失败了又会作何处理呢?

  • 可以看到,当我们申请-1内存空间大小单元,a指针接收的地址为0x0000000000000000,是一个空地址,表示malloc申请空间失败。
    在这里插入图片描述

👇🏼那么就引出了第三条特性。

  1. 如果内存空间开辟成功,则返回该内存空间的起始位置;否则返回空指针NULL,注意该空指针不应该被解引用。

✔️正因为会有开辟失败的情况,因此需要对malloc的返回值进行检查,避免解引用空指针

  • 这里使用perror函数,用于将错误信息输出到标准设备(stderr),方便开发者定位错误。
int *a = (int*)malloc(10);
if (a == NULL)
{
	perror("malloc fail!");
	exit(-1);
}
  • 例如开辟-1内存空间大小的整形空间,可以看到输出窗口打印了错误信息。
    在这里插入图片描述

有同学会问,什么情况malloc开辟内存空间会失败呢?
⚫️开辟内存空间过大
开辟的内存空间超过了当前环境(编译器版本,程序的内存寻址64位、32位)下堆的最大可分配内存。

⚫️内存碎片导致无法开辟大空间
长时间使用动态内存分配和释放可能导致内存碎片,使得虽然总体上有足够的内存,但是无法找到足够大的连续内存块。

⚫️开辟内存空间大小不合法
例如开辟空间大小为0的内存空间。

  1. 如果参数 size0malloc的行为是标准是未定义的,取决于编译器。
  • 对于这种情况,我们来测试一下。请添加图片描述
    ✔️可以看到,虽然没有申请到任何空间,但是指针a不为NULL,即malloc仍然返回了一块地址。(不同的编译器会有不同的结果,博主使用的是vs2022)
  1. malloc开辟的内存空间会在程序结束时自动归还给操作系统。

那么问题来了,如果我在程序中使用后不再需要这块内存空间,没有立即释放会导致什么问题呢

  • 内存泄漏(Memory Leak):内存泄漏是指程序在申请内存后,未能在不再需要时正确释放,导致这部分内存无法被再次使用。随着程序的运行,内存泄漏会逐渐累积,可能导致可用内存减少。
  • 程序性能下降:如果内存泄漏严重,程序可能会因为可用内存不足而频繁触发内存分配和回收(这里主要是realloc的分配回收),这会降低程序的性能。

❔有没有在使用完毕后立即释放该空间的方法呢?
👇🏼那么就引出了free函数来解决这个问题。

(2)free

函数原型

void free (void* ptr);

🔸返回类型void
🔸参数类型void*

文档信息

在这里插入图片描述

  • 通过阅读函数原型和文档信息,我们可以得出以下特性。
  1. 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的
  • 观察下面代码,我们尝试用free去释放一个在栈上分配的内存。
#include<stdio.h>
#include<stdlib.h>

int main()
{
	int a = 1;
	int* b = &a;
	if (a == NULL)
	{
		perror("malloc fail!");
		exit(-1);
	}
	free(b);

	return 0;
}
  • 可以看到,这里出现了断言错误,错误信息指出表达式 _CrtIsValidHeapPointer(block) 失败,其中 block 是传递给 free 函数的指针。

☑️因为局部变量a是在栈上分配的,不是通过malloccallocrealloc在堆上分配的。因此a在程序结束时会自动被编译器回收,不应该让开发者手动释放
在这里插入图片描述

  1. 如果传入的指针是空指针,free函数将不做任何操作。
  2. free函数无法修改指针本身指向内存块的地址。

我们来测试一下

  • 可以看到,当指针afree函数释放后,初始化的1~10变为了随机值,但指针a指向的仍然是被分配内存块的地址。

✔️使用free函数释放指针a指向的内存块,则这块空间归还给了操作系统,因此被初始化的1~10变为了随机值,但是指针a仍然指向原来内存块的地址,这意味着a指针变为了”野指针“。这是很不安全的。
请添加图片描述
☑️为了解决问题,开发者应当手动给a指针置空,这样就能避免野指针的产生。

a=NULL;
  • 置空后就不会指向原内存块的地址了
    在这里插入图片描述

🔹需要注意的是,mallocfree函数都在头文件stdlib.h中,记得要包含头文件哦

  • 到这里,我们就可以解决上面的给n个学生统计成绩的问题了。
#include<stdio.h>
#include<stdlib.h>

//int stu[30];//空间大小固定,当数据量超过开辟空间大小,会造成数组越界
int main()
{
	int n;
	scanf("%d", &n);

	//分配n个整形大小的内存空间
	int* stu = (int*)malloc(sizeof(int) * n);
	if (stu == NULL)
	{
		perror("malloc fail!");
		exit(-1);
	}
	for (int i = 0; i < n; i++)
	{
		int score; scanf("%d", &score);
		stu[i] = score;
	}
	//释放内存块
	free(stu);
	//指针置空,防止出现野指针
	stu = NULL;

	return 0;
}

🔴我们来小结一下,使用malloc分配内存要记得判空,预防malloc申请失败,使用完这块空间后要记得将其归还给操作系统,使用free函数释放内存空间,最后给指针置空,防止出现野指针。

(3)calloc

函数原型

void* calloc (size_t num, size_t size);

返回类型void*
参数类型size_t(无符号整形)
num为要开辟内存块中的元素个数size为每个元素分配的字节数

文档信息
在这里插入图片描述
特性

  1. num个字节数为size的元素开辟一块内存,所有元素初始化为0。
  2. malloc功能不同的地方在于calloc可以将内存块初始化为0。

我们来测试一下

请添加图片描述

(4)realloc

函数原型】:

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

返回类型void*
参数类型void*size_t(无符号整形)
ptr要调整的内存地址size是调整之后的新大小

文档信息
在这里插入图片描述

特性

  1. 可用于重新分配原内存块的大小(有可能把内存移动到一个新位置)
  2. 如果传入的指针为空指针,realloc()的作用和malloc()一致
  3. 如果需要缩小内存块大小,则在原内存块释放一部分空间

请添加图片描述

🟤扩容机制

  • 本地扩容
    当前内存块的后面就有足够的空间可以扩容,此时直接在后面续上新的空间即可

  • 异地扩容
    当后边没有足够的空间可以扩容realloc函数会找一个满足空间大小的新的连续空间。把旧的空间的数据,拷贝到新空间的前面的位置,并且把旧的空间释放掉(无需手动释放),同时返回新的空间的地址

在这里插入图片描述

注意:如果扩容失败了,relloc会返回一个NULL指针,因此当扩容失败我们不能在其上面赋值。这里要加个判断,别忘了把tmp指针置空。

int* tmp=(int*)relloc(p,sizeof(int)*20);

if(tmp == NULL)
{
	perror("relloc fail!");
	exit(-1);
}

p = tmp;
tmp = NULL;

3、常见动态内存错误

(1)使用free释放动态内存开辟的一部分空间

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

int main()
{
	int* p = (int*)malloc(sizeof(int) * 100);

	for (int i = 0; i < 100; i++)
	{
		*p = i;
	}

	int* origin = p;

	for (int i = 0; i < 10; i++) 
	{
		p++;
	}

	free(p);

	return 0;
}

在这里插入图片描述

  • 为什么当p指针偏移后再使用free释放会出现报错呢?
    ✔️这是因为free函数需要做到申请多少空间就释放多少空间,因此释放了一部分空间后,就会超出申请的空间外,造成内存访问错误。

(2)对同一块动态开辟内存多次释放

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

int main()
{
	int* p = (int*)malloc(sizeof(int) * 100);

	free(p);

	free(p);

	return 0;
}

在这里插入图片描述

  • 可以看到,当对同一块内存块释放多次时出现了报错。这是为什么呢?
    ✔️这是因为当调用第一次free()时,指针p指向的内存块的数据已经还给操作系统了,此时p指针就成为了一个野指针,当我们再次使用free()释放p时就会造成释放【野指针】的错误
#include<stdio.h>
#include<stdlib.h>

int main()
{
	int* p = (int*)malloc(sizeof(int) * 100);

	free(p);

	p = NULL;

	free(p);

	return 0;
}

☑️这里我们只需要给p指针置空即可,这样就不会出现报错了

(3)向堆区申请空间后忘记释放

#include<stdio.h>

void test() 
{
	int* p = (int*)malloc(sizeof(int) * 100);

}

int main()
{
	test();

	return 0;
}
  • 观察上面代码,可以发现主函数调用了test函数,test函数中向堆区申请了100个整形大小的空间,但是并没有释放,那么这就会造成【内存泄漏】的问题。
#include<stdio.h>

void test() 
{
	int* p = (int*)malloc(sizeof(int) * 100);
	
	free(p);
	
	p = NULL;
}

int main()
{
	test();

	return 0;
}

☑️这里我们只需要在test函数中释放p指向的内存块,再给p指针置空即可

4、笔试题分析

(1)题目一

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

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

void Test(void)
{
	char* str = NULL;
	str = GetMemory();
	//为什么会销毁?
	//因为 p指针 出了函数栈帧后会销毁,此时p指针指向的内存块中的内容呗操作系统回收,因此打印出错
	printf(str);
}

int main()
{
	Test();

	return 0;
}
  • 观察以上代码,其中有一个错误,分析错误的地方和原因。

✔️分析可知这是栈空间地址问题Test()函数中调用了GetMemory()函数并p字符数组的首元素地址,此时str指针接收了p数组的首元素地址,但是GetMemory()函数调用结束后,局部变量p字符数组出了函数作用域,被操作系统回收,p数组的内容被清除,因此打印str时出现错误。

请添加图片描述

(2)题目二

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

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;
}
  • 观察以上代码,其中有一个错误,分析错误的地方和原因。

✔️分析可知,这段代码是可以编译运行打印出world的,但是这是非法的,因为str指向一块动态开辟内存的空间,使用free()函数释放该空间后,str指向内存块的空间被销毁了,但str指针仍然指向被原来的内存块。接下来的操作就是非法的了,我们向一个已经被释放的空间插入字符串,这就造成了【非法访问内存】问题。

☑️正确做法是给str指针置空即可

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

void Test(void)
{
	char* str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);
	//问题:没有置空
	//虽然运行没有出错但是 其实是非法访问
	//正确做法:加上置空
	str = NULL;

	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
}

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

5、柔性数组

(1)概念和声明

柔性数组,也称为伸缩性数组成员,是C99标准中引入的一种结构体特性。它允许在结构体定义的最后一个元素声明一个未知大小(可动态分配)的数组,使得结构体能够处理可变长度的数据

🟤柔性数组的声明有两种形式

  • 结构体最后一个成员为 数组名[0] 的形式
typedef struct st_type
{
	int x;
	int a[0];
}type_a;
  • 结构体最后一个成员为 数组名[] 的形式

✔️两种柔性数组的声明方式不同,在某些编译器支持int a[0]的写法,而有些则支持int a[]的写法,具体取决于编译器的实现

(2)特点

  1. 柔性数组必须是结构体的最后一个成员
    在这里插入图片描述

  2. 柔性数组前面必须至少一个其他成员

  • 即结构体本身必须要有空间,否则将无法通过结构体找到柔性数组,那么就无法为柔性数组分配空间。
typedef struct st_type
{
	int a[];
}type_a;
  1. 柔性数组不占内存空间
  • 前面我们学过了内存对齐,知道了计算结构体内存空间时每个成员都是计算在内的。
  • 但观察下图,可以发现柔性数组不参与结构体大小的计算
    在这里插入图片描述

(3)使用场景

  • 使用柔性数组首先要开辟结构体空间随后再去给柔性数组开辟空间
type_a* s = (type_a*)malloc(sizeof(type_a)+sizeof(int) * 10);

其中sizeof(type_a)是结构体空间,sizeof(int)*10是为柔性数组开辟的十个整形空间。

注意:分配的内存应该大于结构体的大小,以适应柔性数组的预期大小

  • 下面我们来测试一下柔性数组是否被正确声明,并给其初始化。
#include<stdio.h>
#include<stdlib.h>

typedef struct st_type
{
	int x;
	int a[];
}type_a;

int main()
{
	//开辟结构体空间,并给柔性数组10个整形空间
	type_a* s = (type_a*)malloc(sizeof(type_a)+sizeof(int) * 10);
	
	if (s == NULL) {
		perror("malloc fail");
		exit(-1);
	}

	s->x = 99;
	
	for (int i = 0; i < 10; i++) {
		s->a[i] = i + 1;
	}

	for (int i = 0; i < 10; i++) {
		printf("%d ", s->a[i]);
	}
	printf("\n");
	free(s);
	return 0;
}

在这里插入图片描述

✔️通过观察运行结果可以发现,柔性数组被正确声明和初始化。(和普通数组的使用区别不大)

🟠此外,柔性数组可以动态改变大小,那么我们就可以使用所学的realloc()去实现动态扩容操作。

type_a* tmp = (type_a*)realloc(s,sizeof(type_a) + sizeof(int) * 20);
if (s == NULL) {
	perror("malloc fail");
	exit(-1);
}
  • 给开辟的空间初始化并打印

在这里插入图片描述
❕需要注意的是,柔性数组的大小不计入结构体的空间总大小
在这里插入图片描述

❔那么,要如何正确释放柔性数组开辟的空间呢?
☑️使用free(s)即可。这是因为柔性数组的内存是与结构体的其他部分连续分配的,因此无法单独释放柔性数组的空间(即free(s->a)是错误的)

柔性数组的内存和结构体的内存是不可分割的,它们共享同一块内存区域。

🟤让我们分析一下这块内存的布局

  • 前面是结构体的非柔性数组部分int x
  • 后面是柔性数组部分int a[]

这块内存是一个整体,free()只能释放整个分配的内存块,而不能单独释放其中的某一个部分。

☑️因此,如果需要单独管理其中的空间,我们可以使用指针数组替换柔性数组,方便更灵活的管理内存

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

typedef struct st_type
{
    int x;
    int* a; // 使用指针代替柔性数组
} type_a;

int main()
{
    // 分配结构体空间
    type_a* s = (type_a*)malloc(sizeof(type_a));
    if (s == NULL) {
        perror("malloc fail");
        exit(-1);
    }

    // 分配柔性数组的空间
    s->a = (int*)malloc(sizeof(int) * 10);
    if (s->a == NULL) {
        perror("malloc fail");
        free(s); // 释放结构体空间
        exit(-1);
    }

    s->x = 99;

    // 给柔性数组赋值
    for (int i = 0; i < 10; i++) {
        s->a[i] = i + 1;
    }

    // 输出柔性数组的值
    for (int i = 0; i < 10; i++) {
        printf("%d ", s->a[i]);
    }
    printf("\n");

    // 释放柔性数组的空间
    free(s->a);

    // 释放结构体的空间
    free(s);

    return 0;
}

🔴如果想在某些情况共享内存,在另一些情况分开管理内存,这个时候可以使用联合体union来实现

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

typedef struct st_type
{
    int x;
    //使用联合体将指针和柔性数组联合
    union {
        int a[]; // 柔性数组
        int* ptr; // 指针
    } u;
} type_a;

int main()
{
    // 分配结构体空间,并给柔性数组10个整形空间
    type_a* s = (type_a*)malloc(sizeof(type_a) + sizeof(int) * 10);
    if (s == NULL) {
        perror("malloc fail");
        exit(-1);
    }

    s->x = 99;

    // 使用柔性数组
    for (int i = 0; i < 10; i++) {
        s->u.a[i] = i + 1;
    }

    // 输出柔性数组的值
    for (int i = 0; i < 10; i++) {
        printf("%d ", s->u.a[i]);
    }
    printf("\n");

    // 释放整个结构体空间
    free(s);

    // 如果需要单独管理内存,可以这样
    type_a* s2 = (type_a*)malloc(sizeof(type_a));
    if (s2 == NULL) {
        perror("malloc fail");
        exit(-1);
    }

    s2->u.ptr = (int*)malloc(sizeof(int) * 10);
    if (s2->u.ptr == NULL) {
        perror("malloc fail");
        free(s2);
        exit(-1);
    }

    // 使用指针
    for (int i = 0; i < 10; i++) {
        s2->u.ptr[i] = i + 1;
    }

    // 输出指针数组的值
    for (int i = 0; i < 10; i++) {
        printf("%d ", s2->u.ptr[i]);
    }
    printf("\n");

    // 释放指针数组的空间
    free(s2->u.ptr);

    // 释放结构体的空间
    free(s2);

    return 0;
}

(4)优缺点分析

🟤柔性数组对比我们常用的指针数组有什么不同呢?

  1. 柔性数组
//开辟结构体空间和柔性数组空间
type_a* s = (type_a*)malloc(sizeof(type_a) + sizeof(int) * 10);
if (s == NULL) {
	perror("malloc fail");
	exit(-1);
}
//同时释放结构体空间和柔性数组空间
free(s);

✔️可以看到,柔性数组使用了一次malloc,一次free

  1. 指针数组
//开辟结构体空间
type_a* s = (type_a*)malloc(sizeof(type_a));
if (s == NULL) {
	perror("malloc fail");
	exit(-1);
}

//开辟结构体指针数组空间
int* tmp = (int*)malloc(sizeof(int) * 10);

//把这块空间给指针数组
s->a = tmp;
//初始化
for (int i = 0; i < 10; i++) {
	s->a[i] = i + 1;
}

//打印
for (int i = 0; i < 10; i++) {
	printf("%d ", s->a[i]);
}

printf("\n");
//由于内存地址不连续,因此需要分开释放
free(s->a);
free(s);

✔️可以看到,指针数组使用了两次malloc,两次free

  1. 内存分布对比

柔性数组在这里插入图片描述

指针数组
在这里插入图片描述

🟢因此使用柔性数组的主要优势有以下两点
☑️ 方便内存申请和释放
当我们不需要单独管理结构体中数组的内存大小时,使用柔性数组可以使代码更清晰简洁,无需多次对内存进行操作,实现内存管理的简化
☑️ 提高代码运行速度
结构体和数组的内存是连续分配的,释放内存时只需要一次 free 操作,避免了多次分配和释放内存导致的内存碎片问题,访问柔性数组的元素时,内存访问更加高效,减少了缓存不命中的可能性。

6、总结和回顾

最后我们来回顾一下本文所学

  • 首先,我们讲解了三个动态分配内存函数,分别是只负责开辟空间malloc(),可以开辟空间又可以分配字节calloc(),可以重新分配内存块realloc()以及用于释放内存free()函数。
  • 了解这几个函数还不够,如何正确的使用它们很关键。于是总结了三个常见动态内存分配错误情况,方便我们使用时规避它们。
  • 学会了使用它们,就要来几道题目练练手!我们可以通过笔试题加深对它们的理解,了解使用细节。
  • 最后我们谈到了【柔性数组】,了解它的声明,释放等使用细节,以及配合realloc()实现无限扩容的特点。最后通过【内存分布】分析柔性数组和指针数组的不同点,得出了其优势所在。

好,这就是本文的全部内容,感谢阅读🌹

请添加图片描述

;