C生万物 | 动态内存管理-3

简介: C生万物 | 动态内存管理

题目四

代码:

  • 下面这段代码,其总共有1处错误,你可试着自己分析一下:mag:
void Test(void)
{
  char* str = (char*)malloc(100);
  strcpy(str, "hello");
  free(str);
  if (str != NULL)
  {
    strcpy(str, "world");
    printf(str);
  }
}

分析:

错误:非法内存访问

  • 在使用free()释放完str这块空间的之后,虽然这个地址中所存放的内容销毁了,但是这块地址还是存在的,此时这个str就变成了野指针,指向了一块随机的地址,这块地址是不为空的,所以会进入if条件判断,那么在使用strcpy()的时候就造成了非法内存访问
  • 本题我们通过画图来进行讲解,在一开始我们动态申请了100个字节的空间,然后往这块空间中放入了hello这个字符串,接着立马进行了free()释放,那我们之前有说过一块动态申请的空间若是释放了的话,虽然空间销毁了,但是指针还是留存着那块空间的地址,此时这个str即为一个野指针,指向了一块未分配空间的地址,而且有着100个字节的大小,所以其是不为空的
  • 那么接下去所做的操作就是非法的了,又往这块空间存放了world这个字符串,这也就形成了【非法访问内存】,虽然去运行不存在问题,但是这块空间的使用权并不是我们的,这才有【非法】这么一说

image.png

改进:

那我们该如何去改进它呢?

  • 很简单,我们只需要在free(str)后将其置空即可,因为在将一块空间还给操作系统后,本身我们不再拥有这块空间的使用权了,后面的操作都是非法的,但若时间我们将其置为NULL之后,这个指针也就忘记了它之前所指向的地址,此时进不了下面的这个if分支了,那逻辑也就正确了
void Test(void)
{
  char* str = (char*)malloc(100);
  strcpy(str, "hello");
  free(str);
  str = NULL;
  if (str != NULL)
  {
    strcpy(str, "world");
    printf(str);
  }
}

五、C/C++程序的内存分布原理

接下去呢,我们来讲一讲C/C++程序的内存分布

  • 对于一个C/C++,你所做写的所有代码其实和内存相关的,例如你在函数内部创建一个变量,它就会在【栈区】上创建栈帧来进行存放这个变量,如果你通过malloc向【堆区】申请了一块空间并试图往里写点东西的时候,此时堆区就会多出来一块已经分配了的空间。
  • 不过,除此之外呢,我们可能还会涉及【静态区】或【代码段】,接下去我们就一起来看一下这几个区域吧👇
  1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
  2. 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。堆区主要进行动态内存分配,堆区的内存大小不固定,可以根据需要动态分配和释放
  3. 数据段 / 静态区(static)存放全局变量、静态数据。程序结束后由系统释放。其在程序编译时就确定了变量的存储空间大小和内存地址,具有固定的大小和位置
  4. 代码段:存储程序指令(代码)的一块内存区域,也被称为文本段(Text Segment),代码段通常是只读的,因为程序指令一般不应该被修改,代码段中存放函数体(类成员函数和全局函数)的二进制代码

光学习概念可不行,我们这里结合具体的代码来观察一下

int globalVar = 1;
static int staticGlobalVar = 2;
void test()
{
  static int staticVar = 3;
  int localVar = 4;
  int num1[5] = { 1,2,3,4,5 };
  char str[] = "abcd";
  char* ps = "abcd";
  int* ptr1 = (int*)malloc(sizeof(int) * 4);
  int* ptr2 = (int*)calloc(4, sizeof(int));
  int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 8);
  free(ptr1);
  free(ptr3);
}

具体地我们可以通过下图来进行观察:

  • 可以看到这里在外部我创建了一个全局变量以及静态变量,它们都是存放在【数据段 / 静态区】的,以及函数test中的staticVar,虽然它是一个函数当中所创建的变量,但是因为前面加上了一个static作为修饰,所以它所存放的地址也是【数据段 / 静态区】
  • 接下去是【栈区】,这个很明显,即为在函数内部所创建的临时变量,可以看到无论这个变量是怎样进行初始化的,其本身就会在栈区开辟出相应的栈帧
  • 然后是【堆区】,即为本文我们所讲到的三个动态内存函数malloc()calloc()realloc(),只要是所涉及的内存分配,都是在堆区中开辟的
  • 最后的话便是【代码段】,这一块可能接触地比较少一些,也很少听到,上面有讲到,代码段是只读的,里面存放的都是一些指令,或者为一些只读的常量。那我们看到这里的abcd,就是一个常量字符串,它就是不可修改的,因此是存放在【代码段】

image.png💬 这里我们先简答地讲一下有关C/C++内存分布,帮助理解本文的知识点。后续会专门出一篇相关文章进行详述,敬请期待🤛

六、柔性数组

在本文的最后呢,我们再来讲一下有关【柔性数组】的相关知识,这个相信很多同学都没有听说过,可要竖起耳朵👂认真听哦!

1、概念与声明

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

  • 例如说下面这一个结构体,它最后一个成员便是数组,那么此时这个数组a就被称作为是【柔性数组】
typedef struct st_type
{
  int i;
  int a[0]; //柔性数组成员
}type_a;
  • 不过呢,上面这样去声明一个数组在某些编译器中可能会报错,所以可以写成像下面这个样子,此时这个数组的大小就是不确定,随时可以去进行调整
typedef struct st_type
{
  int i;
  int a[];  //柔性数组成员
}type_a;

2、柔性数组的特点分析

知道了什么是柔性数组后,我们来逐一分析一下它的特点

1、sizeof 返回的这种结构大小不包括柔性数组的内存

  • 如果你有看过结构体内存对齐的话,就可以知道在计算结构体大小的时候每个成员的大小都是要计算在内,不过呢在看下图的执行结果中我们可以知道这个数组的大小完全是不计算在内的,完全就像隐形了一样

image.png2、结构中的柔性数组成员前面必须至少一个其他成员

  • 也就是说在这个结构体中只有数组a这么一个成员,但是呢它又是属于当前结构体中的最后一个也是唯一的成员,因为其为【柔性数组】,那若是连其他成员都没有的话这个结构体的大小就没有了,即没有空间了。这其实是很荒唐的一件事,若是这个结构体的内存空间都没有了的话,谈何为这个柔性数组去分配空间呢?
typedef struct st_type
{
  int a[];  //柔性数组成员
}type_a;

3、包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小

  • 这里的话我们就要来说到有关【柔性数组】的一个使用了。这里我们要去申请两块空间,一个是整个结构体的大小,一个则是和这个柔性数组的大小,此处我首先为这个数组申请了10个字节的空间,再加上结构体本身的大小,才是我们要为结构体去申请的内存空间
  • 当申请到足够的空间后,我们便可以去初始化并看看这个柔性数组是否可以被正常地使用
int main(void)
{
  // 1.开辟空间
  type_a* s = (type_a*)malloc(sizeof(type_a) + 10 * sizeof(char));
  if (NULL == s)
  {
    perror("fail malloc");
    exit(-1);
  }
  // 2.初始化空间
  s->i = 100;
  for (int i = 0; i < 10; i++)
  {
    s->a[i] = 'Q';
  }
  // 3.打印
  for (int i = 0; i < 10; i++)
  {
    printf("%c ", s->a[i]);
  }
  // 4.释放
  free(s);
  return 0;
}
  • 通过观察运行结果我们可以发现,的确是可以正常去进行使用的,其实和普通数组没什么两样

image.png

当然,就上面这样还体现不出柔性数组的特征,我们要动态地去改变这个数组的大小

  • 那此时我们便可以去做一个扩容的操作,使用到上面所学习的realloc()函数去进行操作即可
type_a* tmp = (type_a*)realloc(s, sizeof(type_a) + 20 * sizeof(char));
if (NULL == tmp)
{
  perror("fail realloc");
  exit(-1);
}
  • 那此时呢我们就可以对新增容后的这一块空间去进行初始化的操作
for (int i = 10; i < 20; i++)
{
  s->a[i] = 'W';
}
for (int i = 0; i < 20; i++)
{
  printf("%c ", s->a[i]);
}
  • 可以观察到,这个柔性数组确实产生了扩容,后面新增了我们再次初始化的数据

image.png

那还可以再扩吗?当然可以了!

tmp = (type_a*)realloc(s, sizeof(type_a) + 30 * sizeof(char));
  • 通过再次去过扩容并初始化发现无论我们去扩容多少,它都可以呈现一个无线地缩放,这也就体现了【柔性数组】的特质

image.png

  • 那么这个数组在结构体中就是呈现下面这一种[柔性]的状态

image.png💬 那有同学问:既然结构体的中的数组大小都增大了,那么这个结构体的大小会发生改变吗?

  • 这点相信你也想看看,不过通过观察我们可以发现其是不会发生变化的,因为在一开始讲柔性数组的时候我们就有说到过,无论数组的大小是多少,均是不算在结构体的大小内的

image.png

3、对比:柔性数组的优势

其实对于上面的这一种柔性数组实现,还可以像下面这样去进行设计

  • 此时我将结构体中的数组定义成了一个字符型指针
typedef struct st_type
{
  int i;
  char* a;  
}type_a;

然后一样利用malloc的形式去申请内存空间,不过这里结构体的空间和数组的空间是分开申请的,只有当结构体的内存空间申请完后,我们才去确立这个数组的大小

type_a* s = (type_a*)malloc(sizeof(type_a));
if (NULL == s)
{
  perror("fail malloc");
  exit(-1);
}
s->i = 100;
char* tmp = (char*)malloc(sizeof(char) * 10);
if (NULL == tmp)
{
  perror("fail malloc");
  exit(-1);
}
s->a = tmp;
  • 接下去的话也是一样切进行初始化、打印、扩容等操作即可
// 2.初始化空间
s->i = 100;
for (int i = 0; i < 10; i++)
{
  s->a[i] = 'Q';
}
// 3.打印
for (int i = 0; i < 10; i++)
{
  printf("%c ", s->a[i]);
}
// 4.增加
tmp = (char*)realloc(s->a, sizeof(char) * 20);
if (NULL == tmp)
{
  perror("fail malloc");
  exit(-1);
}
s->a = tmp;
  • 然后去运行就可以发现,也是同样可以正常使用这数组的,完全用不到像【柔性数组】那样的东西,使用我们平常这样的写法也是可以的

image.png

  • 那此时这个结构体的内存分布就是下面这样的,有一个结构体指针指向了一块内存空间,里面存放了两个结构体成员,分别是ia,其中后者是一个字符型指针,又指向了内存中的一块连续区域,它们都是在堆中的malloc出来的

image.png💬 那有同学说,这完全不需要柔性数组了,没必要😎

  • 那我们就都知道,在开辟内存空间后要及时释放,这样才不会造成【内存泄漏】的问题,但是我们观察一下这个释放的过程,你是否有觉得繁琐吗?
  • 而且我们再进行释放的时候,必须要先释放数组a所指向的那块空间,然后再释放结构体的这块空间,因为如果你先释放结构体所在的这块空间的话,里面的指针a就找不到了
// 释放
free(s->a);
s->a = NULL;
free(s);
s = NULL;

最后我们再来对比分析一下这两种方法的区别

【对比分析】;

  • 从下图来分析两种形式我们可以观察到三个不同点:
  • 对于柔性数组来说都是一次malloc一次free
  • 对于动态数组来说都是两次malloc两次free
  • 柔性数组所在的结构体内存空间都是连续的;动态数组所在的结构体内存空间不一定是连续的,因为两次【malloc】的地址可能不一样;
  • 因此呢我们就可以体会到柔性数组的优势了,接下去让我们具体地来讲讲其优势到底体现在哪里👇

image.png

第一个好处是:方便内存释放

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

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

  • 连续的内存有益于提高访问速度,也有益于减少内存碎片,因为当前在多次malloc之后,内存中就会产生多个内存碎片,所以我们应该尽量减少mallocfree的此处。不过呢,我个人觉得也没多大区别了,反正你跑不了要==用做偏移量的加法来寻址==

七、总结与提炼

最后来总结一下本文所学习的内容

  • 在本文中我们首先讲到了三个动态内存函数,分别是malloccallocrealloc,分别对其展开做了解读、分析和使用的学习,初步了解到了使用动态内存给我们带来的便捷之处
  • 但是在使用它们的时候还是需要有一些注意事项,接下去我们又讲到了常见的六种动态内存错误,帮助大家在使用的时候去有意识地规避一些问题
  • 对几种内存函数有了一定的掌握后,我们就可以通过笔试题来进行加深对知识点的理解,透过这些笔试题我们也了解到在使用【动态内存】的时候还是要去关注许多细节之处,否则就会造成不可挽救的风险
  • 接着我们又提到了C/C++中的内存分布原理,分别有【栈区】、【堆区】、【数据段 / 静态区】、【代码段】,我们所写的代码多多少少都与这些内存分区有着紧密的联系,所以对于这个内存分布一定要有清晰的认识
  • 最后我们又提到了【柔性数组】这个东西,它是在C99之后诞生的,若是在一个结构体中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』成员,它使用起来很是灵活,我们可以通过和realloc去进行配合无限地增加数组的长度。最后我们还拿【柔性数组】和【动态数组】去做了一个对比,对比观察出了其优势所在

以上就是本文要介绍的所有内容 ,感谢您的阅读🌹

相关文章
|
4月前
|
编译器
C生万物 | 从浅入深理解指针【第二部分】(一)
C生万物 | 从浅入深理解指针【第二部分】 前言: 如果没有看过第一部分的话,推荐先看第一部分,然后再来看第二部分~~
C生万物 | 从浅入深理解指针【最后部分】(二)
C生万物 | 从浅入深理解指针【最后部分】(二)
C生万物 | 从浅入深理解指针【第二部分】(二)
C生万物 | 从浅入深理解指针【第二部分】(二)
|
4月前
|
存储 C语言 C++
C生万物 | 从浅入深理解指针【第一部分】(一)
C生万物 | 从浅入深理解指针【第一部分】
|
4月前
|
机器学习/深度学习 安全 程序员
C生万物 | 从浅入深理解指针【第一部分】(二)
C生万物 | 从浅入深理解指针【第一部分】(二)
|
4月前
|
C语言 C++
C生万物 | 从浅入深理解指针【最后部分】(一)
C生万物 | 从浅入深理解指针【最后部分】(一)
|
9月前
|
编译器 Linux Go
C生万物 | 动态内存管理-2
C生万物 | 动态内存管理
41 0
C生万物 | 动态内存管理-2
|
9月前
|
编译器 C语言 C++
C生万物 | 动态内存管理-1
C生万物 | 动态内存管理
34 0
|
9月前
|
编译器 C语言 索引
C生万物 | 常见的六种动态内存错误
C生万物 | 常见的六种动态内存错误
66 0
|
9月前
|
编译器 C++
C生万物 | 指针进阶 · 提升篇-2
C生万物 | 指针进阶 · 提升篇
37 0