随笔——动态内存管理

前言

之前我们在《函数栈帧的创建与销毁》介绍了VS是如何对内置类型变量进行内存管理的,接着我们又在《随笔——自定义类型:结构体》《随笔——自定义类型:联合和枚举》中说明了VS如何对自定义类型进行内存管理,我们发现VS的内存管理总体上来说是很优秀的,但在某些特殊场景下VS的内存管理就不太行了;除此之外,VS自己进行的内存管理无法被开发人员控制,所以形式上有些不自由,这种不自由对初学者来说很友好,但对另一些人来说,这种呆板的形式限制了他们的发挥,这时就需要动态内存管理了。

在动态内存管理中,我们将直接对内存进行管理,而不是以前那样,借助变量间接管理内存;这种动态内存管理,如果用得好,会让程序锦上添花,用的不好,那就可能什么都没有了。

这倒让我想起2022年的高考作文题了:
附:全国新高考Ⅰ卷试题内容:
阅读下面的材料,根据要求写作。(60分)
“本手、妙手、俗手”是围棋的三个术语。本手是指合乎棋理的正规下法;妙手是指出人意料的精妙下法;俗手是指貌似合理,而从全局看通常会受损的下法。对于初学者而言,应该从本手开始,本手的功夫扎实了,棋力才会提高。一些初学者热衷于追求妙手,而忽视更为常用的本手。本手是基础,妙手是创造。一般来说,对本手理解深刻,才可能出现妙手;否则,难免下出俗手,水平也不易提升。
以上材料对我们颇具启示意义。请结合材料写一篇文章,体现你的感悟与思考。
正巧写这篇文章的时候也快高考了,在此祝广大学子都能获得一个理想的成绩,如果成绩理想,那就去更广阔的天空追寻自己的梦想;如果失利了,也不要失去信心,人生的道路还长,生命中仍有美好在前面等着你。

函数

在内存动态管理中,有四个函数至关重要,说白了,动态内存玩的就是这四个函数;想要把动态内存学会,就要玩好这四个函数,这四个函数分别是malloc,free,calloc,realloc。下面我们将逐个说明:

malloc

函数原型(头文件:stdlib.h):

void* malloc (size_t size);
  • 其中的size指的是将开辟内存空间的大小,如果写20,那就开辟20个字节空间
  • 如果开辟成功,则返回一个指向开辟空间起始地址的指针
  • 如果返回失败,则返回一个空指针NULL
  • 由于返回指针存在空指针的可能性,使用时要检查是否为空指针
  • 由于返回的指针类型是void*,所以若要使用该指针,需要强制类型转换
  • size=0是未被定义的,具体结果要看编译器

也许有些人会问为什么返回void*呢?因为这个函数只是开辟了一处内存空间,至于这个空间里到底存储什么数据,只有开发者知道,函数本身并不知道将要存储的数据类型,所以用void*这种通用指针。

现在我们要开辟一处空间用来存储五个整型,那就这样写:

#include<stdlib.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	if (p == NULL)
	{
		perror("malloc():");
		return;
	}
	return 0;
}

perror的用法在《随笔——字符串函数》讲过,就在函数strerror的后面,如果是空指针,就打印错误信息,并且提前结束,如果不是空指针,那就接着运行。这里用的是if语句判断是否是空指针,不过我更喜欢用断言:

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	assert(p);
	return 0;
}

开辟好了,就要存入数据了,这里我们就存入1,2,3,4,5

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	return 0;
}

注意这里最好不要改变指针p的值,原因后面会讲;还可以用调试看看:

此时p指向随机值,是野指针:
在这里插入图片描述
现在我们看到p已经指向新开辟空间的起始地址了,和《函数栈帧的创建与销毁》里不太一样,这里的空间被初始化成cdcdcdcd……而不是cccccccc,这说明动态内存与变量创建所使用的内存初始化方式不同
在这里插入图片描述
这是赋完值的:
在这里插入图片描述


那这malloc到底是从哪里开辟空间的?
我们知道,内存中有三个区域,叫做栈区,堆区,静态区,《函数栈帧的创建与销毁》就主要是对栈区讲的,栈区存储局部变量,形式参数;本文中四个函数(malloc,free,calloc,realloc)管理的内存都在堆区,静态区则存储静态变量,全局变量。


free

既然有用来开辟内存空间的函数,当然也有释放内存空间的函数,这个函数就是free
函数原型(头文件:stdlib.h):

void free (void* ptr);
  • 如果ptr指向的不是动态开辟的空间,程序将出错
  • 如果ptr是空指针,则不进行任何操作
  • ptr是动态开辟空间的起始地址
#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	free(p);
	p = NULL;
	return 0;
}

注意:由于内存释放后,指针p所指向的内容失去了意义,所以此时p变成了野指针,要将其置为空指针
在这里插入图片描述
在这里插入图片描述

calloc

函数原型(头文件:stdlib.h):

void* calloc (size_t num, size_t size);
  • 函数的功能是为 num 个大小为 size 的元素开辟⼀块空间,并且把空间的每个字节初始化为0
  • 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0

还是开辟一处内存空间,用来存五个整型:

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)calloc(5, sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	free(p);
	p = NULL;
	return 0;
}

可以看到,这处空间被初始化成全0。
在这里插入图片描述

realloc

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

函数原型(头文件:stdlib.h):

void* realloc (void* ptr, size_t size);
  • ptr 是要调整的内存地址
  • size 是调整之后的新大小
  • 返回值为调整之后的内存起始位置
  • 无法调整则返回NULL
  • ptr为空指针则相当于malloc

比方说,还是上面这个例子,在创建好20字节后,我觉得不够用,要扩大成40字节,就这样写:

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	int* ptr = realloc(p, 10 * sizeof(int));
	assert(ptr);
	p = ptr;
	ptr = NULL;
	for (i = 5; i < 10; i++)
	{
		*(p + i) = i + 1;
	}
	free(p);
	p = NULL;
	return 0;
}

由于realloc存在返回空指针NULL的可能,所以,我们一般再创建一个指针来储存函数返回值,再判断这个指针是否是空指针,如果不是空指针,再传给原来的指针p;如果直接用原指针p来接收返回值,若返回空指针,则将失去原空间的起始地址。

当调用 realloc 时,函数会首先尝试在现有的内存段之后找到更多的空余空间来满足需要的大小。如果找到了合适的空间,它就会扩展现有的内存段,并且返回原来的内存地址。(对于这张图,返回的就是地址0)

在这里插入图片描述
但是,如果realloc无法找到足够的连续空闲空间来扩展现有的内存,它会通过操作系统在堆区的其它地方分配一个新的、足够大的内存空间,并将旧的内存内容复制到新的内存位置,然后释放旧的内存空间,并返回新的内存地址。(对于这张图,返回的就是地址22)
realloc调用前:
在这里插入图片描述
realloc调用后:
在这里插入图片描述
若realloc无法找到足够的连续空闲空间来扩展现有的内存,并且在堆区其它地方也找不到足够大的空间,则空间开辟失败,realloc返回空指针NULL。

如果传入的ptr是空指针,则说明没有原空间,realloc会直接开辟一个符合大小的新空间,此种情况下,realloc就相当于malloc。

如果想亲手调试看看,可以把size定的大些,以达到开辟失败的情况。(这可能要看电脑性能,大不了你取最大值ULLONG_MAX,你可以用everything来搜索limits.h来看整型一族的取值范围)


题外话:size_t 的具体大小取决于特定的编译环境。也就是说,它由你的操作系统和编译器环境决定。在许多环境下,size_t 被定义为 unsigned int 的别名,但在一些其他环境下(特别是64位系统),它可能被定义为 unsigned long 的别名。

对于VS来说,
在32位环境(x86)中,它相当于 unsigned long,是32位整数,即4字节
在64位环境(x64)中,它相当于unsigned long long ,是64位整数,即8字节


常见动态内存错误

解引用空指针

函数开辟内存失败,返回了空指针,又不判断返回值是否为空指针,直接解引用

int main()
{
	int* p = (int*)malloc(ULLONG_MAX);
	*p = 20;
	return 0;
}

在这里插入图片描述

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

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)malloc(5 * sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 10; i++)
	{
		*(p + i) = i + 1;
	}
	return 0;
}

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

#include<stdlib.h>
#include<assert.h>

int main()
{
	int i = 0;
	int* p = &i;
	//略
	free(p);
	p = NULL;
	return 0;
}

在这里插入图片描述

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

我有个地方说不要改变p的值,就是对这个错误来说的,free中的ptr是开辟空间的起始地址

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)calloc(5, sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 3; i++)
	{
		*p = i + 1;
		p++;
	}
	free(p);
	p = NULL;
	return 0;
}

在这里插入图片描述

对同⼀块动态内存多次释放

#include<stdlib.h>
#include<assert.h>

int main()
{
	int* p = (int*)calloc(5, sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	free(p);
	//略
	free(p);
	p = NULL;
	return 0;
}

在这里插入图片描述
建议在内存释放之后就立刻把指针置为空指针,这样即使误释放了,也没有太大问题。

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

#include<stdlib.h>
#include<assert.h>

void test()
{
	int* p = (int*)calloc(5, sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
}

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

程序如果运行,会向操作系统请求一段内存空间,动态开辟是在这部分空间的堆区上开辟的,程序结束,操作系统会把这部分空间强制回收,如果程序一直不结束,就像上面那个样子,那这部分空间就不会被强制回收,而在test函数中,又没有释放动态开辟的空间,而且test函数运行结束后,指针p被销毁,main函数就更无法释放这部分动态开辟空间,所以这个动态开辟的空间就一直被占用着。

将来可能是多人协作写程序,如果开辟了一处空间,后面的同事还要用到,自己无法释放内存,那就一定要后面同事,我这内存还没释放,用完记得释放。

还要注意开辟和释放之间程序会不会提前结束,比如这样:

#include<stdlib.h>
#include<assert.h>

void test()
{
	int* p = (int*)calloc(5, sizeof(int));
	assert(p);
	int i = 0;
	for (; i < 5; i++)
	{
		*(p + i) = i + 1;
	}
	//略
	int j = 1;
	//略
	if (j)
	{
		return;
	}
	//略
	free(p);
	p = NULL;
}

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

GitHub上有一个项目可以用于检测VS2022是否存在内存泄露问题:
直接下载.exe一路next即可安装成功

GitHub无法访问的,这里有百度网盘分享
在这里插入图片描述
安装后,重启VS2022,点击【项目】【属性】,看C\C++和链接器下的【常规】【附加目录】是否有:
在这里插入图片描述
在这里插入图片描述
有就安装成功

使用时,需要包含本地头文件vld.h,若无内存泄露,会显示
在这里插入图片描述
有内存泄露会显示具体信息:
在这里插入图片描述
下面是测试代码:

#define __DEBUG__

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

#define Malloc(type,n) (type*)malloc(n * sizeof(type))
#define ERRORS(mem) "Open error of " #mem
#define Alloc_failed(mem) \
		if(mem == NULL)\
		{\
				perror(ERRORS(mem)); \
				mem = NULL; \
				exit(EOF);\
		}

#ifndef __DEBUG__



#endif



int main()
{
	int** arr = Malloc(int*, 3);
	Alloc_failed(arr);
	int i = 0;
	for (i = 0; i < 3; i++)
	{
		int* pa = NULL;
		pa = Malloc(int, 5);
		Alloc_failed(pa);
		*(arr + i) = pa;
	}
	int j = 0;
	for (i = 0; i < 3; i++)
	{
		for (j = 0; j < 5; j++)
		{
			*(*(arr + i) + j) = i * 5 + j;
		}
	}
	for (i = 0; i < 3; i++)
	{
		for (j = 0; j < 5; j++)
		{
			printf("%d ", *(*(arr + i) + j));
		}
		printf("\n");
	}
	for (i = 0; i < 3; i++)
	{
		free(*(arr + i));
		*(arr + i) = NULL;
	}
	free(arr);
	arr = NULL;
	return 0;
}

动态内存经典笔试题分析

题目一

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

void GetMemory(char* p)
{
	p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(str);
	strcpy(str, "hello world");
	printf("%s\n",str);
}

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

哪错了?怎么改?

我们可以大致推测出这个程序的运行逻辑:
首先通过函数GetMemory开辟出一处空间,再试图把新开辟空间地址传入上一级函数中的指针变量str,随后把字符串拷贝进这处空间,再打印。

有两个错误:

  1. 看过《函数栈帧的创建与销毁》的都知道,这个新开辟的空间地址是传不到上一级函数中的str的,在函数GetMemory结束之后,变量p会被销毁,改变不了str的值,所以这拷贝字符串时,str仍为空指针,这会导致字符串无法拷入,程序崩溃;
  2. 动态空间光开辟了,用完之后没有释放,更别提把str置为空指针了。

如何解决?
错误2好解决,关键是错误1,该如何把变量中的值传到上一级函数中去呢?无非几种办法:
3. 用全局变量(不考虑,稍微学过一点的人都知道,尽量不要创建全局变量)
4. 传参,把值返回出来给str接收
5. 用级别更高的指针(下面的改正用的就是这个)

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

void GetMemory(char** p)
{
	*p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(&str);
	strcpy(str, "hello world");
	printf("%s\n", str);
	free(str);
	str = NULL;
}

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

题目二

#include<stdio.h>

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收到的实际是个野指针,野指针怎么能用呢?如果原来的字符数据没有被覆写,那就还是打印hello world,如果被覆写了,那就不知道会打印出什么了。

题目三

#include<stdio.h>

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

其实和第一题错误二一样,没有释放内存,不过不是什么大问题,毕竟程序结束后,程序曾经申请的空间会被操作系统回收。

题目四

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

这题估计是想检测内存释放之后,输入的指针是否会被重置为空指针,是实验性质的,如果不会被置为空指针,就会进入if语句,把hello\0覆写成world\0,如果会被置为空指针,就不会打印任何东西。

柔性数组

我不知道你们之前有没有听说柔性数组,反正我没听说。

柔性数组是 C99 标准中的一个特性。在 C99 标准之前的 C 版本中,这一特性通常被称为 “结构体黑魔法”。
它为一个结构体提供了一种声明变长(长度可以变化)数组的方式。柔性数组必须是结构体的最后一个成员,且它的长度是未定的。也就是说,这种数组没有固定的长度,结构体被分配空间时才确定它的长度。

柔性数组的使用必须满足以下两个条件:
结构体中至少要有一个其他成员,这样才能根据起始地址和这些成员计算出柔性数组的位置。
柔性数组不能被直接初始化。因为在编译阶段,编译器并不知道数组将包含多少个元素。初始化必须在运行时动态地完成。

当使用操作符sizeof计算含有柔性数组的结构体大小时,计算结果将不包含柔性数组(因为它的大小未知)

柔性数组有两种创建格式(为了方便叙述,这里只创建两个成员)

struct test1
{
	int i;
	//也可以再添些成员
	int arr[];
};

struct test2
{
	int i;
	//也可以再添些成员
	int arr[0];
};

如果其中一种格式无法被编译器识别,那就换另一种;VS两种都能识别

怎么用呢?

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

typedef struct test
{
	int i;
	char arr[];
}test;

int main()
{
	test* pstr = (test*)malloc(sizeof(test) + 12 * sizeof(char));
	assert(pstr);
	pstr->i = 12;
	strcpy(pstr->arr, "hello world");
	printf("%s\n", pstr->arr);
	//现在我想把字符串换成"I am a student."为了放得下这个数组,要调整字符串数组大小
	test* p = (test*)realloc(pstr, sizeof(test) + sizeof(char[16]));
	assert(p);
	pstr = p;
	p = NULL;
	pstr->i = 16;
	strcpy(pstr->arr, "I am a student.");
	printf("%s\n", pstr->arr);
	free(pstr);
	pstr = NULL;
	return 0;
}

在这里插入图片描述
我们也可以在不使用柔性数组的前提下实现变长数组,比如下面的代码:不过形式和柔性数组差不多,都是只定义结构体类型,不使用结构体变量,只把结构体类型使用在内存开辟函数的参数中:

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

typedef struct test
{
	int i;
	char* arr;
}T;

int main()
{
	T* p = (T*)malloc(sizeof(T));
	assert(p);
	p->i = 12;
	char* p1 = (char*)malloc(sizeof(char[12]));
	assert(p1);
	p->arr = p1;
	p1 = NULL;
	strcpy(p->arr, "hello world");
	printf("%s\n", p->arr);
	p->i = 16;
	p1 = (char*)realloc(p->arr, sizeof(char[16]));
	assert(p1);
	p->arr = p1;
	p1 = NULL;
	strcpy(p->arr, "I am a student.");
	printf("%s\n", p->arr);
	free(p->arr);
	p->arr = NULL;
	free(p);
	p = NULL;
	return 0;
}

在这里插入图片描述
这两段代码各有优缺点,代码一只开辟了一处内存空间,而代码二开辟了两处内存空间,从这个角度来说,代码一
产生的内存碎片更少些,对内存的利用率更高,除此之外,代码一只需一次就能释放全部空间而代码二则需要分别释放两次,且释放顺序存在要求;但对于现代计算设备来说,这种级别的内存碎片是可以忽略的,还有,代码二的可读性更高,很容易让别人读明白,而代码一用到了柔性数组的知识,如果不知道这些知识就很难理解代码一。


题外话:
内存碎片主要分为两类:内部碎片和外部碎片。
内部碎片的产生:当我们采用固定大小的内存分区时,可能会产生内部碎片。如果一个进程不能完全使用分给它的固定内存区域,那么这部分未利用的内存便形成了内部碎片。这种情况通常出现在内存区域被分配出去,但进程并没有充分利用的场景中。
外部碎片的产生:外部碎片是由于频繁的内存分配与回收造成的。如果你对内存进行频繁的分配与释放,那么就会造成大量的、连续且小的空闲内存块夹在已分配的内存块之间,这就形成了外部碎片。例如,假设有一块一共有100个单位的连续空闲内存空间,如果你频繁地请求与释放小部分的内存,可能会造成这100个单位的内存被划分为多个不连续的小内存块,甚至无法满足大规模的内存请求。
内存碎片问题在一些需要频繁申请和释放内存的场景中表现得尤为明显,例如网络服务、数据库等。预防和处理内存碎片问题的方法有:合理地设计内存管理策略,如内存池技术、采用合适的算法进行内存分配(最佳适应算法、最坏适应算法、首次适应算法等)等。


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

自己看吧:
在这里插入图片描述
在C/C++中,程序的内存空间主要可以分为以下几个部分:

代码区/文本区:存放程序的二进制代码,它是只读的,主要包括函数体的机器代码,其中也包含字符串常量。

静态/全局存储区:存放全局变量、静态变量和常量,程序编译时就已经分配好了。此区域的内存分为了初始化部分和未初始化部分,其中初始化部分由编译器自动初始化,未初始化部分通常是由程序自行定义初始化值。

堆区:由程序员动态分配和回收,没有自动释放的功能。如果程序员没有主动释放,可能会出现内存泄露。这个区域的大小由程序员控制。

栈区:自动分配和回收内存,包括在函数内部声明的变量(局部变量、函数参数等)。函数执行完毕后,自动释放其申请的空间。

常量区:存放字符串常量和其他常量数据。这个区域的内存只读,不具备写入权限。

以上这些区域在内存中的分布通常是:代码区在最低端,接着是静态/全局存储区,然后是堆区,由低地址向高地址扩展;栈区从高地址向低地址扩展。函数调用时会形成新的栈帧,并压入栈顶,函数调用结束会弹出栈帧。反复的调用和结束会形成“栈”的效果,因此得名“栈区”。通常堆区和栈区之间会有一段没有显式使用的内存区域,这段区域一般称为内存映射段"或"内存映射区",主要被动态库和映射文件占用。

注:“显式"这个词在编程语言中一般代表一种明确、直接的操作或行为。它的对应词是"隐式”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值