【C语言】深度剖析动态内存管理

简介: 【C语言】深度剖析动态内存管理

0. 前言


在平常开辟数组的时候,你是否为空间不足、空间浪费、空间无法调整而烦恼?如果对此头疼不已,相信看完这篇博客,你的问题就能迎刃而解。没错,本篇博客就是对动态内存管理的讲解。博客中,对于动态内存的相关函数、使用动态内存时经常出现的问题,和几道经典笔试题做了详细讲解。话不多说,我们这就开始。




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


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

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



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


   空间开辟大小是固定的,无法扩容或减容,可能会空间不足或空间浪费。

   数组在定义的时候,必须指定数组的长度,它所需要的内存在编译时分配。


但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了(数组需要提前指定好大小,因为编译的时候需要确定函数栈空间大小,遇到运行位置才能确定大小的情况就不太适合了),那么这时不如试试动态内存开辟!




2. 动态内存函数


2.1 malloc

c语言提供了一个动态内存开辟的函数:

void* malloc (size_t size);



malloc是一个开辟动态内存的函数,参数size为开辟空间的内存大小,单位是字节。函数返回值为void*的指针,开辟成功返回开辟空间的地址,失败返回NULL空指针



2.1.1 申请空间成功


例如,开辟一个四十个字节的空间:

#include <stdlib.h>//所需头文件
int main()
{
  void* p = malloc(40);
  return 0;
}


但是这样使用还是不够准确的,因为p的类型是void*void*的指针也不知道步长,也不能解引用,也不能±,不如我们直接将p强制类型转换成对应类型。就比如我们想申请一个10个整形元素的空间。

int* p = (int*)malloc(40);


当malloc成功申请到空间,返回这块空间的起始地址。


2.1.2 申请空间失败


倘若我们申请空间,失败了。例如我内存只有4个G,但是我要申请1个T的空间,这时就会返回NULL空指针。


所以当空间开辟失败这是很危险的,所以在每次开辟空间后最好来一个判断:

int main()
{
  int* p = (int*)malloc(INT_MAX);//21亿多,整形的最大值
  if (p == NULL)
  {
    printf("%s\n", strerror(errno));//打印错误码,了解错误
    return 1;//异常返回
  }//使用
  return 0;
}


运行结果:3a8cf6f956d6fa6b9eb10f08eb0d8955.png

这里也可以用断言,断言为直接将程序奔溃,雷厉风行;而if语句则是一个委婉的处理,让我们看到对应的错误。一般在传参时参数检查使用断言,malloc等开辟空间的函数使用if语句判断是否为空指针。

int main()
{
  int* p = (int*)malloc(INT_MAX);//21亿多
  assert(p);//断言
  return 0;
}

运行结果:

635c25f72a493af941f27a96ab26f7b3.png


2.1.3 总结

void* malloc (size_t size);



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


   size是需要动态开辟空间的内存大小。

   如果开辟成功,则返回一个指向开辟空间起始处的指针。

   如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。

   返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候自己来决定。

   如果参数size为0,malloc的行为是标准未定义的,取决于编译器。




2.2 free


2.2.1 free的重要性

我们平常创建的局部变量,出作用域自动销毁,它在栈区上开辟。


而动态内存分配,如malloc等函数是在堆区上开辟空间的,它并不会自动销毁,需要自己回收,或者程序结束时自动回收。

image-20220923165941539.png


但是程序结束时自动回收有个缺点,当这个程序不结束时,这块空间就会一直存在。试想一下,如果运行一个大规模的程序,程序运行的周期很长,但是动态内存一直在开辟空间,也不释放,最后会不会因为内存不足,导致内存耗干?导致电脑卡死?


然后就会出现某些灵异现象,程序一跑起来就很卡,过一会程序结束就没了,或者没有关掉程序,然后电脑越来越卡,只能重启,重启完毕又好了的事情,如果内存没有及时释放,你说多恐怖?这件就是典型的"吃内存"现象。



所以C语言提供了另外一个参数free,专门用来做动态内存的释放和回收:

void free (void* ptr);


free用来释放动态内存开辟的空间,参数ptr为指向开辟空间的首地址处的指针。函数没有返回值。若参数为动态开辟的起始地址,释放空间。若参数为NULL空指针,则不进行操作。



2.2.2 free的使用

例如:

int main()
{
  int* p = (int*)malloc(40);
  if (p == NULL)
  {
    printf("%s\n", strerror(errno));//打印错误码,了解错误
    return 1;//异常返回
  }
  //使用
  int i = 0;
  for (i = 0; i < 10; i++)
  {
    *p = i;
    p++;//改变指向,p最后指向空间的结尾后面位置
  }
    //释放
    free(p);//ok?
  return 0;
}

这样做可不可行?答案是不行的,因为使用动态开辟的空间时,p被修改了,这时p释放的不是我们开辟的空间,这样就出问题了。


我们应该额外保存一份p的拷贝,用拷贝进行使用,最后再释放p的空间:

int main()
{
  int* p = (int*)malloc(40);
    int* ptr = p;
  if (p == NULL)
  {
    printf("%s\n", strerror(errno));//打印错误码,了解错误
    return 1;//异常返回
  }
  //使用
  int i = 0;
  for (i = 0; i < 10; i++)
  {
    *ptr = i;
    ptr++;//改变指向,p最后指向空间的结尾后面位置
  }
    //释放
    free(p);
    p = NULL;//及时置空
    ptr = NULL;//最好这样,防止把ptr误用
  return 0;
}



注意:

p需要及时置为空指针。当对p进行释放时,p对应的空间被置为随机值,但是p本身的地址还没有改变。这是很麻烦的,万一有人不知道,又使用了之前开辟的空间,这块空间我们已经还给操作系统无法使用了,这时访问了就属于非法访问,所以要及时置为空指针,让它无法被访问。

2c05e9dea694fc2b89cac6d4090bafa9.png


设想一下,如果我们把p释放了,但是没有置空,那它是不是个野指针,对应着指针的指向被释放。野指针很危险,现在拿NULL把它限制住了,我们不就安全了?


但是这里最好把ptr也置为空指针,因为ptr当前指向了不属于我们当前程序的空间,为防止误用,还是置空。但是ptr不用free,因为ptr指向的空间不是我们动态开辟的。



2.2.3 总结

void* free (void* ptr);


ptr是值为动态内存开辟的起始地址的指针。

free释放的是动态开辟的指针ptr指向的空间,而不是ptr本身,指针需指向开辟空间的首地址处。

如果ptr指向的空间不是动态开辟的,那free函数的行为是未定义的。

如果ptr是NULL空指针,则函数什么事都不做。

free释放空间后,需要将ptr置为空指针,防止野指针问题(指针指向空间被释放),造成非法访问。



2.3 calloc

倘若我们已经有了明确的目的我们要开辟多大的空间,类型是什么。那么我们就可以使用calloc函数。


calloc和malloc一样,也是由C语言提供,用来动态内存分配:

void* calloc (size_t num, size_t size);


calloc也是动态内存开辟空间的一个函数,参数num为开辟空间的元素个数,参数size为开辟空间元素的大小,单位是字节。函数返回值为void*,开辟成功返回开辟空间的地址,失败返回NULL空指针。


2.3.1 calloc的使用

和malloc一样,calloc返回值也是void*,所以我们在使用时需要强制类型转换。


例如开辟一个40字节,用来存储整形的空间:

int main()
{
    int* p = (int*)calloc(10, sizeof(int));
    if (p == NULL)
    {
        perror("calloc");//打印错误信息
        return 1;
    }
    int i = 0;
    //使用
    for (i = 0; i < 10; i++)
    {
      *(p + i) = i;//不改变指向   
    }
    //释放
    free(p);
    p = NULL;
    return 0;
}


calloc开辟空间失败也会返回NULL,所以需要判断。并且需要释放开辟的空间,这里由于p并没有改变指向,p还是指向原来的位置,所以直接释放p置空即可。



2.3.2 malloc和calloc的区别


  1. malloc传参时直接传递开辟空间的大小,calloc传参时传元素个数和元素的大小。
  2. malloc开辟的空间默认值为随机值,calloc开辟的空间默认值为0。


2e86338db8bf54eb0422f15ef9ea9c2d.png


calloc相当于把开辟的空间每个元素设置为0,然后返回起始地址。相当于calloc = malloc + memset(内存设置为0)。


总结:


开辟的空间需要初始化,使用calloc,不需要初始化,使用malloc。但是malloc不初始化效率会更高,calloc效率较malloc会比较低。



2.3.3 总结

void* calloc (size_t num, size_t size);


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



2.4 realloc

realloc函数的出现会让动态内存管理更加灵活。


有时我们发现申请的空间太小了,有时我们又会觉得申请的空间过大了,那为了合理的申请内存,我们就必须对内存大小做出灵活的调整,那么realloc函数就可以做到对动态内存开辟内存大小的调整。

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


realloc是调整动态开辟内存大小的函数,ptr为指向动态内存开辟空间的指针,size为调整过后这块空间的大小,单位是字节。函数返回值为void*,调整成功返回指向调整之后的内存块,失败返回NULL空指针。


2.4.1 realloc调整空间的两种情况


  1. 当前内存空间大小充足,则跟着原先开辟的空间继续向后开辟,返回原来的空间的起始地址。

e37086301c2e15f1c4038e5f3d3d39a5.png

当前内存空间大小不够,重新寻找内存,单独开辟一块全新的空间,空间大小满足调整大小。将原先空间的数据先拷贝到当前空间,再释放掉原先的空间,返回新开辟空间的起始地址。

00e5a4fcfca5f90aeb1e4e6c80ece886.png

realloc调整后的空间比原先空间小,直接在原先空间的基础上缩短空间大小,返回原来空间的起始地址。


2.4.2 realloc的使用


对于realloc调整内存,还是要着重强调一下前两种情况:


  1. 内存足够在原有内存之后追加空间,返回原先空间的起始地址。
  2. 内存不足重新开辟调整大小的空间,先拷贝数据,在释放原先空间,返回新空间起始地址。

例如,一个realloc的正常使用:

int main()
{
    //动态开辟
  int* p = (int*)malloc(40);
  if (p == NULL)
  {
    return 1;
  }
  //使用
  int  i = 0;
  for (i = 0; i < 10; i++)
  {
    *(p + i) = i;
  }
  //打印
  for (i = 0; i < 10; i++)
  {
    printf("%d ", *(p + i));
  }
  //增加空间
  int* ptr = (int*)realloc(p, 80);
    //判断
  if (ptr != NULL)
  {
    p = ptr;
    ptr = NULL;//防止ptr误使用
  }
  //扩容后使用
  for (i = 10; i < 20; i++)
  {
    *(p + i) = i;
  }
    //释放
  free(p);
  p = NULL;
  return 0;
}


这里有几个注意点,需要重点提一下。


2.4.2.1 注意点 1

一定要接收realloc的返回值。


首先,得了解函数调整内存的情况。不要不知所云就认为realloc不管什么情况都是以原先空间的基础上向后延伸.

一定要返回值接收,否则当开辟空间足够大,返回新空间的地址时,如果我们不用返回值接收,就像这样:

int main()
{
  int* p = (int*)malloc(8000);
  if (p == NULL)
  {
    return 1;
  }
  //使用
  int  i = 0;
  for (i = 0; i < 10; i++)
  {
    *(p + i) = i;
  }
  realloc(p, 80);//无返回值接收
  //扩容后使用
  for (i = 10; i < 20; i++)
  {
    *(p + i) = i;
  }
  free(p);
  p = NULL;
  return 0;
}

运行一下:


image-20220924115541942.png


分析:

程序直接奔溃了,因为realloc调整空间时,发现空间不足,只能找一块全新的位置开辟,将原先的空间释放掉了,而我们并没有用返回值接收调整p,那么就用p非法访问了内存。



2.4.2.2 注意点 2

用全新的指针接收realloc的返回值,而不是直接用动态开辟内存的指针接收。


我们知道realloc调整空间失败返回NULL空指针。

如果将NULL赋给原先指向开辟空间的p指针。比如,p原本指向40个字节的空间,但是空间调整失败了,直接给我弄成了空指针。这不是偷鸡不成蚀把米嘛!连原本的空间都没了,你说realloc这个老六干的什么事情!


所以我们需要用一个全新的指针来接收,比如这样:

int* ptr = (int*)realloc(p, 80);


当然仅仅用返回值接收肯定不够,当然还要赋给我们之前的指针。当然在这时要对返回值做出判断,并且及时将ptr置空。因为ptr被赋值,以后这块空间就由先开始的指针进行管理并释放,为了保险起见,不让ptr影响p的操作,于是把ptr置空,防止误操作。

int main()
{
    int* p = (int*)malloc(40);
    if (p == NULL)
    {
        return 1;
    }
    int* ptr = (int*)realloc(p, 80);
    if (ptr == NULL)//判断
    {
        p = ptr;
        ptr = NULL;//置空
    }
    return 0;
}



2.4.2.3 注意点 3

当第一个参数为NULL空指针时,realloc起到和malloc/calloc一样的作用。

int main()
{
    int* p = (int*)realloc(NULL, 40);//等价于malloc(40)
    return 0;
}


2.4.3 总结

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


ptr 是要调整的内存地址

size 是调整之后新大小

返回值为调整之后的内存起始位置。

这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。

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

   情况1:原有空间之后有足够大的空间。

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

一定要接收realloc的返回值。

用全新的指针接收realloc的返回值,而不是直接用动态开辟的指针接收。

当ptr为NULL空指针时,realloc起到和malloc/calloc一样的作用。



2.5 malloc/calloc和free的问题

malloc和free的次数相同,不能开辟空间不释放,会造成内存泄漏。也不能多次释放,同样的对于calloc也是这样。


malloc/calloc不成对出现代码一定错误,但是malloc/calloc成对出现也可能写不出正确的代码。

举个例子:

int test()
{
  int* p = (int*)malloc(40);
  if (p == NULL)
  {
    //...
    return 1;
  }
  //使用
  if (1)//某个条件满足
  {
    return 2;//条件满足返回
  }
  //释放
  free(p);//没有释放
  p = NULL;
  return 0;
}
int main()
{
  test();
  return 0;
}

分析:


这里malloc和free成对出现,但是由于满足条件,函数提前结束了,然后p指向的空间就没有释放,依然错误。


而p又是在函数中创建的,等函数结束,p也销毁,也并没有返回值来记住p,p在函数中指向的那块空间是被开辟的,但是出了函数就没人知道这块空间在哪里,这就造成了内存泄漏。



相关文章
|
5月前
|
C语言 C++
C语言 之 内存函数
C语言 之 内存函数
54 3
|
3月前
|
存储 编译器 程序员
【C语言】内存布局大揭秘 ! -《堆、栈和你从未听说过的内存角落》
在C语言中,内存布局是程序运行时非常重要的概念。内存布局直接影响程序的性能、稳定性和安全性。理解C程序的内存布局,有助于编写更高效和可靠的代码。本文将详细介绍C程序的内存布局,包括代码段、数据段、堆、栈等部分,并提供相关的示例和应用。
84 5
【C语言】内存布局大揭秘 ! -《堆、栈和你从未听说过的内存角落》
|
3月前
|
存储 缓存 算法
【C语言】内存管理函数详细讲解
在C语言编程中,内存管理是至关重要的。动态内存分配函数允许程序在运行时请求和释放内存,这对于处理不确定大小的数据结构至关重要。以下是C语言内存管理函数的详细讲解,包括每个函数的功能、标准格式、示例代码、代码解释及其输出。
134 6
|
4月前
|
传感器 人工智能 物联网
C 语言在计算机科学中尤其在硬件交互方面占据重要地位。本文探讨了 C 语言与硬件交互的主要方法,包括直接访问硬件寄存器、中断处理、I/O 端口操作、内存映射 I/O 和设备驱动程序开发
C 语言在计算机科学中尤其在硬件交互方面占据重要地位。本文探讨了 C 语言与硬件交互的主要方法,包括直接访问硬件寄存器、中断处理、I/O 端口操作、内存映射 I/O 和设备驱动程序开发,以及面临的挑战和未来趋势,旨在帮助读者深入了解并掌握这些关键技术。
103 6
|
4月前
|
存储 C语言
C语言如何使用结构体和指针来操作动态分配的内存
在C语言中,通过定义结构体并使用指向该结构体的指针,可以对动态分配的内存进行操作。首先利用 `malloc` 或 `calloc` 分配内存,然后通过指针访问和修改结构体成员,最后用 `free` 释放内存,实现资源的有效管理。
340 13
|
4月前
|
存储 编译器 数据处理
C 语言结构体与位域:高效数据组织与内存优化
C语言中的结构体与位域是实现高效数据组织和内存优化的重要工具。结构体允许将不同类型的数据组合成一个整体,而位域则进一步允许对结构体成员的位进行精细控制,以节省内存空间。两者结合使用,可在嵌入式系统等资源受限环境中发挥巨大作用。
128 12
|
4月前
|
大数据 C语言
C 语言动态内存分配 —— 灵活掌控内存资源
C语言动态内存分配使程序在运行时灵活管理内存资源,通过malloc、calloc、realloc和free等函数实现内存的申请与释放,提高内存使用效率,适应不同应用场景需求。
|
4月前
|
存储 C语言 开发者
C 语言指针与内存管理
C语言中的指针与内存管理是编程的核心概念。指针用于存储变量的内存地址,实现数据的间接访问和操作;内存管理涉及动态分配(如malloc、free函数)和释放内存,确保程序高效运行并避免内存泄漏。掌握这两者对于编写高质量的C语言程序至关重要。
111 11
|
4月前
|
存储 算法 程序员
C 语言指针详解 —— 内存操控的魔法棒
《C 语言指针详解》深入浅出地讲解了指针的概念、使用方法及其在内存操作中的重要作用,被誉为程序员手中的“内存操控魔法棒”。本书适合C语言初学者及希望深化理解指针机制的开发者阅读。
|
4月前
|
并行计算 算法 测试技术
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面,旨在通过综合策略提升程序性能,满足实际需求。
106 1