详解动态内存管理【malloc/calloc/realloc/free函数/柔性数组】【C语言/进阶/数据结构基础】

简介: 详解动态内存管理【malloc/calloc/realloc/free函数/柔性数组】【C语言/进阶/数据结构基础】

前言

动态内存管理是今后学习数据结构的基础,它弥补了之前学习一般数组的缺点,即不能按需使用内存:数组在初始化时的大小就已经被确定了。这种规定虽然提高了安全性,但对合理高效地使用内存不利,这篇文章将详细讲解几种动态内存管理函数、讲解经典笔试题以加深理解、介绍C/C++内存开辟的特点以及柔性数组的使用

1. 为什么存在动态内存分配

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

//1. 创建一个变量
int a = 20;//在栈空间上开辟四个字节
//2. 创建一个数组
char arr[10] = { 0 };//在栈空间上开辟10个字节的连续空间

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

  1. 空间开辟大小是固定的。
  2. 数组在初始化时,必须指定数组的长度,它所需要的内存在编译时分配。 但事实上,实际应用场景中程序需要的内存空间往往是变化的,若因为上述条件的约束,而在数组初始化时将长度设定很长,这样就浪费了很多空间。所以动态内存分配出现了。例如
int num = 0;
    scanf("%d", &num);
    //这种写法是不被允许的

注意:

动态内存分配是在堆区上处理的

栈区:不需要我们维护,一个变量只要进入它的作用域,栈区会自动为它开辟空间,当变量超出了它的作用域后,栈区也会自动回收该空间

堆区:需要我们人为地开辟空间,也一定要人为地归还空间

2. 动态内存函数

2.1 malloc和free

malloc要和free配对使用

malloc和free都声明在stdlib.h 头文件中。

2.1.1 malloc

void* malloc (size_t size);//字节

要点

  1. 这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
  2. 如果开辟成功,则返回一个指向开辟好空间的指针(起始地址)。
  3. 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
  4. 返回值的类型是void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
  5. 如果参数size 为0,malloc将如何做是标准是未定义的,取决于编译器。

2.1.2 free

void free (void* ptr);

要点

  1. free函数用来释放动态开辟的内存。
  2. 如果参数ptr 指向的空间不是动态开辟的,那free的行为是未知的。也就是说,先用malloc开辟内存才能用free释放。(但如果free的是空指针,即使没有先malloc也是符合语法的,因为不会发生任何事,就好像对0加减乘除一样)
  3. 如果参数ptr 是NULL指针,则函数什么事都不做。

2.1.3 用例

#include <stdio.h>
int main()
{
  int* ptr = NULL;//初始化指针
  ptr = (int*)malloc(1000);
  if (NULL != ptr)//判断ptr指针是否为空
  {
    int i = 0;
    for (i = 0; i < num; i++)
    {
      *(ptr + i) = 0;
    }
  }
  free(ptr);//释放ptr所指向的动态内存
  ptr = NULL;//free掉以后一定要将指针置空
  return 0;
}

要点

  1. 内存泄漏:在程序结束前,如果只向内存申请在堆区开辟空间,使用完毕后却不free,没有将内存空间还给堆区,操作系统会认为用户还在使用,这块内存就相当于浪费了,造成内存泄漏。

为什么是程序结束前?因为程序结束后会自动将内存还给操作系统。

  1. 开辟内存的不同写法
ptr = (int*)malloc(1000);
ptr = (int*)malloc(100 * sizeof(int));
  1. 非法访问内存:仅仅将内存free还不够,因为内存中的内容(如果其他程序没有使用的话),指针存放的地址都没有被改变,free掉使用过的内存仅是向系统传达一个“申请的内存空间已经使用完毕”这个信息。如果后续再使用这个指针p,那么它还是指向那块内存空间的,但这个内存空间已经还给操作系统了,若再访问,则造成非法访问内存。

  1. 要完全切断ptr和free之前的内存空间的联系,就要将ptr置为空指针。所以这两句是一定要在一起。
free(ptr);//释放ptr所指向的动态内存
  ptr = NULL;//free掉以后一定要将指针置空

2.2 calloc

void* calloc (size_t num, size_t size);

要点

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

举个例子:

#include <stdio.h>
#include <stdlib.h>
int main()
{
  int* p = (int*)calloc(10, sizeof(int));
  if (NULL != p)
  {
    for (int i = 0; i < 10; i++)
    {
      printf("%d ", *(p + i));//打印
    }
  }
  free(p);
  p = NULL;
  return 0;
}

2.3 realloc

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

要点

  1. ptr 是要调整的内存地址
  2. size 调整之后新大小
  3. 返回值为调整之后的内存起始位置。如果失败,返回空指针。
  4. 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。
  5. realloc在调整内存空间的方式有两种:
  1. 情况1:原有空间之后有足够大的空间

当空间足够大 ,直接再原有内存之后追加空间,直至达到size大小,原来空间的数据不发生变化。返回原有内存的地址

  1. 情况2:原有空间之后没有足够大的空间

当没有足够空间,在堆区找到一个足够大的连续空间,将原有的数据copy,然后再在它后面拓展空间。这样函数返回的是一个新的内存地址。

由于上述的两种情况,realloc函数的使用就要注意一些。

  1. 对于以上代码,是两个指针(p和ptr)变量维护一个内存空间,语法上是可以只用一个维护的,但是这样做是很危险的:如果realloc操作失败,返回空指针,这时原本维护内存的指针就变成了空指针,而且没有free,相当于造成了内存泄漏。
  2. 所以要用一个指针变量专门维护内存空间,还要用一个指针变量接收realloc的返回值。若非要用一个指针变量维护,则需要判断指针是否否为空。
  3. 上面的程序有个问题,你能找出来吗?

3. 常见错误

3.1 对NULL指针解引用

void test()
{
  int num = 0;
  scanf("%d", &n);
  int* p = (int*)malloc(num);
  *p = 20;//如果p的值是NULL,就会有问题
  free(p);
}

如果malloc操作失败返回NULL,p的值就为NULL,后续操作都是无效的。

改进

void test()
{
  int num = 0;
  scanf("%d", &n);
  int* p = (int*)malloc(num);
  if(p != NULL)//判断指针是否为空
  *p = 20;
  free(p);
}

3.2 越界访问动态开辟空间

void test()
{
  int i = 0;
  int* p = (int*)malloc(10 * sizeof(int));
  //这里只开辟了10个int大小的空间
  if (NULL == p)
  {
    return 0;
  }
  for (i = 0; i <= 10; i++)
  {
    *(p + i) = i;//循环11次,当i是10的时候越界访问
  }
  free(p);
}

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

void test()
{
  int a = 10;
  int* p = &a;
  free(p);//ok?
}

free必须在开辟内存之后使用

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

#include<stdio.h>
#include<stdlib.h>
int main()
{
  int* p = (int*)malloc(10 * sizeof(int));
  if (p == NULL)
    return 0;
  for (int i = 0; i < 10; i++)
  {
    *p = 1;
    p++;//改变了起始地址
  }
  free(p);
  p = NULL;
  return 0;
}

free传入的地址一定要是原来开辟空间的起始地址

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

#include<stdio.h>
#include<stdlib.h>
int main()
{
  int* p = (int*)malloc(10 * sizeof(int));
  if (p == NULL)
    return 0;
  free(p);//c第一次
  p = NULL;
  //一堆代码...
  free(p);//第二次
  p = NULL;
  return 0;
}

一个函数内不能多次对同一个内存空间free。这里体现指针置空的好处,第一次将它置零,后面就相当于对k进行无效操作。

3.6 动态开辟内存未释放(内存泄漏)

#include<stdio.h>
#include<stdlib.h>
int main()
{
  int* p = (int*)malloc(100);
  if (NULL != p)
  {
    *p = 20;
  }
  return 0;
}

动态开辟的空间一定要正确地free。

4. 笔试题

4.1 题目1

//请问运行Test 函数会有什么样的结果?
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
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函数,然后再调用GetMemory函数。

在Test函数中将存放着NULL地实参str传给Getmemory,而形参是实参的一份临时拷贝,所以Getmemory定义的指针变量p指向的是另外的一个存放着NULL的内存,在函数内部对NULL进行内存开辟是无效的。

GetMemory调用完毕,那块内存已经被销毁,而str指向的内存还是之前存放NULL的内存。strcpy想将一个常量字符串放入NULL中,这是无效操作。

所以程序什么都不会发生,编译器会报内存崩溃错误。

其次这个代码本身是有问题的:开辟内存后没有free。

改进

不难知道,这个程序的目的是将ptr指向的空间拓展,然后存放一个常量字符串。而问题出现在函数传参的过程中,函数没有起到修改原内存内容的作用。有两种改进方法

  1. 先将一个指针变量的地址传给函数,让它开辟内存空间
    在调用GetMemory时,应该传入&str,形参也应该以char**的形式定义,开辟内存维护的指针也应该是*p。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
void GetMemory(char** p)
{
  *p = (char*)malloc(100);
}
void Test(void)
{
  char* str = NULL;
  GetMemory(&str);
  strcpy(str, "hello world");
  printf(str);
  free(ptr);//free掉
  ptr = NULL;
}
int main()
{
  Test();
  return 0;
}
  1. GetMemory的作用是用一个指针变量维护开辟的内存空间,然后返回这个指针,赋值给Test中的str
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
char* GetMemory(char* p)//改变了返回值类型
{
  p = (char*)malloc(100);
  return p;
}
void Test(void)
{
  char* str = NULL;
  str = GetMemory(str);//将返回值赋值给str
  strcpy(str, "hello world");
  printf(str);
  free(ptr);
  ptr = NULL;
}
int main()
{
  Test();
  return 0;
}

请思考 :一般变量在函数调用完毕后会被销毁,那这块开辟的内存空间也会被销毁吗?

4.2 题目2

#include<stdio.h>
#include<stdlib.h>
#include<string.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;
}

解读

p数组是一个局部数组,GetMemory被调用完毕以后,返回的是一个野指针,打印出来的是随机值

4.3 题目3

#include<stdio.h>
#include<stdlib.>
#include<string.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;
}

解读

程序使str指向Getmemory开辟的内存空间并放入一个常量字符串,打印成功

错误在于未释放开辟的空间

4.4 题目4

#include<stdio.h>
#include<stdlib.h>
#include<string.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;
}

解读

这是一个十分奇怪的错误:copy完常量字符串到开辟的内存空间以后就free掉了,紧接着又使用这块空间,造成非法访问内存

free的作用仅是向操作系统传达“已经使用完毕”这一信息,其内存空间的内容暂时不会改变(如果其他程序没有用到这块内存的话),紧接着判断指针是否为空,然后world覆盖了hello,打印结果为world

free的同时也要将指针变量置空,因为free没有这个功能

改进

显然,在开辟完空间以后就应该判断str是否为空,而不是在free掉以后再判断。不可直接将free放到程序末尾。本题考点是free的同时将指针变量置空

#include<stdio.h>
#include<stdlib.h>
#include<string.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. C/C++程序的内存开辟

以一段代码为例

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

  1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
    栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返 回地址等。
  2. 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由操作系统回收 。分配方式类似于链表。
  3. 数据段(静态区)(static)存放全局变量、静态数据。程序结束后由系统释放。
  4. 代码段:存放函数体(类成员函数和全局函数)的二进制代码。

以上图理解static修饰局部变量

实际上普通的局部变量是在栈区分配空间的,栈区的特点是:在栈区创建的变量一旦超出了作用域就会被销毁。但是被static修饰的变量存放在数据段(静态区),数据段的特点是:在静态区创建的变量,直到程序结束才会被销毁。所以生命周期变长。

6. 柔性数组

C99 中,结构中的最后一个元素允许是未知大小的数组,这就叫做柔性数组成员。

例如

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

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

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

6.1 柔性数组的特点

  1. sizeof 返回的这种结构大小不包括柔性数组的内存大小。
typedef struct st_type
{
  int i;
  int a[0];//柔性数组成员
}type_a;
int main()
{
  printf("%d\n", sizeof(type_a));//输出的是4
  return 0;
}
  1. 结构中的柔性数组成员前面必须至少有一个其他成员。
  2. 包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。

6.2 柔性数组的使用

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
struct s
{
  int i;
  int a[0];//柔性数组成员
};
int main()
{
  int i = 0;
  struct s* p = (struct s*)malloc(sizeof(struct s) + 100 * sizeof(int));
  //使用指针变量维护和使用malloc开辟内存要强转
  p->i = 100;//修改成员i的值
  for (i = 0; i < 100; i++)
  {
    p->a[i] = i;//修改成员a的值
  }
  free(p);
  p = NULL;
  return 0;
}

6.3 柔性数组的优势

以上代码和下面等价,但有所不同。以下面的代码为例与之作比较。

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
typedef struct st_type
{
  int i;
  int* p_a;//后面把这个指针变量当数组使用
}type_a;
int main()
{
  type_a* p = (type_a*)malloc(sizeof(type_a));
  //为结构体开辟内存
  p->i = 100;//修改成员i
  p->p_a = (int*)malloc(p->i * sizeof(int));
  //为数组成员开辟内存
  for (int i = 0; i < 100; i++)
  {
    p->p_a[i] = i;//修改数组成员
  }
  //释放空间
  free(p->p_a);
  p->p_a = NULL;
  free(p);
  p = NULL;
  return 0;
}

这里将一个数组作为结构成员,并单独为它开辟内存空间,每次通过结构访问成员使用它,这和柔性数组的特性是十分类似的。

不同或者说是前者的优点

  1. 方便内存释放

注意这个程序开辟了两次空间,一次为结构体,一次为数组,所以需要free两次。但假设这两次内存的开辟都是在一个函数中,当别人使用这个函数不知道要free两次,只free了一次,这会造成问题。所以前者的写法是符合习惯的。开辟一次内存,free一次。

  1. 提高内存访问速度

能提高,但只能提高一点点。访问一块连续的内存,比访问若干分散的内存速度要快。但以当今的性能,这点速度是很难感知的。所以主要的优点是前者。

结语

动态内存管理是数据结构的基础,一个比较长的链表需要不断地开辟和释放内存,但这么做的意义不仅在于更灵活地使用数组,弥补一般数组不能按需增加长度的缺点,更在于这么做能提高内存的使用效率,这是单纯一个数组无法做到的。

欢迎指正!

如果你有收获的话,不妨给作者一个鼓励吧~

目录
相关文章
|
1月前
|
编译器 C语言 C++
详解C/C++动态内存函数(malloc、free、calloc、realloc)
详解C/C++动态内存函数(malloc、free、calloc、realloc)
154 1
|
1月前
一刻也没有为它哀悼~接下来登场的是动态内存分配的malloc与realloc以及free函数
一刻也没有为它哀悼~接下来登场的是动态内存分配的malloc与realloc以及free函数
65 0
|
3月前
|
程序员 C++
malloc与free的内存管理奥秘:技术分享
【8月更文挑战第22天】在软件开发过程中,内存管理是一个至关重要的环节。特别是在使用C或C++这类语言时,程序员需要手动管理内存的分配与释放。malloc和free函数是这一过程中的核心工具。本文将深入探讨malloc如何分配内存,以及free如何知道释放多少内存,帮助你在工作学习中更好地掌握这一技术干货。
89 4
|
1月前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
34 3
|
6天前
|
C语言
c语言调用的函数的声明
被调用的函数的声明: 一个函数调用另一个函数需具备的条件: 首先被调用的函数必须是已经存在的函数,即头文件中存在或已经定义过; 如果使用库函数,一般应该在本文件开头用#include命令将调用有关库函数时在所需要用到的信息“包含”到本文件中。.h文件是头文件所用的后缀。 如果使用用户自己定义的函数,而且该函数与使用它的函数在同一个文件中,一般还应该在主调函数中对被调用的函数做声明。 如果被调用的函数定义出现在主调函数之前可以不必声明。 如果已在所有函数定义之前,在函数的外部已做了函数声明,则在各个主调函数中不必多所调用的函数在做声明
22 6
|
26天前
|
存储 缓存 C语言
【c语言】简单的算术操作符、输入输出函数
本文介绍了C语言中的算术操作符、赋值操作符、单目操作符以及输入输出函数 `printf` 和 `scanf` 的基本用法。算术操作符包括加、减、乘、除和求余,其中除法和求余运算有特殊规则。赋值操作符用于给变量赋值,并支持复合赋值。单目操作符包括自增自减、正负号和强制类型转换。输入输出函数 `printf` 和 `scanf` 用于格式化输入和输出,支持多种占位符和格式控制。通过示例代码详细解释了这些操作符和函数的使用方法。
34 10
|
20天前
|
存储 算法 程序员
C语言:库函数
C语言的库函数是预定义的函数,用于执行常见的编程任务,如输入输出、字符串处理、数学运算等。使用库函数可以简化编程工作,提高开发效率。C标准库提供了丰富的函数,满足各种需求。
|
25天前
|
机器学习/深度学习 C语言
【c语言】一篇文章搞懂函数递归
本文详细介绍了函数递归的概念、思想及其限制条件,并通过求阶乘、打印整数每一位和求斐波那契数等实例,展示了递归的应用。递归的核心在于将大问题分解为小问题,但需注意递归可能导致效率低下和栈溢出的问题。文章最后总结了递归的优缺点,提醒读者在实际编程中合理使用递归。
53 7
|
25天前
|
存储 编译器 程序员
【c语言】函数
本文介绍了C语言中函数的基本概念,包括库函数和自定义函数的定义、使用及示例。库函数如`printf`和`scanf`,通过包含相应的头文件即可使用。自定义函数需指定返回类型、函数名、形式参数等。文中还探讨了函数的调用、形参与实参的区别、return语句的用法、函数嵌套调用、链式访问以及static关键字对变量和函数的影响,强调了static如何改变变量的生命周期和作用域,以及函数的可见性。
29 4
|
30天前
|
存储 编译器 C语言
C语言函数的定义与函数的声明的区别
C语言中,函数的定义包含函数的实现,即具体执行的代码块;而函数的声明仅描述函数的名称、返回类型和参数列表,用于告知编译器函数的存在,但不包含实现细节。声明通常放在头文件中,定义则在源文件中。