🏖️作者:@malloc不出对象
⛺专栏:《初识C语言》
👦个人简介:一名双非本科院校大二在读的科班编程菜鸟,努力编程只为赶上各位大佬的步伐🙈🙈
目录
- 前言
- 一、什么是动态内存分配
- 二、为什么要有动态内存分配
- 三、动态内存函数的介绍
- 3.1 malloc
- 3.1.1 对malloc函数的进一步认知
- 3.2 free
- 3.3 内存泄露
- 3.4 calloc
- 3.5 realloc函数
- 四、柔性数组
- 4.1 柔性数组的特点
- 4.2 柔性数组的优势
前言
本篇文章我们将来讲解的是动态内存分配问题,它能根据我们的自身的需求去动态调整空间大小以适应各种应用场景,所以它也是非常重要的一部分,关于堆空间的内存分配我也会给大家进行初步介绍让大家对堆栈空间有一个初步的认知感。
一、什么是动态内存分配
所谓动态内存分配(Dynamic Memory Allocation)就是指在程序执行的过程中动态地分配或者回收存储空间的分配内存的方法。动态内存分配不象数组等静态内存分配方法那样需要预先分配存储空间,而是由系统根据程序的需要即时分配,且分配的大小就是程序要求的大小。说的再通俗一点,动态内存分配就在堆上分配的内存,可以根据需求来合理分配大小。
二、为什么要有动态内存分配
先来看一下这个例子,我们在栈上开辟一个1MB的大小的空间,内存存储单元是以字节为单位嘛,1024byte = 1 KB,1024KB = 1M,我们看看能不能开辟成功,接着我们进入调试观察一下。
我们发现栈溢出了,栈上开辟不了这么大的空间。
这是因为栈空间的大小一般是在1~2M之间,具体能开辟的栈空间的大小还与操作环境有关,这里就不做过多的说明了,有兴趣的读者可以自行检测。那么此时就需要动态开辟内存了,C语言中用malloc申请一段动态空间,是在堆上建立的,堆空间可以开辟较大的空间,一般来说可以达到4G,这也是相当之大了,也能够满足我们在开发一些大型项目时的需求。
在技术方面,普通的空间申请,都是在全局或者栈区,全局一般不太建议大量使用,而栈空间有限,那么如果一个应用需要大量的内存空间的时候,就需要通过申请堆空间来支持基本业务。
我们再来想一个问题:我们都知道栈内存的效率在内存中是最高的?那么为什么要限制栈空间的大小呢?
栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,每次压栈的时候只是单纯的移动栈指针而已 ,这就决定了栈的效率比较高,但栈的优点和缺点是一体的,都源于它的概念“先进后出”。简单地说,你先压栈一万字节,再压栈五个字节,在这五个字节被弹出栈之前,那一万个字节必须一直放在栈内——哪怕这五个字节你要使用一年,而那一万个字节你只使用一毫秒,这样就导致内存的使用效率大大降低,因为绝大多数时间内,只有栈顶附近的那点数据被使用,而时间效率也会大大降低,因为要管理全部内存,内存之间的数据操作也要跨越太长地址,等等等…
而堆内存的效率略低,主要是因为堆是随机访问,这里的随机,意为“每一块内存都和另一块内存没有先后关系”,可以理解为堆空间是由随机大小的若干个内存块组成的。申请堆内存的效率可能不比栈内存低多少,但释放堆内存的时候,释放的内存可能需要和旁边的未使用内存重新连成一个整体,以供下一次分配,因此释放堆内存的效率可能会较低,这是为了下一次申请时更快而必须付出的代价。
在应用方面,程序员很难一次预估好自己总共需要花费多大的空间。想想之前我们定义的所有数组,因为其语法约束,我们必须得明确"指出"空间大小.但是如果用动态内存申请(malloc/calloc),因为malloc是函数,而函数就可以传参,也就意味着,我们可以通过具体的情况,对需要的内存大小进行动态计算,这样将空间浪费的成本降低到最少,在传参申请时提供了很大的灵活性。
在windows程序中,每个线程都有自己的栈,我们定义的局部变量,参数都是编译系统在栈中分配,程序员无法干涉,只有堆,程序员可以自由的动态分配与释放。
诸多原因说明了为什么我们要有动态内存分配,存在即合理,没有一种东西设计出来是没有缺陷和优点的,我们要用辩证的思想去看待任何事物。接下来我们就正式进入本篇文章的重点:谈谈关于几个动态内存分配的函数。
三、动态内存函数的介绍
3.1 malloc
我们先来看看malloc函数的原型:
附上中文翻译:
通过上图我们来总结malloc函数:这个函数向内存申请一块连续可用的空间,并返回指向这块空间的起始地址(指针)。
基本用法及注意要点:
1.如果开辟成功,则返回一个指向开辟好空间的起始地址(指针)。
2.如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
3.返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
4.如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。
接下来我们一起看看malloc函数的基本用法:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
int main()
{
//向内存申请10个整型的空间
int* p = (int*)malloc(10 * sizeof(int));//malloc的返回类型为void*,我们想存放整形的地址所以将它强制类型转化为int*
//空间开辟失败,返回的是NULL空指针
if (p == NULL)
{
printf("%s\n", strerror(errno));//strerror是打印出错误信息的一个库函数
exit(-1); //程序异常退出
}
//成功开辟空间
else
{
for (int i = 0; i < 10; i++)
{
printf("%d ", *(p + i) = i);
}
putchar('\n');
}
return 0;
}
我们来看看打印结果:
接下来看看开辟失败时出现的情况:
当malloc申请空间失败时会返回NULL,之后进入if条件判断就打印出了错误的信息。这告诉我们在申请空间后一定要进行判空,否则指针变量接收的是NULL,那么接下来对它进行一系列操作都是有问题的,当然在平时我们一般是不会出现开辟失败的情况的,因为我们可申请的空间足够大,但是进行判空是一个好的习惯。
3.1.1 对malloc函数的进一步认知
如果你对malloc的了解只是到这种程度显然是不够的,下面来看几个问题:
>Q:malloc是如何实现申请堆空间的?
Q:malloc是向谁申请的堆空间?
Q:malloc申请的内存进程结束之后还会不会存在?
Q:malloc申请的空间是不是连续的?
Q:malloc实际申请了多大的空间?
大家看到这些问题如果能立马反应出来证明大家对于堆空间的内存分配也是有了很强的认知👍,下面我们就一起来看看这些问题。
Q:malloc是如何实现申请堆空间的?
当前程序向操作系统申请一块适当大小的的堆空间,然后由程序员自己管理这块空间,具体来讲管理堆空间分配的是往往是程序的运行库。运行库相当于是向操作系统"批发"了一块较大的堆空间,然后零售给程序用,当全部"售完"或程序有大量内存需求时,再根据实际需求向操作系统进货。当然运行库在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。这就涉及到后续的堆的分配算法了,后续我们再来聊这个话题。
Q:malloc是向谁申请的堆空间?
经过上述malloc是如何申请堆空间的讲解,相信大家都明白了malloc实际是向操作系统申请空间。
Q:malloc申请的内存进程结束之后还会不会存在?
这是一个很常见的问题,答案显然是不会存在。因为当进程结束以后,所以有进程相关的资源,包括进程的地址空间、物理内存、打开的文件、网络链接等都被操作系统关闭或者回收,所以无论malloc申请了多少内存,进程结束之后都不存在了。
Q:malloc申请的空间是不是连续的?
在回答这个问题之前,我们首先要清楚"空间"这个词所指的意思。如果"空间"是指虚拟空间的话,那么答案肯定是连续的,即每一次malloc分配后返回的空间都可以看做是一块连续的地址,虚拟地址又可以叫逻辑地址,就是你源程序里使用的地址,或者源代码经过编译以后编译器将一些标号变量转换成的地址,或者相对于当前段的偏移地址;如果空间指的是物理空间的话,则答案是不一定连续,因为虚拟地址空间由于分页机制被映射到了物理空间上,所以一块连续的虚拟地址空间很有可能是若干个不连续的物理页拼凑而成的。
Q:malloc实际申请了多大的空间?
这个问题不是很简单嘛?你向操作系统申请了多少空间就是多大空间嘛!答案真的是这样吗?再回答这个问题之前我们先来简单的了解一下一种堆分配算法。
首先堆是向高地址扩展的数据结构,是不连续的内存区域,所以程序在通过malloc申请内存空间时它的大小是不确定的,从数个字节到数个G都是可能的。于是我们必须将堆空间管理起来,将它分块的按照用户需求出售给我们的程序,并且还可以按照一定的方式收回内存。所以总结起来堆分配算法就是:如何管理一大块连续的内存空间,能够按照需求分配、释放其中的空间。
好了,既然我们知道堆是不连续的内存区域,那么该如何将堆空间中的空闲空间利用起来呢?这时我们会不会想起一种简单的数据结构——链表呢,下面就来介绍最简单的一种堆分配算法——空闲链表。
它的方法实际上就是把堆空间中各个空闲的内存块按照链表的形式链接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空间链表中。
空闲链表它的基本结构是这样的,在堆里每一个空闲空间的开头(或结尾)有一个头(header),头结构里面记录了上一个(prev)和下一个(next)空闲块的地址,其实这就像我们的双向链表哈,结构体里面有两个指针,一个指向上一个结构体的地址,一个指向下一个结构体的地址。关于双向链表较单链表其实有很多的好处,这里先不跟大家做过多的赘述了,以后会出相关的博客的嘿嘿orz~
那么在这样的结构中是如何分配空间的呢?
首先在空闲链表里面查找足够容纳请求大小的一个空闲块,然后将这个块分为两部分,一部分为程序请求的空间,另一部分为剩下来的空闲空间。接着将链表里对应原来空闲代码块的结构更新为新的剩下的空闲代码块,如果剩下的空闲块为0,则直接将这个结构从链表里面删除。下面来看简单的一张图:
这样的空闲链表实现是比较简单的,但在释放空间时,给定一个已分配块的指针,但我们无法确定这个块的大小,所以一个简单的解决办法是当用户假设请求n个字节的空间时,我们会额外多分配一些空间来保存这个分配块的大小等信息,这样当我们在释放该内存块时就知道该内存块的大小,然后把它插到空闲链表里就可以了。
关于空闲链表实现堆的分配也就讲到这里了,虽然它实现起来非常的简单,但其实这个分配策略仍然存在诸多问题。例如:一旦链表被破坏或者记录内存块信息的空间被破坏,整个堆就无法正常工作,而这些数据恰恰很容易被越界读写所接触到。既然有弊端,那么就一定会有其他的堆分配算法来解决,由于本篇文章的重点不是解决堆分配管理的问题,这里感兴趣的读者可以查阅相关资料去多了解了解。其实在很多现实应用中,堆分配算法往往是采取多种算法复合而成的,取其精华,去其糟粕哈哈哈~
多分配的部分:记录这次申请空间的更详细的信息,例如:申请了多大的空间、什么时候申请的等一些基础信息…我们一般把多出来的这部分空间也叫做cookie,它是用来保存堆空间的相关信息和属性等的。
所以最终我们得出一个结论:malloc实际向操作系统申请的空间比需要的空间还要多分配一点,这部分空间用来存放内存块的各项信息…
那么有部分读者可能就会问了,既然你说实际上malloc申请的空间更多,那么我在调试窗口内存中只能看到我开辟需求个空间大小的内容变化呢?
这个问题很简单,如果设计者将存储内存块信息的空间暴露出来,那岂不是可以随意的找到这块空间去改动它嘛,这样一来整个堆空间就失效了,所以为了出现不必要的麻烦就干脆不显示这部分内容了,,这是我个人的理解哈~
Q:那么你能实际一点向我们证明malloc实际申请的空间大小要多于我们需求的空间大小吗?
其实还是有办法验证这个问题的,在讲完free函数之后我会通过调试给大家看看现象。
关于malloc进一步的认识就讲到这儿了,也许部分读者可能没有完全弄明白,,那就简单的了解一下malloc的用法就好了,,但是我个人建议哈 ,能看懂尽量把它完全消化了,,这对堆内存的布局的认知会更加深刻,,以及对后续我们即将讲到的realloc函数发生异地扩容等现象理解起来有很大的帮助。
3.2 free
同样的我们先来看看free函数的原型:
附上中文翻译:
通过上图我们先来简单总结一下free函数:指针指向一个要释放内存的内存块的起始地址,该内存块之前是通过调用malloc、calloc或realloc进行分配内存的起始地址。如果传递的参数是一个空指针,则不会执行任何动作。
关于使用free函数的基本注意事项:
1.free函数一定是根据malloc等函数申请到的内存块的起始地址来释放内存块的,如果free函数接收到的参数不是指向动态开辟空间的起始地址的话,那么就会出现错误,它必须是要根据起始地址整体进行释放的。
2.free函数不会对指向动态申请的内存块内容做任何的修改,千万不要认为free释放掉了这块内存空间就将指针置为NULL了。释放的本质是断开指针变量和堆空间之间的关系,虽然指针变量保存的还是堆空间的起始地址,但此时它已经没有权限访问对应的内容了。3.不能对同一块空间进行多次释放,如果你释放完这块空间之后,系统马上将它分给了其他程序,此时你再对这块空间进行释放肯定是不行的,你把本不属于你管理的空间释放了,你觉得编译器会让你释放嘛?
free函数的基本使用方法比较简单,下面我给大家搭配讲述一下free函数的第二点注意事项:
#include<stdio.h>
#include<stdlib.h>
int main()
{
char* p = (char*)malloc(10 * sizeof(char));
printf("before: %p\n", p);
free(p);
printf("after:%p\n", p);
return 0;
}
在释放掉堆空间后堆空间的地址会变为什么?下面我们来看看结果:
我们发现释放堆空间之后,堆空间的地址保持不变,并不是像很多人理解释放之后就置为NULL了。那么问题又来了,为什么要这样来设计呢?释放堆空间之后还能使用继续访问堆空间嘛?
大家觉得能打印出我们想要的结果吗?
#include<stdio.h>
#include<stdlib.h>
int main()
{
char* p = (char*)malloc(10 * sizeof(char));
printf("before: %p\n", p);
free(p);
printf("after:%p\n", p);
*p = 10;
printf("%d\n", *p);
return 0;
}
从结果上来看它确实打印出了我们想要的结果,可它就一定是正确的吗?这个结果很容易让人认为这段代码没有错误,实则不然。
经过我们的调试,最后发现程序已经崩溃了,说明这段代码是存在问题的。
所以我们的结论:在释放堆空间之后我们就不能继续访问堆空间了,实际上释放完堆空间之后,此时堆空间的起始地址就成了一个野指针了,野指针是十分危险的,我们之前把它比作野狗,野狗是没有主人的,所以它随时可能对路人造成危险到处咬人,那该怎么去解决这个问题呢?最好的办法是限制它的行动嘛,就用一根铁链栓住这条野狗,这样就大概率不会出现咬人的风险了(如果你没栓稳就是另外一个故事了🙈)。在C语言中free释放掉堆空间之后常将这块地址置为NULL,用来维护这块地址,别人就不能对其进行访问了,这是一个非常好的习惯。
Q:再回答上一个问题:如果我们不小心继续使用了这块已经释放了的地址,那既然可能出现风险,为什么不规定在free释放堆空间之后将这块地址置为NULL呢?
首先编译器觉得其实没有合适的理由,你继续使用这块已经释放了的地址影响的是你自己的使用,于其操作系统没有任何的危害,如果自己觉得有危害那么自己置为NULL就行了。
就好比小库跟阿耶莎分手之后,你觉得小库会一下忘掉阿耶莎吗?在现实生活当中这也是极其不合理的。而且如果对方根本不喜欢你,受伤的就只有可怜的你,对对方没有任何的伤害,而且想忘掉她只能由你自己主动忘记她🙈🙈
在讲解malloc函数时,我最后留了一个问题:怎么证明malloc函数实际要申请的空间是多于我们需求的空间呢?下面我就要通过free函数证明一下这个问题:
下面我将通过一步步调试观察内存中的变化来证明这个问题:
首先我们的malloc在编译器中只能看见我们按需求开辟字节内容的变化:
接下来我也是对它进行赋值操作,同时我们也看的到对应偏移地址内容的变化,此时已经成功赋值了
最后我们free释放掉这块空间,观察其内容的变化:
我们发现不止20个字节发生了变化,也就是说释放的字节实际上要比20个字节多,那么既然释放的字节比20个字节多的多,那么换个角度实际我们申请的空间应该也不止20个字节,由此我们就再次证明了malloc实际向操作系统申请的空间比需要的空间还要多分配一点,这多分配的空间用来存放内存块的各项信息…
3.3 内存泄露
什么是内存泄露?
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄露的危害?
我们经常说申请了一段动态内存空间,那么就必须手动进行释放,假设不进行手动free的话,我们每次写一段程序就占用一个空间,那么总有一天是会用完的,那么我们写程序的意义又在哪里?讲句不好听的话就是占着茅坑不拉屎。
什么样的程序最怕内存泄露问题呢?
永远不会主动退出的程序(操作系统、杀毒软件、服务器程序):常驻(内存)进程(程序),出现内存泄漏会导致响应越来越慢,最终卡死。
内存泄漏几乎是很难避免的,不管是老手还是新手,都存在这个问题。甚至包括windows, Linux 这类软件,都或多或少有内存泄漏。也许对于一般的应用软件来说,这个问题似乎不是那么突出,重启一下也不会造成太大损失。但是如果你开发的是嵌入式系统软件呢?比如汽车制动系统,心脏起搏器等对安全要求非常高的系统。你总不能让心脏起搏器重启吧,人家阎王老爷是非常好客的。
下面我们来简单的看一个内存泄露的情形:
#include<stdio.h>
int main()
{
while (1)
{
malloc(1);
}
return 0;
}
接下来我们来查找任务管理器中内存的使用情况:
初始的使用情况:
接下来我们再VS中运行一下这个程序:
我们可以看到最终它的内存使用量达到了9.2就趋于平稳了,这是系统的保护机制,难不成还真让你把内存使用完了,那这电脑不就没法用了嘛哈哈,自己可以动手试试,你会发现在执行程序时确实是从7.4一直增长到9.2,最后趋于稳定的。当然每个人的电脑内存情况会稍有不同,但是观察的现象确实很明显的。所以我们在开辟空间结束时,一定要记得释放这段空间还给操作系统;俗话说:有借有还,再借不难。
如何来避免内存泄露呢?
内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。
1.工程前期良好的设计规范,养成良好的编码规范,申请的内存空间一定要记着匹配的去释放,但是如果碰上异常时,就算注意释放了,还是可能会出问题,这就需要下一条智能指针来管理才有保证。
2.采用RAII思想或者智能指针来管理资源。
3.有些公司内部规范使用内部实现的私有内存管理库,这套库自带内存泄漏检测的功能选项,出问题了可以使用内存泄漏工具检测,不过很多工具都不够靠谱或者收费昂贵。
Q:如何辩证的看待动态内存给程序员带来的灵活性?
动态内存给程序员带来最大的便利是申请空间和释放空间是确定的,用多少可以申请多少,本质上可以通过程序员的合理编码,让代码的成本降到最低,不会有任何空间的浪费;而一个坏处就是可能申请的空间由于某些原因忘记释放了,导致内存泄露等问题的出现。所以我们要根据场景的不同的来确定我们申请空间的方式。
3.4 calloc
C语言还提供了一个函数叫calloc,calloc函数也用来动态分配内存,它与malloc的最大的区别就是会在返回地址之前把申请的空间的每个字节初始化为全0,除了使用形式不一样,在功能上基本就是一样的了。下面我们还是先来看看它的原型:
简单的总结一下calloc函数:它的功能是为num个大小为size的元素开辟一块空间,并且把空间的每个字节初始化为0。
我们还是通过调试带大家看看它与malloc之间的区别:
这是malloc向内存申请一块空间后字节的初始值,它是随机值:
再来看下calloc向内存申请一块空间每个字节的初始值:
我们确实发现calloc向内存申请一块空间的同时将每个字节都初始化为0,所以如果我们对申请的内存空间的内容要求初始化,那么可以很方便的使用calloc函数来完成任务。
3.5 realloc函数
realloc函数的出现让动态内存管理更加灵活,有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的时候内存,我们一定会对内存的大小做灵活的调整,realloc 函数就可以做到对动态开辟内存大小的调整。
先来看看realloc的原型:
通过上图我们简单的总结一下realloc函数:简单来说,它的功能就是可以进行扩容和缩容来动态调整空间大小的。再具体一点它是先判断当前的指针是否有足够的连续空间,如果有,则扩大ptr指向的地址的空间,并且将ptr返回;如果空间不够,先按照newsize指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来ptr所指内存区域(注意:原来指针是自动释放,不需要使用free),同时返回新分配的内存区域的首地址,即重新分配存储器块的地址。
下面我们来简单看看realloc函数的基本用法:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int* p = (int*)malloc(20);//向内存申请一块20个字节大小的空间
if (p == NULL)
{
printf("%s\n", strerror(errno));//打印错误信息
}
else
{
for (int i = 0; i < 5; i++)//这里刚好满足占20个字节大小的空间,5个int型的元素
{
printf("%d ", *(p + i) = i);
}
p = realloc(p, 40);//realloc是用来调整动态开辟的内存的,我们将p调整为40个字节大小的空间,就能多使用20个字节的空间了
for (int i = 5; i < 10; i++)
{
printf("%d ", *(p + i) = i);
}
}
return 0;
}
我们可以看看下面的结果,成功的进行了扩容打印出5~9的内容:
下面再来说说realloc函数在使用时的几个常见重要的注意事项:
1.如果ptr指向的空间之后有足够的内存空间可以追加,则直接追加后返回ptr.
2.如果ptr指向的空间之后没有足够的内存空间可以追加,则realloc函数会重新找一个新的内存区域,开辟一块满足需求的空间,并且把原来内存中的数据拷贝回来,释放旧的内存空间。
3.如果没有足够可用的内存用来完成重新分配(扩大原来的内存块或者分配新的内存块),则返回NULL,而原来的内存块保持不变,不会释放也不会移动,所以我们使用的时候应该保留原指针,避免分配失败产生内存泄漏。
我们通过调试来看看第一种情况:
重新调整后p的地址,我们发现当p指向的空间后面有足够的空间时能进行追加扩容时,返回的是原来的地址。
接下来我们来看下第二种情况:
重新调整后,我们发现如果p指向的空间之后没有足够的内存空间可以追加,则realloc函数会重新找一个新的内存区域,开辟一块满足需求的空间,并且把原来内存中的数据拷贝回来,释放旧的内存空间(这里是自动释放的,不需要我们手动的进行free释放)。
大家知道堆空间是不连续的内存区域,可以理解为堆空间中有大大小小的内存块分布,它们是不连续的,随机分布的,下面通过一张图展示一下以上出现的两种情况:
还有第三种情况:当总空间不足时,realloc返回的是NULL,所以在追加失败时它是找不到原来p指向的那块空间的,所以我们应该创建一个新的指针变量去维护p,当追加失败时我们也能返回p指向的那块空间,而不至于导致内存泄露问题。下图为第三种情况的正确写法:
其实无论是关于扩容还是缩容,采用一个指针变量去维护原来指向的动态申请的内存块的空间是最保险的一种方法,我个人也是最推荐这种写法。
关于realloc下面还有两种特殊情况:
1.如果ptr为NULL,则realloc()和malloc()类似。会分配一个newsize的内存块,返回一个指向该内存块的指针。
2.如果newsize大小为0,那么释放ptr指向的内存,并返回NULL。
因为它不是realloc的常考点,我稍微带过一下验证一下:
第一种特殊情况:
第二种特殊情况:
关于realloc函数的使用再总结一次:
1.如果ptr指向的空间之后有足够的内存空间可以追加,则直接追加后返回ptr.
2.如果ptr指向的空间之后没有足够的内存空间可以追加,则realloc函数会重新找一个新的内存区域,开辟一块满足需求的空间,并且把原来内存中的数据拷贝回来,释放旧的内存空间。
3.如果没有足够可用的内存用来完成重新分配(扩大原来的内存块或者分配新的内存块),则返回NULL,而原来的内存块保持不变,不会释放也不会移动,所以我们使用的时候应该保留原指针,避免分配失败产生内存泄漏。
4.如果newsize为0,效果等同于free()。这里需要注意的是只对指针本身进行释放,例如对二维指针**ptr调用realloc时只会释放一维,所以在使用时要谨防内存泄露。
5.传递给realloc的指针必须是先前通过malloc(), calloc(), 或realloc()分配的
6.传递给realloc的指针可以为空,等同于malloc。*
四、柔性数组
也许你从来没有听说过柔性数组(flexible array)这个概念,但是它确实是存在的。在C99 中,结构中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』成员。
下面我们就一起来了解一下柔性数组:
我们发现这段代码并没有报错,证明柔性数组确实存在。
4.1 柔性数组的特点
1.结构体中的柔性数组成员前面必须至少一个其他成员,且必须放在最后一个。
2.sizeof 返回的这种结构大小不包括柔性数组的内存。
3.包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小.
下面我们就来一一探究它的各种性质:
性质一:结构体中的柔性数组成员前面必须至少一个其他成员,且必须放在最后一个
从图中我们发现当柔性数组不放在最后面时就出现了错误,由此性质一得证。
性质二:sizeof 返回的这种结构大小不包括柔性数组的内存
计算出来的结果为4,根据柔性数组的性质二,所以计算的只有整型变量n的字节大小。
性质三:包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小
#include<stdio.h>
struct S
{
int n;
int arr[0];//未知大小的 - 柔性数组成员 - 数组的大小是可以调整的
};
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S) + 5 * sizeof(int));//这里另外开辟了5个int型大小的空间,总字节大小为24
ps->n = 1;
for (int i = 0; i < 5; i++)
{
printf("%d ", ps->arr[i] = i);//打印0~4
}
return 0;
}
来看看结果:
我们发现柔性数组确实也能进行动态内存的开辟,下面通过调试让大家更直观的感受一下:
初始时malloc开辟24个字节大小的空间:
Next,给n赋值时:
最后,打印0~4:
如果我们觉得开辟的空间不够,接着我们还可以用realloc进行扩容重新分配一块空间:
我们发现柔性数组其实也非常的灵活能够进行动态的开辟内存,后面的数组大小是可变大可变小的,柔性数组也就由此而来。
4.2 柔性数组的优势
我们先来对比一下用指针来动态开辟空间:
#include<stdio.h>
#include<stdlib.h>
struct S
{
int n;
int* arr;
};
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S));//开辟总的的字节大小,其中包括n和*arr
ps->arr = malloc(5 * sizeof(int));//arr指向一块开辟5个int型字节大小的空间
for (int i = 0; i < 5; i++)
{
printf("%d ", ps->arr[i] = i);//打印0~4
}
struct S* ptr = realloc(ps->arr, 40);
if (ptr != NULL)
{
ps->arr = ptr;
}
for (int i = 5; i < 10; i++)
{
printf("%d ", ps->arr[i] = i);//打印5~9
}
return 0;
}
来看看结果:
我们可以这样来理解这段代码动态开辟空间的过程:
既然进行了malloc操作,那么我们就要手动释放这块空间。我们看向这段代码它进行了两次malloc操作,所以就需要进行两次释放,那我们应该先释放哪块空间呢?假设我们先释放ps指向的那块空间,那么*arr指向的那块动态开辟的这块空间还找的着吗?那我们又谈何释放*arr指向的那块空间呢,所以我们应先释放*arr指向的这块动态开辟的空间,再将ps指向的那块空间释放。我们可以把这个过程理解为从里向外释放。
下面我们来看看这段代码的释放:
#include<stdio.h>
#include<stdlib.h>
struct S
{
int n;
int* arr;
};
int main()
{
struct S* ps = (struct S*)malloc(sizeof(struct S));
ps->arr = malloc(5 * sizeof(int));
for (int i = 0; i < 5; i++)
{
printf("%d ", ps->arr[i] = i);
}
struct S* ptr = realloc(ps->arr, 40);
if (ptr != NULL)
{
ps->arr = ptr;
}
for (int i = 5; i < 10; i++)
{
printf("%d ", ps->arr[i] = i);
}
//进行两次释放
free(ps->arr);
ps->arr = NULL;
free(ps);
ps = NULL;
return 0;
}
接下来我们来看先这段代码和柔性数组用malloc进行内存的动态分配有哪些不同?既然可以利用指针malloc动态分配空间那为什么要引入柔性数组?
我们先来看看两者的区别:
从这幅图中我们可以看到柔性数组malloc动态分配空间只进行了一次,而指针成员malloc动态分配空间进行了两次,我们知道malloc和free是成对的,所以malloc多少次就要free多少次,free的次数越多它的风险性越高,况且我们还需进行判断哪一块空间先进行free哪一块后free,这无形之中添加了很大的麻烦,所以此时我们引进了柔性数组。我们知道sizeof不会将柔性数组的大小计算在内的,所以在malloc动态分配空间时只需进行一次,而且n和arr[0]的内存是连续存放的。
最后我们来总结一下柔性数组的优点:
第一个好处是:方便内存释放如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。
第二个好处是:这样有利于访问速度连续的内存有益于提高访问速度,也有益于减少内存碎片。(其实,我个人觉得也没多高了,反正你跑不了要用做偏移量的加法来寻址)
本文关于动态内存分配的问题就讲到这里了,如有任何疑问或错处欢迎评论区互相交流哦🙈🙈另外关于指针部分其实还有一个重要的知识点——在各种情况下指针容易出现误区的地方没跟大家讲,下次我会将指针部分容易出现错误的知识点结合题目全部给大家讲清楚,那么关于指针部分就正式结束了orz~