【C 语言内功修炼】动态内存管理的奥秘
1. 为什么存在动态内存分配
我们目前已经掌握的 内存开辟方式 有:
但是上述的开辟空间的方式有两个特点:
(1)空间开辟大小是固定的。
(2)数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配。
这时候就会出现一些问题:有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。
这时候就只能试试动态存开辟了。
那么动态内存分配所涉及到的 内存 在哪儿去开辟呢?
回顾一下之前学过的知识,内存由上到下分为:栈区、堆区、静态区;
栈区: 存放局部变量、形式参数等临时变量;
堆区:用于动态内存分配,在堆区上申请的空间可以变大变小
静态区:存放静态变量、全局变量等;
2. 动态内存函数的介绍
动态内存函数有:malloc、calloc、realloc、free,下面将依次讲解;
🍑 malloc
C 语言提供了一个动态内存开辟的函数:
malloc
:这个函数向内存申请一块 连续 可用的空间,并返回指向这块空间的 指针。
注意:
(1)如果开辟成功,则返回一个指向开辟好空间的指针。
(2)如果开辟失败,则返回一个 NULL 指针,因此 malloc 的返回值一定要做检查。
(3)返回值的类型是 void*
,所以 malloc 函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
(4)如果参数 size 为 0,malloc 的行为是标准是未定义的,取决于编译器。
假设我们要开辟 10 个整型的空间:
📝 代码示例
🌟 运行结果
🍑 free
C 语言提供了另外一个函数 free,专门是用来做动态内存的释放和回收的,函数原型如下:
其实上面的 malloc 代码中,就已经用到了 free,它是用来释放动态开辟的内存。
注意:
(1)如果参数 ptr 指向的空间不是动态开辟的,那 free 函数的行为是未定义的。
(2)如果参数 ptr 是 NULL 指针,则函数什么事都不做。
(3)malloc
和 free
都声明在 stdlib.h
头文件中。
🍑 calloc
C 语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。原型如下:
calloc 函数的功能:把 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为 0。
说白了也是开辟指定大小的内存空间,如果开辟成功就返回该空间的首地址,如果开辟失败就返回一个 NULL。
与函数 malloc 的区别:calloc 会在返回地址之前把申请的空间的每个字节初始化为全 0。
📝 代码示例
🌟 运行结果
那么 calloc 和 malloc 那个效率更高呢?
malloc 函数不会进行初始化,直接返回地址,而 calloc 函数是初始化完以后才返回地址,显而易见,肯定是 malloc 函数效率更高。
但是以后的使用过程中也是看情况而且,想初始化就用 calloc,不想初始化就用 malloc;
🍑 realloc
有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的时候内存,我们一定会对内存的大小做灵活的调整。
那 realloc 函数就可以做到对动态开辟内存大小的调整,它的出现让动态内存管理更加灵活。
函数原型如下:
(1)ptr
是要调整的内存地址(如果 ptr 是个空指针的话,它的功能就和 malloc 一样);
(2)size
调整之后新大小;
(3)返回值为调整之后的内存起始位置。
(4)realloc
这个函数在调整原内存空间大小的基础上,还会将原来内存中的数据移动到 新 的空间。
📝 代码示例
注意: realloc 函数调整动态内存大小的时候会有三种情况:
假如我们要将一个大小为 40 个字节的空间调整为 80 个字节。
情况一👇
realloc 函数直接在原空间后方进行扩展,并返回该内存空间首地址(即原来的首地址)。
就拿上面的代码可以调试看一下,此时 new_ptr 的地址就是 ptr 的地址,说明是直接在原空间后方进行扩展的。
情况二👇
realloc 函数会在堆区中重新找一块满足要求的内存空间,把原空间内的数据拷贝到新空间中,并主动将原空间内存释放(即还给操作系统),返回新内存空间的首地址。
此时,new_ptr 是一个新的地址,说明是把原空间内的数据拷贝到新空间中,然后返回新内存空间的首地址。
情况三👇
如果需扩展的空间后方没有足够的空间可供扩展,并且堆区中也没有符合需要开辟的内存大小的空间。 结果就是开辟内存失败,返回一个 NULL。
3. 常见的动态内存错误
在动态内存操作的过程中,会遇到一些动态内存开辟的错误,我们来深入剖析一下
🍑 对 NULL 指针的解引用操作
📝 代码示例
⚙ 调试结果
由于 malloc 开辟空间过大,当开辟不出来这么大空间的时候,就会返回空指针;
而一旦返回空指针,那么 p 里面就是空指针,不管对 p 进行怎样的操作,都是无法访问的;
那么如何修改呢?可以加一个判断,检测是否为空指针
🍑 对动态开辟空间的越界访问
假设我们向内存申请了 10 个字符的空间,然后访问 11 个字节的空间
📝 代码示例
⚙ 调试结果
需要时刻注意,不能访问未申请的动态内存空间。
比如你向动态内存申请了 10 个字节,那就绝不能访问第 11 个字节。
🍑 对非动态开辟内存使用 free 释放
对于动态内存开辟的空间用一个 指针 来维护;
那如果我们不使用动态内存开辟的空间呢?
📝 代码示例
a 是函数的局部变量,是分配在 栈区 上的,栈区 上的空间怎么能够进行 free 呢?
⚙ 调试结果
在 栈区 上的空间,是进入作用域以后就创建,出作用域以后就销毁,它是按照代码的执行逻辑来走的,不需要主动释放。
注意:free 函数只能释放动态开辟的内存空间
🍑 使用 free 释放一块动态开辟内存的一部分
假设我们要开辟 10 个整型的空间,把前五个元素初始化为:1、2、3、4、5
📝 代码示例
⚙ 调试结果
我们可以看到 for 循环里面使用了另外一种赋值方法,这种方法当然是可以赋值成功的,但是还是会有一点小问题;
初始化完以后,程序直接进入到 free(p),那么就是相当于释放了 p 的一部分空间,这种情况是及其危险的,会导致 内存泄漏 的问题;
所以我们应该记录一下 p 的起始位置。
🍑 对同一块动态内存多次释放
假设把动态内存开辟的空间使用以后,需要 释放,但是忘记把 p 置为空指针,接着不小心又对它进行了一次 释放;
📝 代码示例
⚙ 调试结果
这种情况也是及其危险的,那我们应该怎么办呢?
(1)每次 free 完以后,把 p 置为 空指针;
(2)不要对同一块二空间进行多次释放;
🍑 动态开辟内存忘记释放
📝 代码示例
我们在 test 函数内部,使用动态内存开辟的空间, 当在 主函数 调用完成以后,如果忘记释放,那么在 test 函数外部想释放也不不可能,此时就会出现 内存泄漏 的问题。
当然如果把 p 返回给 main 函数,也是可以释放的👇
如果你既不返回,也不释放,那么就 完蛋🤣;
切记:动态开辟的空间一定要释放,并且正确释放 。
4. 动态内存经典笔试题
这里有几道关于 动态内存开辟 的经典笔试题,大家可以分析这些代码的运行结果、以及代码有什么问题?
🍑 题目一
📝 代码示例
🌟 分析
首先进入 mian 函数会直接调用 Test() 函数;
在 Test 函数内部:创建了一个 char* 的 str 变量,里面放了一个 空指针;
然后调用 GetMemory() 函数,此时是传值,p 相当于是 str 的一份临时拷贝,所以当前 p 里面放的是 空指针,然后 malloc 函数在 堆区 申请了 100 字节的空间,假设这 100 空间的地址是 0x0012FF80,然后把这个 地址 放到 p 里面去,那么 p 就有能力指向这块儿空间;
此时出了 GetMemory() 函数以后,因为 p 是形参,并且是在函数内部定义的,是 char 的指针,也就是 4 字节,所以一旦出了函数以后,p 就销毁了,但是 malloc 开辟的 100 字节没有释放呀,还是存在的,那么这 100 字节的空间地址就找不到了;
因为 p 的改变不会影响 str,所以 str 还是个空指针,那么 strcpy(str, "hello world")
就会非法访问内存,程序就会崩溃;
既然程序到 strcpy 就崩溃了,那么下面的 printf 肯定就会打印失败了
注意: printf(str)
是正确的,等同于 printf("hello world")
;
🍑 题目二
📝 代码示例
🌟 运行结果
🌟 分析
首先创建了一个 str 变量,里面是个 空指针,然后让 str 接收 GetMemory() 函数的返回值,下一步调用 GetMemory() 函数;
进入 GetMemory() 函数内部以后,创建了一个局部临时数组 p ,里面存放了 hello world,假设 p 的地址是 0x12FF40,然后返回 p 的地址;
回到 Test 函数以后,str 里面放的就是 0x12FF40,那也就意味着,当 p 返回去以后,str 指针就指向 0x12FF40 这块空间,那这种做法到底行不行呢?肯定是不行的啦!
虽然把 p 的地址返回去了,然后存到了 str 里面,但是出了 GetMemory() 函数以后,p 指向的这块空间就被回收了呀,因为 p 是创建的局部临时数组,进入 GetMemory() 函数,我们就有使用权,出了 GetMemory() 函数,就还给操作系统了;
所以一旦 p 返回去了,0x12FF40 这块空间就不属于我们了,那么 str 就是个 野指针,所以打印的结果就是 随机值;
这种问题统称为:返回栈空间地址的问题!
🍑 题目三
📝 代码示例
🌟 运行结果
这段代码运行起来是没问题的,唯一的问题就是没有 释放;
打印完 str 以后,应该使用 free 把 str 释放,然后再把 str 置为 空指针;
🍑 题目四
📝 代码示例
🌟 运行结果
可以看到打印出来也是可以的,唯一的问题就是使用 free 释放掉 str 以后,没有置为 空指针;
因为前面对 str 指向的空间已经释放,不能再使用,这时候就形成非法访问内存!
所以 free 释放完以后,应该置为 空指针 !
5. C/C++ 程序的内存开辟
先来看张静经典的 内存区域分布图👇
从图上可以知道,C/C++ 程序内存分配的几个区域如下:
(1)栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
(2)堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由 OS(操作系统) 回收 ;分配方式类似于链表。
(3) 数据段(静态区):(static)存放全局变量、静态数据;程序结束后由系统释放。
(4)代码段:存放函数体(类成员函数和全局函数)的二进制代码。
那么回想一下之前学的 static 关键字修饰局部变量,配合上面的图,就很容易理解了:
实际上普通的局部变量是在栈区分配空间的,栈区的特点是在上面创建的变量出了作用域就销毁;
但是被 static 修饰的变量存放在数据段(静态区),数据段的特点是在上面创建的变量,直到程序结束才销毁;
所以生命周期变长。
版权声明: 本文为 InfoQ 作者【Albert Edison】的原创文章。
原文链接:【http://xie.infoq.cn/article/a9dd5471eef9f397000d3516b】。文章转载请联系作者。
评论