动态内存管理——详细解读C/C++程序中的内存分区
导读
大家好,很高兴又和大家见面啦!!!
在前面的内容中,我们已经介绍完了4个动态函数及其使用。不知道大家在前面的内容中有没有过一种疑惑——为什么同样是申请空间,通过动态函数申请的空间可以进行大小的修改,而创建变量或数组时申请的空间确无法进行空间大小的修改?
相信有细心的小伙伴在前面的函数介绍中有发现这么一句话:
- 在调试过程中,如何管理更多的堆的信息,请参阅C运行库调试支持。
这里提到的堆究竟是什么呢?难道这个堆跟动态内存管理是有什么联系吗?下面我们就一起来探讨一下;
一、C/C++程序中的内存分区
在计算机的世界里,所有的事物都是由数据构成,并且这些数据在计算机中都是以同一种形式存在——电信号。
现代的计算机结构都是冯·诺依曼结构——计算机由控制器、存储器、运算器、输入设备、输出设备这五大部件构成。
在冯·诺依曼机器中,所有的数据与指令都是以二进制的形式存储在存储器中,计算机能够识别不同的指令与数据。
为了更好的利用存储器的内存空间,C/C++程序将内存空间分成了以下几个部分:
- 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时
这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内
存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。 - 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方
式类似于链表。 - 数据段(静态区)(static)存放全局变量、静态数据。程序结束后由系统释放。
- 代码段:存放函数体(类成员函数和全局函数)的二进制代码。
下面我以一段代码来理解这些不同的分区:
//定义函数
void func1() {
}
//定义全局变量
int global_value = 1;
//C/C++中的内存分区
void test() {
//定义静态变量
static int static_value = 2;
//定义局部变量
int local_value = 3;
//定义数组
char str[4] = "abc";
//调用函数
func1();
//申请动态内存
int* p = (int*)calloc(3, sizeof(int));
if (!p) {
perror("calloc");
return;
}
free(p);
}
在这段代码中,我们分别创建了全局变量、静态变量、局部变量、字符数组,调用了函数,通过动态函数申请了空间以及释放了申请的空间。
那么对于这段代码中的各个元素,他们在内存中又分别位于什么位置呢?下面我们就一起来看一下:
从上图中我们可以根据这些变量和值的字体颜色来进行它们对应区域的划分:
- 红色字体:栈区
- 蓝色字体:堆区
- 绿色字体:数据段
- 橙色字体:代码段
现在我们就明白了,我们在进行动态内存申请时,内存空间申请是在堆区完成,而空间地址也就是函数的返回值则是由栈区的指针进行接收。
也就是说动态函数管理的内存空间实际上是管理的堆区,而创建的变量、数组是在栈区。
这也很好的解释了为什么同样是申请内存,但是动态函数申请的内存可以主动的被释放,而变量、数组所申请的内存空间不能主动释放。就是因为他们所分配的内存空间不相同。
现在动态内存管理的内容我们就介绍完了,接下来我们就来继续探讨一下如何正确的使用动态内存;
二、常见的动态内存的错误
在动态内存管理中,由于整个过程分别涉及到栈区的指针变量以及堆区的内存空间,因此我们如果使用不当的话,很容易会照成一些错误。在动态内存管理中,大致有以下几点常见错误:
2.1 内存开辟失败后对空指针进行解引用
malloc
、calloc
和realloc
这三个函数在申请内存空间时都会存在两种情况:
- 内存开辟成功:返回指向内存起始地址的指针
- 内存开辟失败:返回空指针
因此,如果我们在进行内存申请后,未对返回值及时的进行判空操作,那么就很容易在后续操作中出现对空指针解引用的问题。如下所示:
//常见错误1——内存开辟失败后对空指针进行解引用
#include <limits.h>
void test7() {
//通过动态函数申请空间
int* p = (int*)calloc(INT_MAX, sizeof(int));
for (int i = 0; i < 5; i++) {
p[i] = i + 1;
}
free(p);
}
这里我们可以看到,此时我通过calloc
申请内存空间后,并未对指针p进行判空,而是直接对指针p进行解引用,这种情况下就很容易出现对空指针解引用的错误。
因此为了避免这种错误的产生,我们一定要注意:
- 内存空间申请完后,对返回值进行判空操作。
2.2 对已开辟好的空间进行越界访问
当我们通过内存函数开辟空间时,实际上就是在堆区申请了一块连续的内存空间。
当我们的空间申请好后,在进行访问时,该空间的大小是无法被改变的。比如我申请了10个字节的空间,那么我也就只能够访问十个字节的空间。当我想要访问第11个字节的空间时,此时就会发生越界访问的问题。如下所示:
//常见错误2——对已开辟好的空间进行越界访问
void test8() {
//通过动态函数申请10个整型空间
int* p = (int*)calloc(10, sizeof(int));
//完成申请后对p及时进行判空操作,防止出现错误1
if (!p) {
perror("calloc");
return;
}
for (int i = 0; i < 20; i++) {
p[i] = i + 1;
}
free(p);
}
在这个例子中,我们通过calloc
只申请了10个整型空间,但是在进行访问时,我们设置的边界却是20个整型空间,因此当代码完成第10个整型空间后继续访问第11个空间时,就会发生越界访问。
这时可能就有朋友要反驳了——你不是说这个堆区的空间是可以实时进行调整的吗?怎么现在又不能调整了呢?
这是因为动态内存管理的动态体现在我们可以通过动态函数来改变内存空间的大小,而不是我们在访问内存空间时,它的大小能够随意的被改变,这一点一定要注意!!!
因此为了避免出现越界访问的问题,我们一定要确定好访问的边界,如申请了10个字节的空间,那我们就只能够访问从起始地址开始的10个字节以内的空间。
2.3 free不是有动态函数开辟的空间
这个问题在前面我们也提到过,free
函数能够主动释放的只有堆区的空间,而堆区的空间只能够通过malloc
、calloc
以及realloc
这些动态函数来进行申请,当我们通过free
来释放栈区的空间时,那就会发生错误。如下所示:
//常见错误3——free不是有动态函数开辟的空间
void test9() {
//创建大小为10的整型数组
int arr[10] = { 0 };
//通过动态函数创建10个整型空间
int* Arr = (int*)calloc(10, sizeof(int));
if (!Arr) {
perror("calloc");
return;
}
//通过free释放数组空间
free(arr);
}
在这个例子中,我们分别在堆区和栈区创建了10个整型空间。两个指针名虽然都是arr
,但是首字母大写的指针名执行的空间是堆区的空间,小写的指针名指向的空间是栈区的空间。最后我们在释放空间时,通过free
释放的是小写的arr
,也就是释放的栈区的空间。
显然这种操作是错误的,在内存空间中,栈区的空间只能够有操作系统进行回收,只有堆区的空间才能够由程序员主动释放,因此free
函数能够释放的是指向堆区的指针Arr
,这个指针名是大写开头。
为了避免这个问题的出现,我们就需要特别注意指针名以及空间申请的方式。传入到函数的指针一定得是指向由动态函数开辟的空间。
2.4 free动态内存开辟空间的一部分
这个问题的出现一般是在进行空间释放时,我们给函数传入的不是空间的起始地址,如下所示:
//常见错误4——free动态内存开辟空间的一部分
void test10() {
//创建大小为10的整型数组
int P[10] = { 0 };
//创建10个整型空间
int* p = (int*)calloc(10, sizeof(int));
//及时进行判空,避免错误1
if (!p) {
perror("calloc");
return;
}
//控制好访问边界,避免错误2
for (int i = 0; i < 5; i++) {
*p = i + 1;
p++;
}
//区分好指针指向的空间,避免错误3
free(p);
}
在这个例子中我们可以看到,从申请空间,到访问空间,最后到释放空间,前三个问题我们都已经避免了,现在感觉代码没啥问题对吧。
但是这个代码是存在问题的,注意看我们在对空间进行访问时,我们的访问方式是怎样的?
没错,我们的访问方式不是通过下标来逐一访问各个空间,而是通过移动指针并进行解引用完成的访问。在我们没有记录起始地址的情况下,这种访问方式就会让我们丢失已经被访问过的空间,最后传入函数的地址并不是空间的起始地址。这样free函数会判定该空间并不是有效的空间。
导致这个错误的原因是我们对free函数的底层逻辑不太理解。那么free函数的底层逻辑是什么呢?下面我就来说一下我对这个逻辑的简单理解;
2.4.1 free函数的底层逻辑
在动态内存管理中,malloc
是动态内存申请的一个最核心的函数,calloc
是在malloc
的基础上进行的空间内容的初始化,realloc
是在malloc
的基础上进行的空间大小的调整,因此我们可以认为free在释放空间时需要判断该空间是否是由malloc
申请的有效空间。
那么这个有效空间具体指的是什么呢?
其实我们通过分析malloc
的功能就能明白了——malloc
是用来在堆区申请指定字节大小的内存空间。
这里的关键字就是堆区与指定字节大小:
- 在C/C++程序中,堆区位于低地址处,栈区位于高地址处,两个分区所在的地址是不相同的,
malloc
在进行空间申请时,就是从堆区开始查找空间; - 当我们指定了开辟空间的大小后,
malloc
会根据该指定的字节大小进行精确查找,当堆区中没有指定大小的空余空间时,函数就会返回NULL
,当存在该大小的空间时,函数就会返回该空间的起始地址;
malloc
在申请内存时会记录申请的内存空间的大小,然后free
在释放时会比较进行两次比较:
- 释放空间的地址是否在堆区;
- 需要释放的空间是否等于这个大小
通过这两次比较以此来判断该释放的空间是否为有效空间,当然,具体的判断过程我们不去深究,这里我们只需要知道free
函数在释放空间时会判断释放空间的大小是否与申请的空间大小相同即可。因此当我们使用free来释放开辟空间的一部分时,就会程序就会报错,如下所示:
因此为了避免这个问题的出现,大家在对申请好的空间进行访问时,一定得注意起始地址的记录,在进行空间释放时,一定是传入的空间起始地址;
2.5 未对开辟的空间进行释放导致内存泄漏
这个问题一般出现在使用完堆区的空间后,因各种因素而导致未及时释放空间,如下所示:
//常见错误5——未通过`free`释放空间
void test11() {
//创建10个整型空间
int* p = (int*)calloc(10, sizeof(int));
//及时进行判空,避免错误1
if (!p) {
perror("calloc");
return;
}
}
在这个例子中可以看到,我们在完成空间申请后并未对该空间进行释放,函数就直接结束了。
对于堆区的空间而言,它可以由程序员通过free
来主动释放内存,也可以在程序结束后通过操作系统自动回收内存,当遇到上例这种情况时,那空间的回收就只能够有操作系统来完成。
目前来看好像是每什么问题,接下来我们继续往下看:
但是现在我们可以看到,当我们在调用test11
这个函数后,随即在主函数内又进行了内存空间的开辟,可以此时主函数内的空间开辟却失败了,这就是因为我们没有主动将test11
函数中申请的空间释放,使得堆区中的内存被占用,从而影响了后续的使用;
因此为了避免出现这种问题,我们一定要记住,只要有进行内存空间的申请,那么就需要有一次对应的内存空间释放,如下所示:
那是不是说只要我们像这样处理就行了呢?下面我们继续往下看:
//常见错误5——未通过`free`释放空间
void test12() {
//创建10个整型空间
int* p = (int*)calloc(10, sizeof(int));
//及时进行判空,避免错误1
if (!p) {
perror("calloc");
return;
}
//通过realloc调整空间大小
int* tmp = (int*)realloc(p, 20 * sizeof(int));
if (!tmp) {
perror("realloc");
return;
}
p = tmp;
free(p);
p = NULL;
}
在这个例子我们先是通过calloc
向堆区申请了一块空间,之后又通过realloc
将这块空间进行了扩容,在这之后我们便通过free释放了该空间。
从整个过程来看,似乎不存在任何问题,接下来我们继续往下看:
从测试结果可以看到,此时我们在主函数中申请内存时同样失败了,这又是为什么呢?
细心的朋友已经发现问题所在了,没错就是realloc
扩容失败的处理上。
在代码中,我们对扩容失败的处理是直接结束函数的运行,这时后面释放空间的过程压根就不会执行,正因为这样,所以先前通过calloc
申请的内存空间仍未被释放,因此这也就影响了后续对内存空间的申请操作。
为了避免这种情况的出现,我们一定要注意,当我们执行了申请空间的操作后,在使用完空间后一定需要将空间归还给操作系统。
那对于这里的修改我们则可以在扩容失败后,先将calloc
申请的空间进行释放后,再回到主函数,如下所示:
可以看到,此时程序的运行就不会有任何问题。当然除了上述的情况可能导致内存泄漏外,还有我们之前介绍过的,直接通过指向需要进行扩容的空间的指针来接收realloc
的返回值,在扩容失败后,丢失原先空间的地址而导致内存泄漏,这里我就不再继续展开。我们接着往下看;
2.6 对同一块空间进行多次释放
这个问题常出现在多个指针指向同一块空间时的情况,如下所示:
//常见错误6——对同一块空间进行多次释放
void test13() {
//创建5个整型空间
int* p = (int*)calloc(5, sizeof(int));
//及时进行判空,避免错误1
if (!p) {
perror("test13:calloc");
return;
}
//通过realloc进行扩容
int* tmp = (int*)realloc(p, 8 * sizeof(int));
if (!tmp) {
perror("test13:realloc");
//扩容失败时,主动释放calloc申请的空间,避免错误5
free(p);
p = NULL;
return;
}
free(tmp);
tmp = NULL;
free(p);
p = NULL;
}
在这个例子中,我们通过calloc
先申请了一块空间,随后通过realloc
进行了扩容,为了避免内存泄漏,我们在函数返回前,通过free释放了内存空间,并且在完成扩容后,我们通过通过指针释放了内存空间,这样是不是就没问题了呢?下面我们就来测试一下:
可以看到,此时同样出现了报错,这又是为什么呢?
从输出的地址我们可以看到,指针p
和指针tmp
此时指向的是同一块空间,我们在完成扩容后,先是通过free
释放了tmp
指向的空间,随后又通过free
释放了p
指向的空间,正因为他们指向的是同一块空间,这就导致了这块空间被重复的进行了释放。
这个问题同样与free函数的底层逻辑有关,根据前面的介绍,我们知道free在进行空间释放时会判断该空间是否时有效空间,当第一次释放该空间时,这时能够释放成功是因为释放的空间为有效空间。
可是这里我们需要注意——空间一旦被释放,它就处于了可使用的状态,而通过malloc
函数申请好的空间是出于不可用状态。
因此free
函数在第二次释放该空间时,会通过空间的可使用状态来判断该空间并不是有效空间。这也就是为什么当我们释放同一块空间时会出现报错。
为了避免这个问题的产生,我们需要注意以下几点:
- 一个内存申请的动作只能够匹配一次内存释放的动作;
- 通过
realloc
进行扩容成功时,我们只需要对realloc
的返回值进行一次内存释放即可;
结语
今天的内容到这里就全部结束了,在下一篇内容中我们将介绍《柔性数组》的相关内容,大家记得关注哦!如果大家喜欢博主的内容,可以点赞、收藏加评论支持一下博主,当然也可以将博主的内容转发给你身边需要的朋友。最后感谢各位朋友的支持,咱们下一篇再见!!!