[C] 动态内存管理


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

动态内存管理不像数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。

C/C++定义了4个内存区间:

  1. 代码区
  2. 全局变量与静态变量区
  3. 局部变量区,即
  4. 动态存储区,即(heap)区或自由存储区(free store)。
    所有动态存储分配都在堆区中进行

为何存在内存管理

int a = 20//在栈空间开辟4个字节内存
char arr[5] = { 0 };		//在栈空间开辟5个字节的连续内存

以上程序使我们常见掌握的内存开辟方式,称为静态的内存分配方式,这种方式有三个特点:

  1. 空间开辟内存大小是固定的。
  2. 开辟空间的生命周期到函数结束,由操作系统释放空间。
  3. 数组在声明的时候,必须指定数组的长度(即元素个数),数组需要占用的内存在编译时分配。

C89标准要求数组元素个数必须是常量
新版本C99标准允许数组元素个数是变量

但实际使用中有时申请开辟的内存空间不足以存储数据,往往还需要手动修改需要的内存空间扩容。为了避免如此繁琐的步骤,使用动态内存分配的方式就可以无需考虑空间不足的情况了。


动态内存函数

函数的声明都包含在<stdlib.h> 头文件中。

malloc

void* malloc (size_t size);

这个函数向内存申请一块连续可用的空间,并返回指向这块内存区域的指针

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

malloc函数的使用

int *a = (int *)malloc(10 * sizeof(int));
  • 因为malloc只是动态的申请一段连续的内存空间,无论当成intchar数组使用都是可以的,因为其返回值是void*类型,所以只需在函数前进行强制类型转换为要使用的类型即可。
    (例中转换为整型数组int*
  • malloc函数申请空间的单位为字节,所以与使用者希望构建的数组类型息息相关,这时通过元素个数 * 元素类型的方式计算总字节数。这样的方式申请内存空间的在运行时开辟,空间长度能够比较灵活。
    (例中表示10个元素的整型数组,计算结果为开辟的总字节数:10×4=40
  • 指针变量a的返回值也是int*,需要与强转的类型相同。此时申请完毕,a就指向了10 * sizeof(int) = 40这么多字节的一段连续的内存空间。

free

C语言提供的free函数专门是用来做动态内存的释放和回收的。

void free (void* ptr);
  1. 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
  2. 如果参数 ptrNULL空指针,则函数什么事都不做。【详见下面特殊情况的讲解】
  3. 释放之后这部分内存并不为空,除非手动设置为NULL

free函数的使用:直接手动释放内存

int *a = (int *)malloc(10 * sizeof(int));
free(a);		//直接使用
  • 如果频繁malloc但没有free,申请到的内存没有释放一直被占用,就会造成内存泄漏
  • 例外的情况:
int main(){
	int *a = (int*)malloc(sizeof(int));
	*a = 100;
	return 0;
}

此代码中申请到的内存没有进行释放,但是刚申请到内存,main函数就结束了,说明程序也就结束了,操作系统回收了内存,就不会造成内存泄漏。
所以是否为内存泄漏现象也要取决于程序的生命周期,如果是7×24小时运行的服务器程序,那么就必然是内存泄漏。

【扩展】:C语言中要求使用者必须自己保证释放申请到的内存空间。
为了避免内存泄漏问题,后世的许多编程语言引入了新的概念:垃圾回收机制GC

  • 优点:不用使用者考虑何时释放内存,自动帮用户释放。
  • 缺点:占用资源更多,开销更大,需要专门的时间来进行。
    STW问题:stop the world)。

Q:如何检测函数中是否出现了内存泄漏了?
A:单次调用函数可能泄露很少的内存,无法直接发现。但可以多次重复调用函数,量变引起质变,这时候观察内存使用情况就可以很明显的发现是否出现了内存泄漏。

应对内存泄漏的燃眉之计就是:重新运行代码,泄露的内存区段就全销毁了,全部重新开始写入。但这只是聊胜于无的应对方案,还是需要程序员找到真正造成内存泄露的代码块,并加以修正。

特殊情况

C++标准规定对空指针进行free是合法的,无事发生。
所以推荐大家在free申请到一个内存空间后,就把刚刚申请到指向该内存空间的指针设为NULL

int *p = NULL;
free(p);
p = NULL;		//设为 NULL

calloc

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

【优点】:每个字节置为0,避免使用到未初始化的随机值的情况。
【缺点】:相比于随机值,多了一步设置为全零的操作,产生多余开销


realloc

  • realloc函数的出现让动态内存管理更加灵活。
  • 有时申请的空间太小,有时过大了,为了合理我们需要对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小的调整。
void* realloc (void* ptr, size_t size);
  1. ptr是需要调整的内存地址。
  2. size是调整之后的新大小,单位为字节
  3. 返回值为调整之后的内存起始位置。
  4. 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间
  5. realloc在调整内存内存空间的是存在两种情况:
    • 原有空间之后足够大的空间:
      在原申请空间后继续向后申请内存空间,直至扩充到目标大小,正常返回内存空间地址。
      在这里插入图片描述

    • 原有空间之后没有足够大的空间
      原申请空间之后不远的地址上存放着一变量a的字段,这一段内容不会直接覆盖掉,更不会截断存放,因为malloc系列函数申请的内存空间都是连续的。所以另外开辟了一段空间,进行了原数据拷贝(例如存放了4个整型数据)。然后原内存空间的内容就被销毁了,内存就被释放掉了,把新内存空间的地址赋值给了原指针变量,所以返回的地址有可能与原地址不相同。
      在这里插入图片描述


常见错误

错误一:以下演示一种经典的内存泄漏的场景

int *p = (int*)malloc(4);
p = (int*)malloc(4);;
free(p);

【错误原因】:

  • 第一次malloc申请到一块内存空间,假设此连续的内存空间的首地址为0x100,那么指针变量p存储的地址就是0x100
    在这里插入图片描述
  • 第二次malloc申请又到一块连续内存空间,假设新申请的连续的内存空间的首地址为0x200,那么指针变量p更新之后存储的地址就是0x200
    在这里插入图片描述
  • 最后对申请空间的p变量进行free释放空间,则释放的是第二次申请到的内存空间,而第一次申请到的内存空间一直没有释放,就造成了内存泄漏。
    在这里插入图片描述

错误二:以下演示多次free的场景

int *p = (int*)malloc(4);
free(p);	//释放申请到的内存
free(p);	//再次释放内存

【错误原因】:
因为释放了向操作系统申请到的内存空间,再对这段没有申请的、没有操作权限的内存空间进行释放,就是未定义行为。造成错误。除非这个指针为空指针NULL


错误三:

int a = 10;
int *p = &a;
free(p);

【错误原因】:
因为free必须搭配malloc系列函数使用,不能单独使用,如果去释放不是由malloc系列函数申请到的内存空间,就会出现错误。程序报错。


错误四:

int *p = (int*)malloc(4);
free(p);
*p = 100;

【错误原因】:
申请到的内存释放掉了,再进行赋值造成了内存访问越界,类似于数组访问越界行为,都是未定义行为。程序报错。

*p = 100;这一语句也与上面的free后设置为NULL的操作相呼应,如果出现了此语句,那么就是对空指针解引用,会引起程序崩溃,便于程序员快速找到问题所在。如果没有设置为空,那么出现未定义行为或许不会造成程序崩溃,一错再错,置程序员于万劫不复的境地。所以还是要防微杜渐。


错误五:

int *p = (int*)malloc(100);
p++;
free(p);

【错误原因】:
p不再指向动态内存的起始位置,如果原地址为0x100,那么++之后地址就是0x104,并不是释放了之后的空间而造成的内存泄漏,而是一个未定义行为。不能对一个已经变更的内存地址进行free。程序报错。


代码一:

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
  • p变量是形式参数,在函数内部对p如何操作都是不影响实际参数的,实参仍为一个空指针。之后对空指针进行strcpy就出现了内存访问越界,造成未定义行为。

【修改】所以应该改为二级指针进行传参:

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);		//释放内存
}

代码二:

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

【错误原因】:

  • 函数结束,局部变量p的生命周期就结束了,内存释放。在主函数再去访问已释放的内存就也是内存访问越界,造成未定义行为了。
    因为"hello world"这个字面值常量的生命周期是全局的,这个字符串只读不可以修改,要用const修饰。所以函数结束指针变量p并没有销毁,可以正确打印出字符串。

【修改】:

char *GetMemory(void){
    const char *p = "hello world";	//指针变量p
    return p;
}
void Test(void){
    const char *str = NULL;
    str = GetMemory();
    printf("%s\n",str);
}

代码三:

void GetMemory2(char **p, int num){
    *p = (char*)malloc(num);
}
void Test(void){
    char *str = NULL;
    GetMemory(&str, 100);
    strcpy(str, "hello");
    printf("%s\n",str);
}

【错误原因】:

  • 申请内存没有释放。
  • malloc申请内存有可能失败,返回一个空指针,所以要在函数中判定一下返回指针为空的情况。

【修改】:

void GetMemory2(char **p, int num){
    *p = (char*)malloc(num);
}
void Test(void){
    char *str = NULL;
    GetMemory(&str, 100);
    if(str == NULL){	//判断为空条件
    	//...
    }
    strcpy(str, "hello");
    printf("%s\n",str);
    free(str);		//释放内存
}

代码四:

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

【错误原因】:

  • 指针变量str在释放内存后就变成了野指针,再对它进行内容填充就相当于访问一个非法内存,属于未定义行为。结果不可预期,有可能输出希望的结果,也有可能程序闪退,要尽量避免这种行为。

C/C++程序的内存开辟

内存分配区域图

在这里插入图片描述

  1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
    CentOS默认栈的大小为8192KB=8M
    VS默认栈的大小不到1M
    但是默认的大小可以修改,比如CentOS可通过ulimit -s指令进行修改,只是修改的空间过大系统就不允许了。
  2. 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。
  3. 数据段(静态区)(static):存放全局变量、静态数据。程序结束后由系统释放。
  4. 代码段:存放函数体(类成员函数和全局函数)的二进制代码。

【这里讨论的栈和堆并非数据结构中的名词】
实际上普通的局部变量是在栈区分配空间的,栈区的特点是在上面创建的变量出了作用域就销毁。
但是被static修饰的变量存放在数据段(静态区),数据段的特点是在上面创建的变量,直到程序结束才销毁,所以生命周期变长。

  • 如图也就解释了可以直接定义数组,为什么还要引入malloc
    因为定义的数组是在栈上申请的内存,然而栈相比于malloc申请空间的堆区,它的空间非常小,有事用户要创建很大的数组就无法正确申请到内存,所以也是动态内存管理函数存在的必要性。
  • Q:何时要通过malloc来申请内存?
  • A:当申请的内存空间比较大时使用malloc
  • Q:何时要通过直接定义临时变量来使用内存?
  • A:当对于申请的内存性能要求比较高时。

如下图借用网上的一张图例来直观了解内存区域:
在这里插入图片描述

柔性数组

这是C语言独有的概念,C99标准中规定结构中最后一个元素允许是位置大小的数组,这就叫做柔性数组成员

使用

typedef struct st_type {
	int i;
	int a[0];		//柔性数组成员,0的意思是可以有任意多个元素
	//int a[]; 也可写成此方式
};

int main() {
	//创建一个10个单位长度的数组
	Test *t = (Test*)malloc(sizeof(int) + sizeof(int) * 10);
	
	//创建一个100个单位长度的数组
	//Test *t2 = (Test*)malloc(sizeof(int) + sizeof(int) * 100);
	
	//内存申请完毕,开始赋值
	t->i = 10;
	for(int i = 0;i < 10;++i){
		t->a[i] = i;
	}
	free(t);		//记得释放掉申请的内存空间
	return 0;
}

同一个结构体可以定义成不同的长度,这就是柔性数组的功能所在。

那么不使用柔性数组,也可以实现吗?可以,请看代码二:

typedef struct st_type {
	int i;
	int *a;		//定义一个结构体指针  代替柔性数组成员
}Test;

int main(){
	Test *t2 = (Test*)malloc(sizeof(Test));
	t2->i = 10;
	t2->a = (int*)malloc(sizeof(int) * 10);	//在这里指针a指向另一个malloc到的内存,大小也可以自己设置
	free(t2->a);
	free(t2);
	return 0;
}

相比之下柔性数组还是优于代码二的,两者区别就是:

  1. 柔性数组只需要free一次,而代码二需要malloc两次并free两次,开销增大。
  2. 代码二有一定的风险,如果进行free操作时先free(t2);,再进行free(t2->a);,就会造成错误,所以还需多费精力处理这两者的顺序。
  3. 代码二略微的一点优势就是有利于访问速度,连续的内存有益于提高访问速度,也有益于减少内存碎片。

特点

  • 结构中的柔性数组成员前面必须至少包含一个其他成员
  • sizeof 返回的这种结构大小不包括柔性数组的内存。如果结构中成员为{int i; int a[0];},那么结构体sizeof的结果就是4
  • 包含柔性数组成员的结构用malloc函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小

猜你喜欢

转载自blog.csdn.net/qq_42351880/article/details/87366529