C语言:动态内存管理

我们已经掌握的内存开辟方式有:

int a = 0;              //在栈空间开辟四个字节的空间
char arr[10] = {0,};    //在栈空间开辟十个字节连续的空间

但是上述的开辟空间的方式有两个特点:

  1. 空间开辟大小是固定的
  2. 数组在声明的时候,必须先指定数组的长度,以便在编译的时候分配出数组所需要的内存

但是有时候我们所需要的空间要到程序运行后才知道,比如说数组版本的通讯录系统中实时添加一个联系人的信息,如果此时数组已经满了,就不能添加了.如果在程序运行的时候实时扩大空间就可以了.

C语言提供了动态开辟的概念以及相关实现,以供在运行时实现对空间的实时开辟.

1. 动态内存函数的介绍

1.1 malloc 和 free

malloc并不是从一个在编译时就确定的固定大小的数组中分配存储空间,而是在需要时向操作系统申请空间.

void* malloc(size_t size);

因为程序中的某些地方可能不通过malloc调用申请空间,所以,malloc管理的空间不一定是连续的.

这样,空闲存储空间以空闲块链表的方式组织,每个块包含一个长度,一个指向下一块的指针以及一个指向自身空间的指针.这些块按照存储地址的升序组织,最后一块(最高地址)指向第一块.

在这里插入图片描述

当有申请请求时,malloc将扫描空闲块链表,直到找到一个足够大的块为止,这种方法叫做首次适应(first fit);与之相对的是最佳适应(best fit),它寻找满足条件的最小块.

  • 如果找到的块恰好满足请求的大小,将这块空间从空闲块链表中移走并交给用户;
  • 如果找到的块太大,则将其分为两部分,一部分从空闲块链表中移走交给用户,另一部分留在空闲块链表中;
  • 如果找不到足够大的块,则向操作系统申请一个大块并放入空闲块链表中.

malloc函数中,为了内存对齐,请求的长度(以字符为单位)将被舍入,以保证他是头部大小的整数倍.

实际分配的块将多包含一个单元,用于头部本身.实际分配到的块的大小将被记录到size块内.

malloc返回的指针指向空闲空间的起始地址,用户只能在分配的空间内进行操作.如果在分配空间外写入数据,则可能会破坏块链表.

在这里插入图片描述

malloc是在堆空间内开辟空间的,同时一般在栈空间创建指针类型的自动变量指向malloc开辟的空闲空间首地址.
在这里插入图片描述


malloc开辟空间后不会主动还给操作系统,只有程序运行结束或者主动使用free函数才可以将空间还给操作系统.

void free(void* ptr);

释放过程也是首先搜索空闲块链表,以找到可以插入被释放块的合适位置.如果被释放块的任一一边也是个空闲块,则将这两个空闲块直接合并.这样就不会有太多的碎片.因为空闲块链表是以地址的递增顺序链接在一块的,所以很容易判断释放块是否有相邻空闲块.


使用实例

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

#define N 10

int main(void)
{
	//动态开辟一块能存放10个int类型的空间
	int* p = (int*)malloc(sizeof(int) * N);	

	//使用这块空间前一定要判断是否开辟成功
	if (p == NULL)
	{
		perror("malloc:");
		return 1;
	}

	//开辟成功,打印未初始化的空间的值
	int i = 0;
	for (i = 0; i < N; i++)
	{
		printf("%d\n", *(p + i));
	}

	//释放空间
	free(p);

	//p置为NULL,防止p成为野指针
	p = NULL;

	return 0;
}

程序运行结果如下:
在这里插入图片描述

  • 对于malloc函数
    • 如果开辟成功,则返回一个指针指向开辟好的空闲块的起始位置.
    • 如果开辟失败,则返回一个NULL指针,因此动态开辟空间后一定要检查是否开辟成功.
    • 返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体使用的时候按照需求进行强制类型转换.
    • 如果参数size0,则malloc函数的行为是未定义的,取决于编译器
    • malloc对空间不进行初始化,仅仅是像操作系统申请指定大小空间,并返回空间的起始地址.
    • malloc不会主动归还空间,只有程序运行结束或者主动使用free函数归还空间.
  • 对于free函数
    • 如果参数ptr指向的空间不是动态开辟的,那么free函数的行为则是未定义的.
    • 如果参数ptrNULL指针,则函数什么也不做.
    • free函数会根据ptr前一块空间,那一块空间记录了申请了size大小的空间,以便释放空间正确.
  • mallocfree函数都是在<stdlib.h>头文件中.

1.2 calloc

calloc也用来动态内存分配,同时将空间初始化为0

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

使用实例:

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

int main(void)
{
	char* p = (char*)calloc(10, sizeof(char));

	if (p == NULL)
	{
		perror("calloc:");
		return 1;
	}

	free(p);
	p = NULL;

	return 0;
}

通过调试,观察到p指向的10字节空间都被初始化为0

在这里插入图片描述

1.3 realloc

realloc的出现让动态内存管理更加灵活.
realloc改变ptr指向的已经分配的空间大小

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

参数

  • ptr - 指向要调整的内存地址
  • size - 调整后的新大小

返回值

  • 指向开辟的空间起始地址, 开辟失败则返回NULL

注意事项

  • ptrNULL指针,则realloc实现的功能和malloc是一样的
  • 返回的地址可能是原来的地址,也可能是新空间的地址

realloc在调整内存空间存在两种情况:

  • 情况1:原有空间后有足够大的空间
    • 直接在原有空间后追加内存空间,原来空间内的数据不发生变化,返回原来的地址
  • 情况2:原有空间后没有足够大的空间
      1. 在堆空间开辟一个足够大小的空间
      1. 将原来空间的数据拷贝到新的空间内
      1. 释放原来的空间,归还给操作系统
      1. 返回新空间的起始地址

注意:

情况2如果申请失败,则原来的空间不会被释放.所以不能直接用原来的ptr来接受realloc的返回值,这样如果申请失败,接受NULL,原来的空间也使用不了同时也是释放不了了,造成内存泄漏.

需要一个临时变量存储realloc返回值,如果内存开辟成功,再将临时变量赋值给原来的ptr,这样就能避免内存泄漏.


使用实例:

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

int main(void)
{
	int* ptr = (int*)malloc(sizeof(int) * 10);
	int* p = NULL;

	if (ptr == NULL)
	{
		perror("malloc");
		return 1;
	}

	//业务处理
	//...

	//扩展容量
	p = (int*)realloc(ptr, sizeof(int) * 10);

	if (p != NULL)
	{
		ptr = p;
		p = NULL;
	}
	else
	{
		perror("realloc:");
		return 1;
	}

	//扩展容量
	p = (int*)realloc(ptr, sizeof(int) * 1000);

	if (p != NULL)
	{
		ptr = p;
		p = NULL;
	}
	else
	{
		perror("realloc:");
		return 1;
	}

	free(ptr);
	ptr = NULL;

	return 0;
}
  • 第一次扩展空间属于情况1,ptr的值并没有改变
    在这里插入图片描述

  • 第二次扩展空间属于情况2,ptr的值被改变了
    在这里插入图片描述

2. 常见的动态内存错误

2.1 对NULL指针的解引用操作

void test()
{
    int* p = (int*)malloc(INT_MAX / 4);
    *p = 20; //如果p的值是NULL,就会有问题
    free(p);
}

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

void test()
{
    int i = 0;
    int *p = (int*)malloc(10 * sizeof(int));
    if(NULL == p)
    {
        exit(EXIT_FAILURE);
    }

    for(i = 0; i <= 10; i++)
    {
        *(p + i) = i; //当i是10的时候越界访问
    }

    free(p);
}

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

void test()
{
    int a = 10;
    int* p = &a;
    free(p);    //程序直接崩溃
}

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

void test()
{
    int* p = (int*)malloc(100);
    p++;
    free(p);    //p不再指向动态内存的起始位置
}

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

void test()
{
    int* p = (int*)malloc(100);
    free(p);
    free(p);    //重复释放
}

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

void test()
{
    int* p = (int*)malloc(100);
    if (NULL != p)
    {
        *p = 20;
    }
}

int main(void)
{
    test();
    while(1);
}

忘记释放不再使用的动态开辟的空间会造成内存泄漏
切记:
动态开辟的空间一定要释放,并且正确释放.

3. 几个经典的笔试题

3.1 题目1

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

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

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

运行Test函数会有什么样的结果?

  1. GetMemoryp是在栈空间临时创建的,接受实参NULL,随后开辟空间,并用p存放空间起始地址.但是形参的改变对实参没有影响.
  2. str在调用GetMemory后的值仍然是NULL,调用strcpy对空指针解引用,出错
  3. 同时在GetMemory函数内开辟的空间起始地址存放到了p中,随着函数调用结束,p的空间被释放,这块开辟后的空间找不到了,未能进行释放空间,造成了内存泄漏

在这里插入图片描述

正确应该使用传值调用,这样才能改变实参的值

void GetMemory(char** p)
{
	*p = (char*)malloc(100);
	if (*p == NULL)
	{
		perror("GetMemory");
	}
}

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

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

3.2 题目2

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

void Test(void)
{
	char* str = NULL;
	str = GetMemory();
	printf(str);
}

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

运行Test函数会有什么样的结果?

  1. GetMemory中,创建了一个字符数组,并将首元素地址返回.这里犯了将局部变量返回的错误.函数在栈上创建了变量,函数调用完毕后,空间还给操作系统,返回值指向的空间其实是一块未知的空间.
  2. str = GetMemory();中,将返回值赋值给str,str成为了野指针,使用str访问这片已经归还给操作系统的空间,造成了非法访问,实际会打印出随机值.

3.3 题目3

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

运行Test函数会有什么样的结果?

  1. GetMemory传址调用,可以修改实参的值.调用GetMemory后,str指向了开辟的大小为100字节的空间.
  2. 程序打印出hello,但是开辟后的空间没有被释放,造成了内存泄漏.

修改后的代码:

void GetMemory(char** p, int num)
{
	*p = (char*)malloc(num);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(&str, 100);
	strcpy(str, "hello");
	printf(str);
	free(str);
	str = NULL;
}

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

3.4 题目4

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

运行Test函数会有什么样的结果?

  1. str指向的开辟的空间被释放后,没有将str置为NULL.str仍然存放原来的值,成为了一个野指针.调用strcpy对野指针进行了访问,出现非法访问

修改后代码如下:

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

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

在这里插入图片描述

C/C++程序内存分配的几个区域:

  1. 程序代码和数据:对所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置.
  2. 栈区(stack):位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用.用户栈在程序执行期间可以动态地扩展和收缩.特别地,每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩.
  3. 共享库:大约在地址空间的中间部分是一块用来存放像C标准库和数学库这样的共享库的代码和数据的区域.
  4. 堆(heap):当调用像mallocfree这样的C标准库函数时,堆可以在运行时动态地扩展和收缩.
  5. 内核虚拟内存:地址空间顶部的区域是为内核保留的.不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数.相反,它们必须调用内核来执行这些操作.

这样就能更好的理解在初识C语言static关键字修饰局部变量的例子了

实际上普通的局部变量(自动变量)是在栈区上分配空间的,栈区的特点就是上面创建的变量出了作用域就被销毁.

但是被static修饰的变量存放在数据段(静态区),数据段的特点是在上面创建的变量,直到程序结束才销毁,所以生命周期变长.即使出了作用域也仍然存在.

5. 柔性数组

柔性数组(Flexible Array)是一种特殊的C语言数组,它允许在结构体中声明一个长度未知的数组.这个数组位于结构体的末尾,它的长度可以根据实际情况动态分配和调整.

其最后一个数组成员有一些特性.

  • 该数组不会立即存在
  • 就好像确实存在被具有所需数目的数组一样

C99新增特性,柔性数组结构有如下规则

  • 数组成员必须时结构的最后一个成员
  • 结构中至少有一个成员
  • 柔性数组的声明类似于普通数组,只是它的方括号是空的
    例如:
struct S
{
	int i;
	int a[0];	//柔性数组成员
};

有些编译器会报错无法编译可以改成

struct S
{
	int i;
	int a[];	//柔性数组成员
};

声明一个struct S类型的结构变量时,不能用a做任何事,因为没有给这个数组预留存储空间.

C99的意图不是让你声明一个struct S的变量,而是要你声明一个指向struct S类型的指针,然后用malloc来分配足够的空间,以存储struct S类型结构的常规内容和柔性数组成员所需要的额外空间.

假如,用a表示一个内含5int类型的数组,可以这样做:

//声明一个柔性数组结构类型的指针
//并且为一个结构和一个数组分配空间
struct S* pf = (struct S*)malloc(sizeof(struct S) + 5 * sizeof(int));

现在有足够的空间可以存储i和一个内含5int类型的数组.可以用指针pf来访问它们.

pf->i = 5;		//设置 i 成员
pf->a[0] = 1;	//访问数组元素

下面展示了使用柔性数组的例子:

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

struct flex
{
	size_t count;
	double average;
	double scores[];	//柔性数组成员
};

void showFlex(const struct flex* p)
{
	int i = 0;
	printf("Scores : ");
	for (i = 0; i < p->count; i++)
	{
		printf("%g ", p->scores[i]);
	}

	printf("\nAverage: %g\n", p->average);
}

int main(void)
{
	struct flex* pf1, * pf2;
	int i = 0;
	int sum = 0;
	int n = 5;

	//为结构和数组分配存储空间
	pf1 = (struct flex*)malloc(sizeof(struct flex) + n * sizeof(double));
	if (pf1 == NULL)
	{
		perror("malloc:");
		return 1;
	}
	pf1->count = n;
	for (i = 0; i < pf1->count; i++)
	{
		pf1->scores[i] = 20.0 - i;
		sum += pf1->scores[i];
	}
	pf1->average = sum / n;
	showFlex(pf1);

	n = 9;
	sum = 0;
	pf2 = (struct flex*)malloc(sizeof(struct flex) + n * sizeof(double));
	if (pf2 == NULL)
	{
		perror("malloc:");
		return 1;
	}
	pf2->count = n;
	for (i = 0; i < pf2->count; i++)
	{
		pf2->scores[i] = 20.0 - i / 2.0;
		sum += pf2->scores[i];
	}
	pf2->average = sum / n;
	showFlex(pf2);

	return 0;
}

程序运行结果如下:

在这里插入图片描述

柔性数组成员的的结构确实有一些特殊的处理要求:

  • 不能用结构进行赋值或拷贝;
    struct flex *pf1, *pf2;		//*pf1和*pf2 都是结构
    
    *pf1 = *pf2;		//不要这样做
    
    这样做只能拷贝除柔性数组成员以外的成员.确实要进行拷贝,可以使用memcpy函数进行拷贝.
  • 不要以按值方式把这种结构传递给函数.原因相同,按值传给一个函数与赋值类似.要把结构的地址传给函数
  • 不要将柔性数组结构的变量作为数组成员或者另一个结构的成员

柔性数组结构中的柔性数组成员,实际上是数组的首元素地址,如果直接改为放个指针可不可以呢?

例如设计成这样的格式:

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

struct flex
{
	size_t count;
	double average;
	double* scores;
};

int main(void)
{
	struct flex* pf = NULL;
	int n = 5;

	//为结构申请空间
	pf = (struct flex*)malloc(sizeof(struct flex));
	
	//为结构中的成员指向了又一次申请的空间
	pf->scores = (double*)malloc(n * sizeof(double));
	pf->count = n;

	//释放空间
	free(pf->scores);
	pf->scores = NULL;
	free(pf);
	pf = NULL;

	return 0;
}

这段代码也可以完成柔性数组的功能,但在底层还是稍有区别的.

在这里插入图片描述

归根结底,还是指针和数组的区别.
指针保存的是空间的地址,而数组原地就是空间.

柔性数组相比还有两个好处:
第一个好处是:方便内存释放

如果我们的代码是在一个给别人的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户.用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以不能指望用户来发现这个事.如果是柔性数组结构,一次性就可以分配好所需要的空间,同时用户free一次也可以把所有的内存释放掉.

第二个好处是:有利于访问速度

连续的内存有益于提高访问速度,也有益于减少内存碎片.malloc无论使用最佳适应还是首次适应来申请空间,不免都会产生内存碎片,内存碎片会减慢访问速度.

扩展阅读:陈皓大佬的C语言结构体里的成员数组和指针

本章完.

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值