C生万物 | 动态内存管理

一、为什么存在动态内存分配

我们已经掌握的内存开辟方式有:

c 复制代码
int val = 20;	//在栈空间上开辟四个字节
char arr[10] = {0};	//在栈空间上开辟10个字节的连续空间

但是上述的开辟空间的方式有两个特点:

  1. 空间开辟大小是固定的
  2. 数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配
  • 那此时呢我们就希望有一种方式,可以在程序运行的过程中动态地去开辟当前程序所需要的内存空间,此时就需要使用到我们的【动态内存函数】了

二、动态内存函数的介绍

本文我总共会介绍三种动态内存函数,分别是malloc()calloc()realloc(),与之对应内存释放函数还有free()

1、malloc和free

【函数原型】:

c 复制代码
void* malloc (size_t size);

【函数解读】:

  • 首先我们来看一下malloc()这个函数,它会向内存申请一块连续可用的空间,并返回指向这块空间的指针

【特点】:

  1. 如果开辟成功,则返回一个指向开辟好空间的指针
  2. 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查
  3. 返回值的类型是void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定

这里我们来举一个例子说明一下

  • 可以看到,我在使用一个动态开辟出来的内存时分配四步走(不止四步),首先使用到malloc()函数去向内存申请大小为40的空间,由于其返回值是一个void*的指针,可以接收任何类型的指针,所以这里我去做了一个强转,将这块空间强制类型转换为int*
  • 上面说到在开辟空间的时候会有失败的可能性,所以我们要去做一个异常判断,若是这个指针为空的话,表明我们完全没有申请到相应的空间,那这个时候再去对这块地址进行操作的话就会造成==空指针异常==的问题
  • 在明确这块空间被开辟出来后,我们要先去做一个初始化操作,指针的访问这一块就不细说了,不太懂的同学可以去看看C语言指针一文。在初始化后就是将其去进行一个打印的操作
c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
	// 1.开辟空间
	int* p = (int *)malloc(40);
	
	// 2.异常判断
	if (NULL == p)
	{
		perror("malloc fail");
		exit(-1);
	}

	// 3.初始化空间
	for (int i = 0; i < 10; ++i)
	{
		*(p + i) = i + 1;
	}

	// 4.打印观察
	for (int i = 0; i < 10; ++i)
	{
		printf("%d ", *(p + i));
	}
	return 0;
}
  • 我们通过调试来进行观察,便可以发现我们刚好将所开辟的40个空间存放了10个整型数据
  1. 如果参数size为0,malloc的行为是标准是未定义的,取决于编译器。
  • 还有一个特点,单独再说一下,看了上面的函数解读后可以知道我们需要给malloc()函数传递进去一个size大小,它便会为我们开辟出指定的空间,但若是我们传递的参数为0的话,就显得很荒唐。
  • 举个例子:就好比你向别人借钱,如果你说要借50、100那还算正常,但是说 "我要借0元",那对方就会感觉到很奇怪,他到底要给你些什么东西呢?那编译器其实也是一样的,不过呢,既然你去要东西了,它还是会给你点什么。通过调试可以观察到虽然我们没有申请到任何的东西,但是呢却有了这么一块地址,这还是要看不同的编译器,反正在VS下还是会给你一个反应的

但是呢就上面这一些操作还是不够的,别忘了我们还有一个free()函数还没介绍呢

【函数原型】:

c 复制代码
void free (void* ptr);

【函数解读】:

  • 然后我们来看看这个函数,它主要用来释放动态开辟的内存

【特点】:

  1. 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的
  2. 如果参数 ptr 是NULL指针,则函数什么事都不做
  • 所以我们在刚才那段代码的下面应该再加上一个free(p)才行,但是这样真的就可以了吗?
c 复制代码
free(p);
  • 我们可以通过调试来观察一下,当执行完这句代码后初始化的1 ~ 10变成了一些随机值,这也就意味着我们一开始申请的这块空间还给操作系统了,所以里面所存放的这些内容都销毁了,不过从上面对于这个函数的解读中我们可以看出即使我们将这块空间还给操作系统了,但是这块申请空间的地址还是在的
  • 那么也就意味着这个指针p现在变成【野指针】了,变得非常危险
  • 若是我们想化解这个危机的话,可以在free(p)之后再将其置为NULL即可,此时就无法再找到之前的那块地址了

【注意实现】:

  • malloc和free都声明在 stdlib.h头文件中,记得要引头文件
  • 每次在使用【malloc】申请完一块空间后,一定要去做一个判空,预防申请失败的情况。而且在使用完这块空间后还要将其归还给操作系统,并且将指针所指向的这块地址置为空,防止野指针

2、calloc

讲完【malloc】之后我们再来讲讲另一个动态内存函数【calloc】

【函数原型】:

c 复制代码
void* calloc (size_t num, size_t size);

【函数解读】:

  • C语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。原型如下:

【特点】:

  1. 函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0
  2. 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0
  • 一样,我们可以通过调试来进行观察,与【malloc】不同的地方在于当我们申请到10个大小为4字节的空间后,发现这10个数据均为0,即在申请的同时就已经为初始化好了,不需要我们自己再去初始化
  • 如果还是觉得有点不可思议的话,我们可以再通过汇编去仔细看看

💬 所以如何我们对申请的内存空间的内容要求初始化,那么可以很方便的使用calloc函数来完成任务

3、realloc

最后再来讲讲另一个动态内存函数【realloc】

【函数原型】:

  • ptr是要调整的内存地址、size是调整之后新大小、返回值为调整之后的内存起始位置
c 复制代码
void* realloc (void* ptr, size_t size);

【函数解读】:

  • realloc函数的出现让动态内存管理更加灵活,有时会我们发现过去申请的空间太小了 ,有时候我们又会觉得申请的空间过大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小的调整

具体地我们来看一下要如何去使用这个realloc()进行一个扩容

  • 可以看到,在下面我首先申请了5个整型空间的大小,对其做了初始化之后就去做了一个扩容,要扩容的地址即为p,扩充后的容量便是10个整型数据
c 复制代码
int main(void)
{
	int* p = (int*)malloc(sizeof(int) * 5);
	if (NULL == p)
	{
		perror("malloc fail");
		exit(-1);
	}
	
	for (int i = 0; i < 5; i++)
	{
		*(p + i) = i;
	}

	// 不够了,增加5个整型空间
	p = (int*)realloc(p, sizeof(int) * 10);
	
	return 0;
}

💬 不过呢,我这里还要讲一下这个realloc到底是怎么进行扩容的,因为它有一个扩容机制,分为【本地扩容】和【异地扩容】

realloc扩容机制:【本地扩容】和【异地扩容】

  • 本地扩容,即在本地就有足够的空间可以扩容,此时直接在后面续上新的空间即可
  • 异地扩容 :当后边没有足够的空间可以扩容,realloc函数会找一个满足空间大小的新的连续空间。把旧的空间的数据,拷贝到新空间的前面的位置,并且把旧的空间释放掉(无需手动释放),同时返回新的空间的地址

可能这么说还不是很好理解,我这里再通过一个生活小案例来帮助理解

  • 平常我们外出旅游的时候由于比较遥远,无法一天之内回来,就会选择一些酒店或者旅馆🏠暂住一宿,那假设这个时候有一个旅行团要去住酒店,因为这家酒店只有单人间,可是呢他们有四个人,因为感情好,想住在一起,所以就让酒店前台开了一些连着的四个房间,互相之间串门方便一些,对于给出的这四个房间其实就是一开始为数组malloc空间,比较小一些
  • 然而这个时候呢,他们四个人又分别叫了自己的伙伴来,一起进行下一天的结伴旅行,想要和他们住在一起,于是问酒店前台小赵🤵可以不可以在已经为他们开的四个房间的后面再连续地开四个房间,这样他们8个人就可以住在一块了,虽然这很过分😀,但是刚好真的有连续的房间空出来,所以就又为他们开了四间房,这个时候新开的四间房就叫做==本地扩容==,就是在与上一次开辟空间后临接着开辟的
  • 这个时候这个前台小萌新就不知道怎么办了,于是去隔壁找了一个经验丰富的管理人员【老王】,老王这个时候想,既然他们是朋友,那就找一个一排空房间给到他们好了,原来的四间房还可以空出来。就在酒店的另一个大区域为他们开了八间房,然后让服务员把原来的四个人叫出来,把他们安置到新的四间房内,然后他们住过的房间就可以重新空出来为其他房客用了,接下去呢又把新来的四个人安排在他们的后面的接连房间内,于是他们8个人就并排地住在了一起,过上了幸福美满的生活。。。。哦,不对,应该是度过了一个美好的晚上🏠
  • 这里说的为他们8个人重新找一块区域安置就叫做==异地扩容==,也就是将原本开辟的空间中所存放的内容拷贝过来,然后放到新的空间中,接着把需要新放入的内容接着旧的内容之后

💬 通过上述这样一个例子,你是否理解了【本地扩容】和【异地扩容】呢 👈

【注意事项】:

  • 这里我还要讲一个注意点,如果仔细一点点学习下来的同学一定会想到一个问题,如果在扩容的时候失败了怎么办呢?此时realloc就会返回一个空指针
  • 但是当我们上面对这个指针p所指向的地址进行扩充后,又将其赋值给了自己,若真像我们上面所扩容失败返回空指针的情况,此时再去使用p的时候就会出现【空指针异常】的问题
c 复制代码
p = (int*)realloc(p, sizeof(int) * 10);

💬 那有同学说:这该怎么办呀🤔

  • 对于这个问题,我们的解决办法一般是这样的,定义一个新的指针tmp去指向这块空间,再扩容结束后再去判断一下这个指针是否NULL,若是为NULL的话代表扩容失败,此时应该打印错误信息然后结束程序,不要再往下执行了,而是当这个地址不为空的时候再将让原先的指针p指向它,让我们从头至尾都在维护同一个指针
  • 因此我们在扩容之后应该再去加上这么一个判断才行,在赋值完后别忘了把临时的tmp指针置为空,防止其变为【野指针】
c 复制代码
// 不够了,增加5个整型空间
int* tmp = (int*)realloc(p, sizeof(int) * 10);
if (tmp == NULL)
{
	perror("fail realloc");
	exit(-1);
}
p = tmp;
tmp = NULL;		// 这个指针不用了,记得置为空

当代码补充完整后,我们再通过调试来观察一下本地扩容和异地扩容

  • 首先是本地扩容,可以看到realloc返回的地址就是原先开辟出来那块空间的首地址
  • 然后是异地扩容,我们可以将需要扩充后的容量调大,这样后续的容量就会不够了,此时编译器便会在内存中再去找一块合适大小的空间,然后将原先的5个整型数据先拷贝过去,然后再在其后开辟出剩余的空间,最后再释放掉原先的那块空间

实际应用:数据结构之【顺序表】与【顺序栈】

对于这个【realloc】,它是有实际的应用场景的

  1. 首先第一个就是我们在数据结构之顺序表中在进行【尾插】的时候所做的扩容检查工作

    • 以下具体的代码实现,对于顺序表来说,我在一开始是没有给他分配任何空间的,因此在进行第一次尾插的时候就会进入到下面这段扩容机制中,首先就判断当前顺序表的容量是多少,再来决定需要扩容的大小,这里就很好地利用了realloc的一个机制:当传递的指针为空的时候,其所表现得行为就和malloc是一样的
cpp 复制代码
//检查是否需要扩容
void SLCheckCapacity(SL* ps)
{
	if (ps->size == ps->capacity)
	{
		int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		SLDataType* tmp = (SLDataType*)realloc(ps->a, newCapacity * sizeof(SLDataType*));

		//判断是否开辟空间成功【失败会返回空指针null pointer】
		if (tmp == NULL)
		{
			perror("realloc fail\n");
			exit(-1);	//结束掉程序【程序异常结束】
		}
		//扩容成功
		ps->a = tmp;
		ps->capacity = newCapacity;
	}
}
  1. 我们在数据结构之顺序栈中在讲入栈操作的时候也有使用到它,因为对于顺序栈来说会出现空间不够的情况,所以我们也需要去实现一个扩容的机制,
    • 以下就是具体的入栈代码实现,判断当前栈顶指针是否达到了栈的容量大小,如果是的话就找执行扩容逻辑,每次扩容的大小为原先的2倍,也是使用到了临时的指针tmp去做一个接受,判断其不为空后再去使用扩容之后的这块空间
c 复制代码
/*入栈*/
void PushStack(ST* st, STDataType x)
{
	//栈满扩容逻辑
	if (st->top == st->capacity)
	{
		//初始化时已经malloc开辟过空间了,因此无需考虑容量为空的情况
		STDataType* tmp = (STDataType*)realloc(st->a, st->capacity * 2 * sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("fail realloc");
			exit(-1);
		}
		st->a = tmp;
		st->capacity *= 2;
	}
	st->a[st->top] = x;		//top指向栈顶元素的后一元素,因此直接入栈即可
	st->top++;		//然后栈顶指针后移,为下一次入栈做准备
}

三、常见的动态内存错误

在介绍完几个动态内存函数之后,我们再来分析一下【常见的动态内存错误】

1、对NULL指针的解引用操作

代码:

cpp 复制代码
void test()
{
    int *p = (int *)malloc(INT_MAX/4);
    *p = 20;	//如果p的值是NULL,就会有问题
    free(p);
}

分析:

  • 首先看到第一个,你要知道的是INT_MAX是什么。它是一个宏定义,表示int类型(整型)能够表示的最大值,其值为2147483647 ,那在上面讲malloc的时候我们有说到过,若是需要申请的空间过大的话可能就会导致申请失败的问题,所以这里很致命的一个错误就是在申请空间之后没有去及时判断是否申请成功
  • 可以看到编译器也是给我们报出了一个Warning警告说:==⚠ 取消对NULL指针的引用==

改进:

  • 此时我们就可以对代码去做一个改进,对malloc之后的返回值做一个判断
c 复制代码
void test()
{
    int* p = (int*)malloc(INT_MAX / 4);
    if (NULL == p)
    {
        perror("fail malloc");
        exit(-1);
    }
    *p = 20;//如果p的值是NULL,就会有问题
    free(p);
}
  • 这个时候我们就可以看到没有警告再报出来了

2、对动态开辟空间的越界访问

代码:

cpp 复制代码
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

改进:

  • 代码修改这一块的话我们只需要在申请空间的时候保证申请到足够的、正确的容量即可
c 复制代码
int* p = (int*)malloc(100 * sizeof(int));
  • 这个时候我们就可以看到没有警告再报出来了

3、对非动态开辟内存进行free释放

代码:

cpp 复制代码
void test()
{
	int a = 10;
	int* p = &a;
	free(p);	//ok?
}

分析:

  • 接下去再来看第三个,这里是对非动态开辟的内存进行free()释放,那我们在介绍free()的时候说到它只能释放由【malloc】、【calloc】、【realloc】所开辟出来的空间,这些空间都是在堆区上进行申请的,但是我们在普通的函数中所创建的普通变量无非是栈区或者静态区的,它们的释放工作并不是由free()来完成的,因此强行去这样做的话就会造成了一个很大的问题
  • 可以看到一样出现了我们刚才那样类似的问题

改进:

  • 本代码并没有什么通用的改进办法,如果不想出现问题的话就不要free()普通栈区上的变量即可,或者按照常规去动态申请然后在进行free()

4、使用free释放一块动态开辟内存的一部分

代码:

cpp 复制代码
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、对同一块动态内存多次释放

代码:

cpp 复制代码
void test()
{
    int* p = (int*)malloc(100);
    //使用...
    free(p);

    //...
    free(p);	//重复释放
}

分析:

  • 这一点的话就是在我们释放完一块内存空间后忘了,然后再去对其进行了一次释放,这种操作的话其实也是很危险的,当我们在第一次释放的时候p所指向的那块空间的使用权已经还给操作系统了 ,但是呢我们并没有对这个指针p做置空的操作,于是它还指向那块空间所在的地址,不过里面的内容已经是随机的了,那么这个指针就是一个【野指针
  • 此时再对其做一个free()的操作,就会造成操作野指针的问题

改进:

  • 此时我们就可以对代码去做一个简单的改进,在第一次free后将指针p置为NULL即可,此刻若是后面再去free的话,就不会出现问题了,因为当我们传递NULL作为参数的时候,free(NULL)便不会去做任何的事情
c 复制代码
void test()
{
    int* p = (int*)malloc(100);
    //使用...
    free(p);
    p = NULL;   // 将不使用的指针置为NULL
    //...
    free(p);	//重复释放
}

6、动态开辟内存忘记释放(内存泄漏)

代码:

cpp 复制代码
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防止野指针
c 复制代码
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】成对出现的情况下,可能也无法百分百保证不会产生内存泄漏的问题,所以还是需要我们在写程序的时候多注意细节🤗
c 复制代码
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:
c 复制代码
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()这两个函数的时候就不会引发【空指针异常】的问题了
c 复制代码
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:
c 复制代码
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就指向了一个常量字符串,而对于常量字符串来说是不可改变的,其也是存放在内存中的【静态区】
c 复制代码
char* p = "hello world";
  • 不过更加准确的写法应该是这样,p为一个常量指针,其所指向的内容是不可修改的
c 复制代码
const char* p = "hello world";
  • 然后我们去调试一下就可以发现,当函数结束后str指向了和指针p相同的那块空间,并且因为它们所指向的是一个常量字符串,它也存放在静态区,是不会消失的,因此我们在打印的时候就没有任何问题

改进2:

  • 除了上面那种改法外,我们还可以将代码改成下面这样,在前面加上一个static做修饰,此时它就是一个静态数组,那和常量字符串一样也是存在于【静态区】中
c 复制代码
static char p[] = "hello world";
  • 此时一样去调试观察可以发现我们可以正常地将hello world打印出来

对比分析返回局部变量:

可能对于本题一开始的错误 ------ 不可返回局部变量,有些同学还没有理解,我这里这里再举一个例子来对比分析一下

  • 可以看到,下面有个指针函数Test(),其返回了一个局部变量的地址,此时外面拿一个指针去接收了一下这块地址,并且将其里面的内容给打印了出来,可以注意到这里我在接收到值后立马就做了打印,那如果我在这中间做点其他事呢,例如再做一个其他的打印,此时发生的结果会不会不一样呢👈
c 复制代码
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:
c 复制代码
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掉即可,最后别忘了将其置空
c 复制代码
void Test(void)
{
	char* str = NULL;
	GetMemory(&str, 100);
	strcpy(str, "hello");
	printf(str);

	free(str);
	str = NULL;
}

题目四

代码:

  • 下面这段代码,其总共有1处错误,你可试着自己分析一下:mag:
c 复制代码
void Test(void)
{
	char* str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);

	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
}

分析:

错误:非法内存访问

  • 在使用free()释放完str这块空间的之后,虽然这个地址中所存放的内容销毁了,但是这块地址还是存在的,此时这个str就变成了野指针,指向了一块随机的地址,这块地址是不为空的,所以会进入if条件判断,那么在使用strcpy()的时候就造成了非法内存访问

  • 本题我们通过画图来进行讲解,在一开始我们动态申请了100个字节的空间,然后往这块空间中放入了hello这个字符串,接着立马进行了free()释放,那我们之前有说过一块动态申请的空间若是释放了的话,虽然空间销毁了,但是指针还是留存着那块空间的地址,此时这个str即为一个野指针,指向了一块未分配空间的地址,而且有着100个字节的大小,所以其是不为空的

  • 那么接下去所做的操作就是非法的了,又往这块空间存放了world这个字符串,这也就形成了【非法访问内存】,虽然去运行不存在问题,但是这块空间的使用权并不是我们的,这才有【非法】这么一说

改进:

那我们该如何去改进它呢?

  • 很简单,我们只需要在free(str)后将其置空即可,因为在将一块空间还给操作系统后,本身我们不再拥有这块空间的使用权了,后面的操作都是非法的,但若时间我们将其置为NULL之后,这个指针也就忘记了它之前所指向的地址,此时进不了下面的这个if分支了,那逻辑也就正确了
c 复制代码
void Test(void)
{
	char* str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);
	str = NULL;

	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
}

五、C/C++程序的内存分布原理

接下去呢,我们来讲一讲C/C++程序的内存分布

  • 对于一个C/C++,你所做写的所有代码其实和内存相关的,例如你在函数内部创建一个变量,它就会在【栈区】上创建栈帧来进行存放这个变量,如果你通过malloc向【堆区】申请了一块空间并试图往里写点东西的时候,此时堆区就会多出来一块已经分配了的空间。
  • 不过,除此之外呢,我们可能还会涉及【静态区】或【代码段】,接下去我们就一起来看一下这几个区域吧👇
  1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
  2. 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。堆区主要进行动态内存分配,堆区的内存大小不固定,可以根据需要动态分配和释放
  3. 数据段 / 静态区(static)存放全局变量、静态数据。程序结束后由系统释放。其在程序编译时就确定了变量的存储空间大小和内存地址,具有固定的大小和位置
  4. 代码段:存储程序指令(代码)的一块内存区域,也被称为文本段(Text Segment),代码段通常是只读的,因为程序指令一般不应该被修改,代码段中存放函数体(类成员函数和全局函数)的二进制代码

光学习概念可不行,我们这里结合具体的代码来观察一下

c 复制代码
int globalVar = 1;
static int staticGlobalVar = 2;

void test()
{
	static int staticVar = 3;
	
	int localVar = 4;
	int num1[5] = { 1,2,3,4,5 };
	char str[] = "abcd";
	char* ps = "abcd";

	int* ptr1 = (int*)malloc(sizeof(int) * 4);
	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 8);

	free(ptr1);
	free(ptr3);
}

具体地我们可以通过下图来进行观察:

  • 可以看到这里在外部我创建了一个全局变量以及静态变量,它们都是存放在【数据段 / 静态区】的,以及函数test中的staticVar,虽然它是一个函数当中所创建的变量,但是因为前面加上了一个static作为修饰,所以它所存放的地址也是【数据段 / 静态区】
  • 接下去是【栈区】,这个很明显,即为在函数内部所创建的临时变量,可以看到无论这个变量是怎样进行初始化的,其本身就会在栈区开辟出相应的栈帧
  • 然后是【堆区】,即为本文我们所讲到的三个动态内存函数malloc()calloc()realloc(),只要是所涉及的内存分配,都是在堆区中开辟的
  • 最后的话便是【代码段】,这一块可能接触地比较少一些,也很少听到,上面有讲到,代码段是只读的,里面存放的都是一些指令,或者为一些只读的常量。那我们看到这里的abcd,就是一个常量字符串,它就是不可修改的,因此是存放在【代码段】

💬 这里我们先简答地讲一下有关C/C++内存分布,帮助理解本文的知识点。后续会专门出一篇相关文章进行详述,敬请期待🤛

六、柔性数组

在本文的最后呢,我们再来讲一下有关【柔性数组】的相关知识,这个相信很多同学都没有听说过,可要竖起耳朵👂认真听哦!

1、概念与声明

【概念】:C99 中,结构中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』成员。

  • 例如说下面这一个结构体,它最后一个成员便是数组,那么此时这个数组a就被称作为是【柔性数组】
c 复制代码
typedef struct st_type
{
	int i;
	int a[0];	//柔性数组成员
}type_a;
  • 不过呢,上面这样去声明一个数组在某些编译器中可能会报错,所以可以写成像下面这个样子,此时这个数组的大小就是不确定,随时可以去进行调整
c 复制代码
typedef struct st_type
{
	int i;
	int a[];	//柔性数组成员
}type_a;

2、柔性数组的特点分析

知道了什么是柔性数组后,我们来逐一分析一下它的特点

1、sizeof 返回的这种结构大小不包括柔性数组的内存

  • 如果你有看过结构体内存对齐的话,就可以知道在计算结构体大小的时候每个成员的大小都是要计算在内,不过呢在看下图的执行结果中我们可以知道这个数组的大小完全是不计算在内的,完全就像隐形了一样

2、结构中的柔性数组成员前面必须至少一个其他成员

  • 也就是说在这个结构体中只有数组a这么一个成员,但是呢它又是属于当前结构体中的最后一个也是唯一的成员,因为其为【柔性数组】,那若是连其他成员都没有的话这个结构体的大小就没有了,即没有空间了。这其实是很荒唐的一件事,若是这个结构体的内存空间都没有了的话,谈何为这个柔性数组去分配空间呢?
c 复制代码
typedef struct st_type
{
	int a[];	//柔性数组成员
}type_a;

3、包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小

  • 这里的话我们就要来说到有关【柔性数组】的一个使用了。这里我们要去申请两块空间,一个是整个结构体的大小,一个则是和这个柔性数组的大小,此处我首先为这个数组申请了10个字节的空间,再加上结构体本身的大小,才是我们要为结构体去申请的内存空间
  • 当申请到足够的空间后,我们便可以去初始化并看看这个柔性数组是否可以被正常地使用
c 复制代码
int main(void)
{
	// 1.开辟空间
	type_a* s = (type_a*)malloc(sizeof(type_a) + 10 * sizeof(char));
	if (NULL == s)
	{
		perror("fail malloc");
		exit(-1);
	}

	// 2.初始化空间
	s->i = 100;
	for (int i = 0; i < 10; i++)
	{
		s->a[i] = 'Q';
	}

	// 3.打印
	for (int i = 0; i < 10; i++)
	{
		printf("%c ", s->a[i]);
	}
	
	// 4.释放
	free(s);
	return 0;
}
  • 通过观察运行结果我们可以发现,的确是可以正常去进行使用的,其实和普通数组没什么两样

当然,就上面这样还体现不出柔性数组的特征,我们要动态地去改变这个数组的大小

  • 那此时我们便可以去做一个扩容的操作,使用到上面所学习的realloc()函数去进行操作即可
c 复制代码
type_a* tmp = (type_a*)realloc(s, sizeof(type_a) + 20 * sizeof(char));
if (NULL == tmp)
{
	perror("fail realloc");
	exit(-1);
}
  • 那此时呢我们就可以对新增容后的这一块空间去进行初始化的操作
c 复制代码
for (int i = 10; i < 20; i++)
{
	s->a[i] = 'W';
}

for (int i = 0; i < 20; i++)
{
	printf("%c ", s->a[i]);
}
  • 可以观察到,这个柔性数组确实产生了扩容,后面新增了我们再次初始化的数据

那还可以再扩吗?当然可以了!

c 复制代码
tmp = (type_a*)realloc(s, sizeof(type_a) + 30 * sizeof(char));
  • 通过再次去过扩容并初始化发现无论我们去扩容多少,它都可以呈现一个无线地缩放,这也就体现了【柔性数组】的特质
  • 那么这个数组在结构体中就是呈现下面这一种[柔性]的状态

💬 那有同学问:既然结构体的中的数组大小都增大了,那么这个结构体的大小会发生改变吗?

  • 这点相信你也想看看,不过通过观察我们可以发现其是不会发生变化的,因为在一开始讲柔性数组的时候我们就有说到过,无论数组的大小是多少,均是不算在结构体的大小内的

3、对比:柔性数组的优势

其实对于上面的这一种柔性数组实现,还可以像下面这样去进行设计

  • 此时我将结构体中的数组定义成了一个字符型指针
c 复制代码
typedef struct st_type
{
	int i;
	char* a;	
}type_a;
  • 然后一样利用malloc的形式去申请内存空间,不过这里结构体的空间和数组的空间是分开申请的,只有当结构体的内存空间申请完后,我们才去确立这个数组的大小
c 复制代码
type_a* s = (type_a*)malloc(sizeof(type_a));
if (NULL == s)
{
	perror("fail malloc");
	exit(-1);
}
s->i = 100;

char* tmp = (char*)malloc(sizeof(char) * 10);
if (NULL == tmp)
{
	perror("fail malloc");
	exit(-1);
}
s->a = tmp;
  • 接下去的话也是一样切进行初始化、打印、扩容等操作即可
c 复制代码
// 2.初始化空间
s->i = 100;
for (int i = 0; i < 10; i++)
{
	s->a[i] = 'Q';
}

// 3.打印
for (int i = 0; i < 10; i++)
{
	printf("%c ", s->a[i]);
}
	
// 4.增加
tmp = (char*)realloc(s->a, sizeof(char) * 20);
if (NULL == tmp)
{
	perror("fail malloc");
	exit(-1);
}
s->a = tmp;
  • 然后去运行就可以发现,也是同样可以正常使用这数组的,完全用不到像【柔性数组】那样的东西,使用我们平常这样的写法也是可以的
  • 那此时这个结构体的内存分布就是下面这样的,有一个结构体指针指向了一块内存空间,里面存放了两个结构体成员,分别是ia,其中后者是一个字符型指针,又指向了内存中的一块连续区域,它们都是在堆中的malloc出来的

💬 那有同学说,这完全不需要柔性数组了,没必要😎

  • 那我们就都知道,在开辟内存空间后要及时释放,这样才不会造成【内存泄漏】的问题,但是我们观察一下这个释放的过程,你是否有觉得繁琐吗?
  • 而且我们再进行释放的时候,必须要先释放数组a所指向的那块空间,然后再释放结构体的这块空间,因为如果你先释放结构体所在的这块空间的话,里面的指针a就找不到了
c 复制代码
// 释放
free(s->a);
s->a = NULL;
free(s);
s = NULL;

最后我们再来对比分析一下这两种方法的区别

【对比分析】;

  • 从下图来分析两种形式我们可以观察到三个不同点:
    • 对于柔性数组来说都是一次malloc一次free
    • 对于动态数组来说都是两次malloc两次free
    • 柔性数组所在的结构体内存空间都是连续的;动态数组所在的结构体内存空间不一定是连续的,因为两次【malloc】的地址可能不一样;
  • 因此呢我们就可以体会到柔性数组的优势了,接下去让我们具体地来讲讲其优势到底体现在哪里👇

第一个好处是:方便内存释放

  • 如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,==但是用户并不知道这个结构体内的成员也需要free==,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉

第二个好处是:这样有利于访问速度

  • 连续的内存有益于提高访问速度,也有益于减少内存碎片,因为当前在多次malloc之后,内存中就会产生多个内存碎片,所以我们应该尽量减少mallocfree的此处。不过呢,我个人觉得也没多大区别了,反正你跑不了要==用做偏移量的加法来寻址==

七、总结与提炼

最后来总结一下本文所学习的内容

  • 在本文中我们首先讲到了三个动态内存函数,分别是malloccallocrealloc,分别对其展开做了解读、分析和使用的学习,初步了解到了使用动态内存给我们带来的便捷之处
  • 但是在使用它们的时候还是需要有一些注意事项,接下去我们又讲到了常见的六种动态内存错误,帮助大家在使用的时候去有意识地规避一些问题
  • 对几种内存函数有了一定的掌握后,我们就可以通过笔试题来进行加深对知识点的理解,透过这些笔试题我们也了解到在使用【动态内存】的时候还是要去关注许多细节之处,否则就会造成不可挽救的风险
  • 接着我们又提到了C/C++中的内存分布原理,分别有【栈区】、【堆区】、【数据段 / 静态区】、【代码段】,我们所写的代码多多少少都与这些内存分区有着紧密的联系,所以对于这个内存分布一定要有清晰的认识
  • 最后我们又提到了【柔性数组】这个东西,它是在C99之后诞生的,若是在一个结构体中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』成员,它使用起来很是灵活,我们可以通过和realloc去进行配合无限地增加数组的长度。最后我们还拿【柔性数组】和【动态数组】去做了一个对比,对比观察出了其优势所在

以上就是本文要介绍的所有内容 ,感谢您的阅读🌹

相关推荐
历程里程碑1 分钟前
Linux22 文件系统
linux·运维·c语言·开发语言·数据结构·c++·算法
恋猫de小郭4 分钟前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
2601_949146537 小时前
C语言语音通知接口接入教程:如何使用C语言直接调用语音预警API
c语言·开发语言
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端