三、常见的动态内存错误
在介绍完几个动态内存函数之后,我们再来分析一下【常见的动态内存错误】
1、对NULL指针的解引用操作
代码:
void test() { int *p = (int *)malloc(INT_MAX/4); *p = 20; //如果p的值是NULL,就会有问题 free(p); }
分析:
- 首先看到第一个,你要知道的是
INT_MAX
是什么。它是一个宏定义,表示int类型(整型)能够表示的最大值,其值为2147483647,那在上面讲malloc
的时候我们有说到过,若是需要申请的空间过大的话可能就会导致申请失败的问题,所以这里很致命的一个错误就是在申请空间之后没有去及时判断是否申请成功 - 可以看到编译器也是给我们报出了一个Warning警告说:==⚠ 取消对NULL指针的引用==
改进:
- 此时我们就可以对代码去做一个改进,对malloc之后的返回值做一个判断
void test() { int* p = (int*)malloc(INT_MAX / 4); if (NULL == p) { perror("fail malloc"); exit(-1); } *p = 20;//如果p的值是NULL,就会有问题 free(p); }
- 这个时候我们就可以看到没有警告再报出来了
2、对动态开辟空间的越界访问
代码:
int main(void) { int* p = (int*)malloc(100); if (NULL == p) { perror("malloc fail"); exit(-1); } int i = 0; for (int i = 0; i < 100; i++) { *(p + i) = 0; // 当i == 25时便会越界 } free(p); p = NULL; return 0; }
分析:
- 接下去我们来看这个越界访问的问题,首先我们使用
malloc
向堆区申请了100个字节的空间,但是呢在下面对这块空间进行访问的时候却访问了100个整型的大小,此时一定会造成访问越界的问题 - 但是呢口说无凭,我们一样通过调试来进行一个观察,不过这里在进行循环的时候
i
没有到100的话是不会出问题的,所以为了方便调试我们需要去设置一个【条件断点】,将i
从【24】开始执行,这样我们很快就能观察到结果了
- 然后我们便可以通过调试去进行观察了,可以看到
i
并没有到达100,而是直接跳出了当前循环,然后在free()
的时候就出现了问题,一般我们在一些其他地方观察不到的问题就会在free()的地方显现出来,因为此时是要去释放掉我们的这块申请的空间了,便会引发一些异常
- 其实我们可以将
*(p + i) = 0
修改成p[i] = 0
,利用[]
操作符对某个下标进行访问,此时我们可以看到编译器就报出了警告说索引"99"超出了“0"至”24"的有效范围,因此100个字节的空间只能供25个整型来进行存放,因此合法的下标索引即为0 ~ 24
改进:
- 代码修改这一块的话我们只需要在申请空间的时候保证申请到足够的、正确的容量即可
int* p = (int*)malloc(100 * sizeof(int));
- 这个时候我们就可以看到没有警告再报出来了
3、对非动态开辟内存进行free释放
代码:
void test() { int a = 10; int* p = &a; free(p); //ok? }
分析:
- 接下去再来看第三个,这里是对非动态开辟的内存进行
free()
释放,那我们在介绍free()
的时候说到它只能释放由【malloc】、【calloc】、【realloc】所开辟出来的空间,这些空间都是在堆区上进行申请的,但是我们在普通的函数中所创建的普通变量无非是栈区或者静态区的,它们的释放工作并不是由free()
来完成的,因此强行去这样做的话就会造成了一个很大的问题 - 可以看到一样出现了我们刚才那样类似的问题
改进:
- 本代码并没有什么通用的改进办法,如果不想出现问题的话就不要
free()
普通栈区上的变量即可,或者按照常规去动态申请然后在进行free()
4、使用free释放一块动态开辟内存的一部分
代码:
void test() { int* p = (int*)malloc(100); if (NULL == p) { perror("malloc fail"); exit(-1); } for (int i = 0; i < 10; i++) { p++; } free(p); //p不再指向动态内存的起始位置 }
分析:
- 本题的情境是这样的,我们在堆区申请了100个字节后,让指针p指向这块地址的起始位置,然后让其偏移了10个整型的位置,即40B的大小,那么此时指针p其实就指向了当前这一块地址的中间位置,那么此时再去
free
的时候其实就会出问题 - 因为该函数在释放动态申请的内存时需要从这块地址其实位置开始,然后释放制定的字节数,若是从某个中间位置开始的话就不对了
从下图可以看出,因为free()
函数需要做到申请多少释放多少,所以当其释放了一部分之后,就不够了,便造成了访问内存错误的问题
- 一样,我们通过调试去进行观察,首先在一开始申请出这块空间的时候先记录一下初始位置的地址,然后我们便可以观察到其进行了一个偏移,
- 可以看到,此时若是去
free()
的话就会出现警告,很明显这个debug_heap.cpp
就是【堆】这一块出的问题
改进:
- 要如何改进的话就会不要去
free()
一块动态开辟出来内存的一部分,而是要从起始地址开始释放,申请多少释放多少
5、对同一块动态内存多次释放
代码:
void test() { int* p = (int*)malloc(100); //使用... free(p); //... free(p); //重复释放 }
分析:
- 这一点的话就是在我们释放完一块内存空间后忘了,然后再去对其进行了一次释放,这种操作的话其实也是很危险的,当我们在第一次释放的时候p所指向的那块空间的使用权已经还给操作系统了,但是呢我们并没有对这个指针
p
做置空的操作,于是它还指向那块空间所在的地址,不过里面的内容已经是随机的了,那么这个指针就是一个【野指针】 - 此时再对其做一个
free()
的操作,就会造成操作野指针的问题
改进:
- 此时我们就可以对代码去做一个简单的改进,在第一次
free
后将指针p置为NULL即可,此刻若是后面再去free
的话,就不会出现问题了,因为当我们传递NULL作为参数的时候,free(NULL)
便不会去做任何的事情
void test() { int* p = (int*)malloc(100); //使用... free(p); p = NULL; // 将不使用的指针置为NULL //... free(p); //重复释放 }
6、动态开辟内存忘记释放(内存泄漏)
代码:
void test() { int* p = (int*)malloc(100); if (NULL != p) { *p = 20; } } int main() { test(); }
分析:
- 那最后一个呢就是我们最常见的,在动态开辟内存后忘记去释放了,例如上面有一个
test()
函数,函数内部去申请了100个字节的数据,并为其做了一个初始化,此时main函数就正常地去调用它,但是呢这中间却没有任何地free()
释放操作,就会存在【内存泄漏】的问题
💬 那有同学说:既然函数内部没有做释放的话我在调用结束后去free
一下这个p不就好了
- 这句话其实就存在很大的问题,如果读者有看过我的函数栈帧一文的话,就会很清楚了,对于一个在一个函数创建的变量,是处在当前这个函数所维护的栈帧中的,所以当这个函数调用结束后局部变量就会随着栈帧的销毁而不复存在,那此时我们再想去
free()
释放这块空间的时候,是无法访问到这个指针p
的。因此要释放的话只能在函数内部进行才可以
改进:
- 那改进这一块的话我们只需要在函数调用结束前去将其释放即可,不过别忘了在
free()
之后要将指针置为NULL防止野指针
void test() { int* p = (int*)malloc(100); if (NULL != p) { *p = 20; } free(p); p = NULL; }
- 所以当我们在使用动态内存的时候,一定要保证在【malloc】之后及时【free】,此时才能保证不会内存泄漏
但是它们两个成对出现就一定不会出现问题吗?
- 我们来看看下面这段代码,可以看到中间有一个
if(1)
的条件判断,我们知道这个条件是天然成立的,然后看到当这个条件成立后就会执行return
语句,那么当前这个函数就会结束了,此时并没有运行到free(p)
这句话 - 那么聪明的你一定很快反应过来了,即使是存在【malloc】和【free】成对出现的情况下,可能也无法百分百保证不会产生内存泄漏的问题,所以还是需要我们在写程序的时候多注意细节🤗
void test() { int* p = (int*)malloc(100); if (NULL != p) { *p = 20; } if (1) return; // 因为某些条件中途return了, 没到free() free(p); } int main() { test(); }
四、历年经典的笔试题分析【⭐】
当我们再度学习完相关的动态内存错误时,基础这一块就算是入门了,但还是要结合实际的题目进行训练才可以将知识点掌握得牢固,在本模块中呢,我会带读者进入几道历年以来非常经典的笔试题,Let's go!
题目一
代码:
- 下面这段代码,其总共有2处错误,你可试着自己分析一下:mag:
void GetMemory(char* p) { p = (char*)malloc(100); } void Test(void) { char* str = NULL; GetMemory(str); strcpy(str, "hello world\n"); printf(str); }
分析:
错误1: 非法访问内存
- 当str传递给p的时候,p是str的临时拷贝,有自己独立的空间,当GetMemory()函数内部申请了空间后,地址是放在p中的,str依然是NULL。当GetMemory()函数返回之后,strcpy()在拷贝的时候便会形成非法访问内存
错误2: 内存泄漏
- 在GetMemory()函数内部,动态申请了,但是没有即使使用
free
释放,会造成内存泄漏的问题
- 那我们可以到VS中来看一下是否存在这样的问题,于是在一编译之后,就可以看到有⚠Waring的出现,说是这个
str可能是“0”
,那这个str
它可是一个指针,那为0的话也就意味着它是一个【空指针】,那去访问空指针的话也是非常危险的一件事
- 接下去我们再通过调试去进行观察就可以很直观地发现,在进入
GetMemory()
函数进行动态开辟内存后,虽然p
指向了那块地址,但是与外界的str
却毫无关系,因此即使我们将其作为参数传入,也无法改变其为NULL的事实,那么此时再将其作为参数传递进strcpy()
和printf()
函数后,便会造成【空指针异常】的问题
改进:
那我们如何对这个代码去进行改进呢?因为我们想要使得函数内部指针的变化带动外部的变化,在C语言中我们可以使用【传址】的形式去进行,若是在C++中呢,则可以使用引用,这里不做细讲
- 可以看到,此处我将
&str
进行传递,然后在函数的形参部分使用二级的字符指针char**
来进行接收,此时内部的在使用*p
的时候就等同于是外部的str
,它们便指向了同一块内存地址,此时再去使用strcpy()
和printf()
这两个函数的时候就不会引发【空指针异常】的问题了
void GetMemory(char** p) { *p = (char*)malloc(100); } void Test(void) { char* str = NULL; GetMemory(&str); strcpy(str, "hello world\n"); printf(str); // 释放 free(str); str = NULL; }
- 然后再去调试就可以发现,没什么大问题了
题目二
代码:
- 下面这段代码,其总共有1处错误,你可试着自己分析一下:mag:
char* GetMemory(void) { char p[] = "hello world"; return p; } void Test(void) { char* str = NULL; str = GetMemory(); printf(str); }
分析:
错误: 返回栈空间地址问题:
p
代表字符数组的首元素地址,外部的str接收到了这个地址,然后再去打印这块地址中的内容,但是字符数组p属于【局部变量】,局部变量的会在栈区开辟函数栈帧,当函数调用结束的时候就把这块空间的使用权还给操作系统了,虽然这块地址还是在的,但是里面的内容已经销毁了,再去访问的话就会造成非法访问的问题
- 通过调试我们可以观察到,虽然在调用完
GetMemoy()
函数后,str接收到了内部的hello world
,但是在打印的时候出了问题,如果你有仔细阅读过函数栈帧一文的话,就可以很清楚得知道这个p
除了函数的作用域后就销毁了,不过呢在销毁之前【return】了一下,所以外界的str
可以接收到这个p所指向的地址,但是在打印str的时候,p
所指向的那块空间就销毁了,此时再去打印的话就看到了[烫烫烫...]
这些字样,因为这块空间已经变成了一块未分配的空间,那我们知道那些未分配的地址均是[ccccc]
,转变为中文字符即为[烫烫烫...]
改进1:
那想要去解决上述的这个问题,其实很简单,我们只要在让这个p不要存放在栈区即可,要让其存放在【静态区】,那里面的东西是从程序开始到结束都会留存着的,具体的内存分布在下一节会详细展开
- 我们只需要将其改为指针即可,此时这个指针
p
就指向了一个常量字符串,而对于常量字符串来说是不可改变的,其也是存放在内存中的【静态区】
char* p = "hello world";
- 不过更加准确的写法应该是这样,
p
为一个常量指针,其所指向的内容是不可修改的
const char* p = "hello world";
- 然后我们去调试一下就可以发现,当函数结束后
str
指向了和指针p
相同的那块空间,并且因为它们所指向的是一个常量字符串,它也存放在静态区,是不会消失的,因此我们在打印的时候就没有任何问题
改进2:
- 除了上面那种改法外,我们还可以将代码改成下面这样,在前面加上一个
static
做修饰,此时它就是一个静态数组,那和常量字符串一样也是存在于【静态区】中
static char p[] = "hello world";
- 此时一样去调试观察可以发现我们可以正常地将
hello world
打印出来
对比分析返回局部变量:
可能对于本题一开始的错误 —— 不可返回局部变量,有些同学还没有理解,我这里这里再举一个例子来对比分析一下
- 可以看到,下面有个指针函数
Test()
,其返回了一个局部变量的地址,此时外面拿一个指针去接收了一下这块地址,并且将其里面的内容给打印了出来,可以注意到这里我在接收到值后立马就做了打印,那如果我在这中间做点其他事呢,例如再做一个其他的打印,此时发生的结果会不会不一样呢👈
int* Test() { int a = 10; return &a; // 返回局部变量的地址 } int main(void) { int* pa = Test(); printf("%d\n", *pa); printf("haha\n"); // 先打印haha的话就看不到10了,printf()函数的栈帧覆盖了原来的pa return 0; }
- 首先我们来看上面这一种,即在接收到局部变量的地址后立即去做一个打印,此时我们可以看到还是可以访问到这个变量
a
的值是10
- 但是呢,若我在返回后没有立马去进行打印的话,此时就可以很直观得观察到局部变量
a
在出了当前函数的栈帧后已经销毁了,所以我们打印出来的并不是【10】,而是【5】,仔细观察调试窗口中,指针pa
所指向的那块空间中的值变成了【3285183】,完全可以说是一个随机值 - 刚才我们可以获取到这个10的原因是编译器的问题,可能我们在Linux上去运行的话结果就不是这样了,因为有些编译器在当前函数结束后不会立即释放掉,而是会等待一会;不过有些编译器呢却会理解销毁掉当前所创建的函数栈帧,此时内部所创建的局部变量也就会随之消失了
题目三
代码:
- 下面这段代码,其总共有1处错误,你可试着自己分析一下:mag:
void GetMemory(char** p, int num) { *p = (char*)malloc(num); } void Test(void) { char* str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str); }
分析:
错误: 内存泄漏
- 可以看到上面这段代码和我们第一题改进后的代码非常类似,就是在传参的时候所传递了一个需要开辟的内存字节数,可能你会觉得这段代码没有任何的问题,但是呢其确实是存在一个非常大的隐患,即在调用GetMemory()函数申请内存空间后没有及时使用free()进行释放,造成了内存泄漏的问题
改进:
- 代码的修改很简答,学到这里了,相信你对于内存泄漏该如何处理应该是非常熟悉了,那就是将动态申请的那块空间
free
掉即可,最后别忘了将其置空
void Test(void) { char* str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str); free(str); str = NULL; }