目录
1.引言
2.使用动态内存的原因
3.malloc 与 free
4.calloc 与 realloc
5.常见的动态内存错误
6.动态内存题目分析
7.柔性数组
8.C与C++中程序内存区域划分情况
9.结语
1.引言
在前面两篇博客中,我们分别讲解了内存函数与自定义类型,那本篇博客就来讲解一下与这两部分相关联的动态内存管理,希望各位小萌新在学习完动态内存管理后,对内存空间有一个更为深入的认识。
2.使用动态内存的原因
在之前的学习中,我们创建局部变量或局部数组时都是在栈上开辟空间的,但这样开辟空间的方式存在局限性,所开辟的空间大小是固定的,即使是变长数组,在指定大小后也无法再做修改。但有些时候我们并不能准确预估所需开辟的空间大小,需要等到程序运行时才能知道,所以C语言就引入了动态内存开辟,程序员可以根据实际需要自己去申请空间及释放空间,这样对于内存空间的开辟就会更加灵活。


3.malloc 与 free
malloc 函数的作用是向内存的堆区申请一块连续可用的空间,如果开辟成功,则返回这块空间的起始地址,如果开辟失败,比如系统剩余空间不足时,则返回一个 NULL 指针,所以在使用 malloc 函数的返回值前一定要做检查。而且 malloc 函数的返回值类型为 void*,所以 malloc 函数并不知道他开辟空间的类型,那我们在使用时就要根据实际情况强制类型转换为对应类型的指针。malloc 函数的参数是要分配的内存块的字节数,值得注意的是,当参数为 0 时使用 malloc 函数为未定义行为,运行结果取决于编译器。使用 malloc 函数前需要包含头文件 stdlib.h。

接下来就是 malloc 函数的用法,因为 malloc 函数在内存空间开辟失败时返回的是 NULL 指针,所以在使用前应先判断指针是否为空。




然后我们来看一下动态内存空间开辟失败的情况,如下图。INT_MAX 表示 int 数据类型的最大上限,这是一个非常大的数字,当我们以此作为参数尝试去开辟一个非常大的空间时就会开辟失败,此时返回 NULL 指针并打印输出错误信息,从最后错误信息提示也能看出错误原因就是堆区没有足够大的剩余空间。


free 函数的作用是释放之前通过动态内存分配函数(如 malloc,calloc,realloc )申请的内存空间,使用前同样需要包含头文件 stdlib.h。free 函数的参数是要释放的内存块的指针,那关于参数有两种特殊情况,如果参数的指针指向的空间不是动态开辟的,此时使用 free 函数为未定义行为,如果参数的指针是 NULL 指针,此时使用 free 函数什么都不会发生。

当我们在前面的代码中补上 free 函数后就完成了动态内存空间从申请到使用再到释放的完整过程,free 函数的用法及摆放位置如下图所示。一般我们在确保当前这片动态内存空间不再继续使用后才会调用 free 函数释放空间,不然可能造成一些问题,同时我们也要记得及时使用 free 函数释放申请的动态内存空间以免造成内存泄漏。那因 free 函数使用不当所造成的动态内存错误,也会在博客的后半部分详细讲解。



我们从内存窗口可以看到,使用 free 函数后,虽然内存空间被释放,对应的数据信息被覆盖,但指针 p 仍然指向原本的地址,那此时的指针 p 就是野指针,为了避免后续程序使用野指针,我们需要在使用完 free 函数后将该指针置为空指针。

4.calloc 与 realloc
calloc 函数的作用是给 num 个大小为 size 的元素开辟一块内存空间,并把空间中的每个字节初始化为 0。calloc 函数与 malloc 函数的区别只在于 calloc 函数在返回地址之前会将申请的空间中的每个字节内容都初始化为 0,所以 calloc 函数可以变相认为是 malloc 函数与 memset 函数的组合。

接下来就是 calloc 函数的用法,calloc 函数与 malloc 函数极其相似,只是在此基础上对开辟的动态内存空间进行了初始化操作,这点我们可以通过内存窗口观察到。



但是从上面的讲解中,我们发现申请动态内存依旧需要我们给定一个固定大小的值,那如果动态内存仅仅只是这样,显然无法完美实现动态管理的效果,所以 realloc 函数才是动态内存管理的精髓,使用 realloc 函数可以让动态管理更为灵活。
realloc 函数的作用是重新调整之前分配的内存块的大小,它可以在不丢失原有数据的情况下,扩大或缩小动态分配的内存块,如果调整成功,则返回一个指向重新分配的内存块的 void * 类型指针,这个指针可能与原来的指针不同,如果调整失败,则返回一个 NULL 指针,且保证原有的内存块内容不变。realloc 函数的参数有两个,ptr 是要调整的内存空间的起始地址,size 是调整后的新空间大小,单位是字节。值得注意的是,当 ptr 为 NULL 指针时,此时 realloc 函数的功能等同于 malloc 函数。

接下来就是 realloc 函数的用法,为了体现 realloc 函数在参数为空指针时也能正常运行,我们一开始开辟动态内存空间不使用 malloc 函数,而是使用给 realloc 函数传空指针的方式。然后我们使用 realloc 函数对申请的动态内存空间进行大小调整,那与 malloc 函数开辟空间失败一样,realloc 函数在调整空间大小时也有可能出现调整失败的情况,所以我们在使用调整后的指针前应先进行判空处理。



如果我们仅仅是从上图代码的内存窗口与最后的运行结果来看,realloc 函数的扩容就是在原空间后面追加一片新空间并返回旧地址。但我们一开始介绍 realloc 函数时说过,调整成功时返回的指针可能与原指针不同,这是怎么回事?那就是因为 realloc 函数在缩小内存空间时是没有任何问题的,但在扩大内存空间时是要分情况讨论的。当原有空间后面有足够大的空间时,realloc 函数就会在原有空间后面直接追加新空间,同时原有空间的数据不会发生变化,最终返回的地址还是旧的地址;但当原有空间后面没有足够大的空间时,realloc 函数就会在内存的堆区中寻找新的满足要求的空间并返回新的起始地址。 那在这个过程中 realloc 函数会执行以下操作:寻找新的满足要求的空间,将旧空间的数据拷贝到新空间中以保证数据不会丢失,释放旧空间,返回新空间的起始地址。

5.常见的动态内存错误
第一种错误:未经检查对 NULL 指针解引用操作。


我们前面讲过,malloc 函数开辟空间时如果开辟失败则返回 NULL 指针,realloc 函数调整空间大小时如果调整失败也返回 NULL 指针,所以我们在使用指针前一定要进行判空处理,不然就有可能造成上述代码的错误。
第二种错误:在动态内存空间中越界访问。


越界访问的问题我们前面在讲解数组时就说过了,那动态内存空间也是一样的,我们申请了多大的空间就只能使用多大的空间,一定要注意边界情况。不同的编译器对越界访问的处理可能并不相同,有的会报错而有的不会。但无论如何,越界访问是一种危险的操作,我们应该避免越界访问操作的出现。
第三种错误:访问已被释放的动态内存空间。


我们前面介绍 free 函数时说过,使用 free 函数释放空间只是对对应空间中的数据进行覆盖操作,并不影响指针的指向。那如果我们没有及时将指针置空,该指针就成了野指针,当我们后续再次使用该指针访问空间时就有可能出现与我们预期不符的情况。那同样的,我们也应该主动避免野指针的产生与使用。
第四种错误:忘记释放开辟的动态内存空间。


free 函数的作用就是释放申请的动态内存空间,如果我们忘记使用 free 函数释放空间,就会造成内存泄漏。一般情况下不会对程序造成太大影响,因为在程序结束时操作系统自己也会回收这部分的内存空间,但在此前程序运行时未被释放的动态内存空间将无法被二次使用。那对于长时间持续运行的程序,像服务器等,由于内存泄漏,程序的可用内存随着程序运行就会越来越少,最终导致程序崩溃。所以我们每次申请并使用完动态内存空间后要记得手动调用 free 函数释放空间。
第五种错误:多次释放同一块动态内存空间。



我们前面介绍了 realloc 函数,也知道 realloc 函数的扩容情况取决于原空间后面的闲置空间大小。但无论是何种情况,只要 realloc 函数扩容成功,我们在最后使用 free 函数释放空间时都只需要调用一次,否则就会像上图代码那样因多次释放同一片动态内存空间而引发错误。
第六种错误:只释放开辟的动态内存空间的部分或释放的不是开辟的动态内存空间。



我们在使用 free 函数释放空间前应先保证此时指针指向的仍是最开始的位置,一旦指针发生偏移,使用 free 函数就只会释放部分动态内存空间,甚至释放的根本就不是我们所开辟的动态内存空间,那此时同样会引发错误。
第七种错误:对栈区空间使用 free 函数。



free 函数的使用对象永远是动态内存空间,也就是开辟在堆区上的空间,如果我们错误使用 free 函数对栈区空间进行空间释放就会引发错误。
6.动态内存题目分析
第一个代码:


这里考察的其实是行参与实参的关系,根据前面的学习我们知道,形参是实参的拷贝,两者拥有各自独立的空间,所以改变形参不会影响实参。当我们利用形参 p 接收动态内存开辟成功后返回的起始地址,此时由于函数没有返回值,实参 pc 仍然是空指针,就会直接进入 if 语句打印输出错误信息并提前结束程序。
那修改方式也很简单,我们只要让自定义函数最后返回 p 指针,并让 pc 指针接收返回值,就能使程序运行输出我们想要的结果。


除此之外我们还可以改用传址调用,利用二级指针 p 传递 pc 指针的地址,再解引用 p,也就是让 pc 指针本身接收动态内存开辟成功后返回的起始地址,这样也可以使程序运行输出我们想要的结果。


第二个代码:


这里考察的是野指针的使用,arr 数组是开辟在栈上的局部数组,其生命周期随函数的结束而结束,那此时的 pc 指针就是野指针。当我们调用 pc 指针访问原空间时,因为先一步使用 printf 函数打印输出 haha,此时原空间中的数据已被修改,也就无法再打印输出我们想要的 ABCDE。
那修改方式有两种,其一就是使用 static 关键字修饰 arr 数组,可将 arr 这个局部数组放置到静态区,此时 arr 数组的作用域仍然是整个自定义函数,但生命周期与全局数组相同,会一直持续到程序结束。那在我们使用 pc 指针访问原空间后,就能正常打印输出我们想要的 ABCDE。


其二就是使用我们刚学的动态内存,利用指针 p 接收动态内存开辟成功后返回的起始地址,再向动态内存空间中写入我们要打印输出的 ABCDE,然后利用申请的动态内存空间不主动释放就会一直存在,直到程序结束时由操作系统自己释放的特性,我们也能使 pc 指针正常访问原空间并打印输出我们想要的 ABCDE。


第三个代码:


这里最主要的问题就是先调用 free 函数释放了动态内存空间,然后在已释放的动态内存空间中使用 strcat 函数拼接字符串 World。
如果修改方式仅仅是将 free 函数放到自定义函数的末尾,那原本是在使用指针前先做判空处理再使用指针执行相应操作,现在是先使用指针执行相应操作再对指针做判空处理,就完全颠倒了使用指针的判断逻辑。所以修改方式不是简单的将 free 函数放到自定义函数的末尾,而是在释放完空间后及时将指针置空,避免后续程序使用野指针。

7.柔性数组
柔性数组指的是在C99标准下,结构体的最后一个成员变量允许是未知大小的数组,换而言之,柔性数组相当于是对结构体使用动态内存管理。

既然柔性数组是结构体的最后一个成员变量,那使用柔性数组就要保证结构体中柔性数组成员前面至少有一个其他的成员。当我们使用 sizeof 操作符计算结构体大小时是不包括柔性数组的大小的。包含柔性数组成员的结构体需使用 malloc 函数进行内存的动态分配,且分配的内存大小应大于整个结构体的大小,以适应柔性数组的预期大小。


接下来就是使用 malloc 函数对包含柔性数组成员的结构体进行动态内存分配,以及如何使用包含柔性数组成员的结构体。



那同样的,我们也可以不使用柔性数组,改用指针的方式实现上图代码的效果,具体代码见下图。

对于上图代码的空间分配逻辑,我们是先使用 malloc 函数为整个结构体分配空间,并让指针 p 指向整个结构体。接着使用 malloc 函数开辟动态内存空间,并让结构体中的 arr 指针成员变量指向这片动态内存空间,之后我们对动态内存空间做大小调整时,只需要将调整后的地址再重新赋值给指针 arr 就能实现柔性数组的效果。

那通过上面两个代码的比较,我们发现柔性数组的优势更大,他便于内存释放。如果程序员把代码写在一个提供给用户使用的函数中,并在里面做了二次内存分配,那用户使用完结构体后可能会及时使用 free 函数释放结构体,但用户可能并不知道该结构体中的成员也需要使用 free 函数释放空间,那这时就会造成内存泄漏,因为程序员不能指望用户自己去发现这点。所以如果我们能把结构体的内存及其成员所需的内存一次性分配好并返回给用户一个结构体指针,那用户只需要使用一次 free 函数就能将所有的动态内存空间都释放掉。除此之外,使用柔性数组还有利于提高访问速度,减少内存碎片。
8.C与C++中程序内存区域划分情况

那在以上这些内存区域中,针对于我们常用的栈区、堆区、数据段以及代码段做了一个简单的总结,以便于各位小萌新对C与C++程序的内存区域有一个清晰的认识。
栈区:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元会被自动释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。关于栈区的知识,各位小萌新可以去函数栈帧篇再次回顾。
堆区:一般由程序员手动释放,若程序员不释放,程序结束时可能由操作系统自动回收。分配方式类似于C++中的链表,我们之后在C++专栏的博客中会详细讲解链表的相关知识。
数据段:数据段也就是我们所说的静态区,用于存放全局变量与静态数据,静态数据包含使用 static 关键字修饰后的局部变量,在程序结束后由系统统一释放。
代码段:存放函数体(类成员函数和全局函数)的二进制代码以及我们之前使用过的字符常量等常量。类同样是C++中的知识,我们之后也是在C++专栏的博客中详细介绍。
9.结语
本篇博客到这里就结束了,希望能够帮到各位刚接触C语言的小萌新们,下篇博客将继续讲解C语言的相关知识。
