您好,欢迎来到刀刀网。
搜索
您的当前位置:首页13. C生万物之动态内存管理

13. C生万物之动态内存管理

来源:刀刀网

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

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

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

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

  • 那此时呢我们就希望有一种方式,可以在程序运行的过程中动态地去开辟当前程序所需要的内存空间,此时就需要使用到我们的【动态内存函数】了

二、动态内存函数的介绍

1、malloc和free

【函数原型】:

void* malloc (size_t size);

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

【特点】:

  1. 如果开辟成功,则返回一个指向开辟好空间的指针
  2. 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查
  • 那么在什么情况下开辟失败呢?

申请的内存过大就会开辟失败,所以就要判断是不是空指针,然后再进行使用

  1. 返回值的类型是void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定

在内存中的布局是这样的:


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

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

  • malloc申请的空间是怎么释放的呢?

    • free释放(主动)
    • 程序退出后malloc申请的空间,也会被操作系统回收的(被动)
  • 正常情况下,谁申请的空间,谁释放

  • 万一自己不释放,也要交代给别人

所以下面我们就介绍free

【函数原型】:

void free (void* ptr);

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

【特点】:

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

  • 若是我们想化解这个危机的话,可以在free(p)之后再将其置为NULL即可,此时就无法再找到之前的那块地址了


【注意实现】:

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

2、calloc

【函数原型】:

void* calloc (size_t num, size_t size);

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

【特点】:

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

  • 如果还是觉得有点不可思议的话,我们可以再通过内存去仔细看看

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

3、realloc

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

【函数原型】:

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

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

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

  • 可以看到,在下面我首先申请了5个整型空间的大小,对其做了初始化之后就去做了一个扩容,要扩容的地址即为p,扩充后的容量便是10个整型数据
int main()
{
	int* p = (int*)malloc(sizeof(int) * 5);
	if (NULL == p)
	{
		perror("malloc");
		return 1;
	}
	
	for (int i = 0; i < 5; i++)
	{
		*(p + i) = i;
	}
	//扩容
	p = (int*)realloc(p, 20 * sizeof(int));
	//释放
	free(p);
	p = NULL;
	
	return 0;
}
  • 不过呢,我这里还要讲一下这个realloc到底是怎么进行扩容的,因为它有一个扩容机制,分为【本地扩容】和【异地扩容】
3.1 realloc扩容机制:【本地扩容】和【异地扩容】
  • 本地扩容,即在本地就有足够的空间可以扩容,此时直接在后面续上新的空间即可
  • 异地扩容:当后边没有足够的空间可以扩容,realloc函数会找一个满足空间大小的新的连续空间。把旧的空间的数据,拷贝到新空间的前面的位置,并且把旧的空间释放掉(无需手动释放),同时返回新的空间的地址


【注意事项】:

  • 这里我还要讲一个注意点,如果在扩容的时候失败了怎么办呢?此时realloc就会返回一个空指针
  • 但是当我们上面对这个指针p所指向的地址进行扩充后,又将其赋值给了自己,若真像我们上面所扩容失败返回空指针的情况,此时再去使用p的时候就会出现【空指针异常】的问题
p = (int*)realloc(p, sizeof(int) * 10);
  • 对于这个问题,我们的解决办法一般是这样的,定义一个新的指针tmp去指向这块空间,再扩容结束后再去判断一下这个指针是否NULL,若是为NULL的话代表扩容失败,此时应该打印错误信息然后结束程序,不要再往下执行了,而是当这个地址不为空的时候再将让原先的指针p指向它,让我们从头至尾都在维护同一个指针
  • 因此我们在扩容之后应该再去加上这么一个判断才行,在赋值完后别忘了把临时的tmp指针置为空,防止其变为【野指针】
int* tmp = (int*)realloc(p, sizeof(int) * 10);
if (tmp == NULL)
{
	perror("fail realloc");
	return 1;
}
p = tmp;
tmp = NULL;	
  • 然后是异地扩容,我们可以将需要扩充后的容量调大,这样后续的容量就会不够了,此时编译器便会在内存中再去找一块合适大小的空间,然后将原先的5个整型数据先拷贝过去,然后再在其后开辟出剩余的空间,最后再释放掉原先的那块空间

三、常见的动态内存错误

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

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

代码:

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

分析:

  • 首先看到第一个,你要知道的是INT_MAX是什么。它是一个宏定义,表示int类型(整型)能够表示的最大值,其值为21474837,那在上面讲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()
{
	int* p = (int*)malloc(40);
	if (NULL == p)
	{
		perror("malloc");
		return 1;
	}

	int i = 0;
	for (int i = 0; i <= 10; i++)
	{
		*(p + i) = 0;	// 当i == 10时便会越界
	}
	free(p);
	p = NULL;
	return 0;
}

分析:

  • 接下去我们来看这个越界访问的问题,首先我们使用malloc向堆区申请了10个字节的空间,但是呢在下面对这块空间进行访问的时候却访问了11个整型的大小,此时一定会造成访问越界的问题
  • 但是呢口说无凭,我们一样通过调试来进行一个观察,不过这里在进行循环的时候i没有到100的话是不会出问题的,所以为了方便调试我们需要去设置一个【条件断点】,将i从【9】开始执行,这样我们很快就能观察到结果了

  • 然后我们便可以通过调试去进行观察了,可以看到i并没有到达11,而是直接跳出了当前循环,然后在free()的时候就出现了问题,一般我们在一些其他地方观察不到的问题就会在free()的地方显现出来,因为此时是要去释放掉我们的这块申请的空间了,便会引发一些异常

  • 其实我们可以将*(p + i) = 0修改成p[i] = 0,利用[]操作符对某个下标进行访问,此时我们可以看到编译器就报出了警告说索引"10"超出了“0"至”9"的有效范围,因此合法的下标索引即为0 ~ 9

改进:

  • 代码修改这一块的话我们只需要在申请空间的时候保证申请到足够的、正确的容量即可
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");
        return 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;
	}
}

分析:

  • 那最后一个呢就是我们最常见的,在动态开辟内存后忘记去释放了,例如上面有一个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);
}

四、笔试题

题目一

代码:

  • 下面这段代码,其总共有2处错误,你可试着自己分析一下
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语言中我们可以使用【传址】的形式去进行

  • 可以看到,此处我将&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处错误,你可试着自己分析一下
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】,而是【5】,仔细观察调试窗口中,指针pa所指向的那块空间中的值变成了【92491】,完全可以说是一个随机值

  • 刚才我们可以获取到这个10的原因是编译器的问题,可能我们在Linux上去运行的话结果就不是这样了,因为有些编译器在当前函数结束后不会立即释放掉,而是会等待一会;不过有些编译器呢却会理解销毁掉当前所创建的函数栈帧,此时内部所创建的局部变量也就会随之消失了

题目三

代码:

  • 下面这段代码,其总共有1处错误,你可试着自己分析一下
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;
}

题目四

代码:

  • 下面这段代码,其总共有1处错误,你可试着自己分析一下
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分支了,那逻辑也就正确了
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++编程》,大家有兴趣的可以看一下
  • 还有一些代码,是nice校招笔试题里的原题

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

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

我们这里结合具体的代码来观察一下

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,就是一个常量字符串,它就是不可修改的,因此是存放在【代码段】

动态内存管理就到这里结束了!


六、柔性数组

在本文的最后呢,我们再来讲一下有关【柔性数组】的相关知识

1、概念与声明

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

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

2、柔性数组的特点分析

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

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

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

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

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

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

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

	// 3.打印
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", ps->a[i]);
	}

	// 4.释放
	free(ps);
	ps = NULL;

	return 0;
}

  • 通过观察运行结果我们可以发现,的确是可以正常去进行使用的,其实和普通数组没什么两样


**

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

  • 那此时我们便可以去做一个扩容的操作,使用到上面所学习的realloc()函数去进行操作即可
type_a* tmp = (type_a*)realloc(s, sizeof(type_a) + 20 * sizeof(int));
if (NULL == tmp)
{
	perror("fail realloc");
	exit(-1);
}
  • 那此时呢我们就可以对新增容后的这一块空间去进行初始化的操作
struct st_type
{
	int i;
	int a[0];	//柔性数组成员
};
int main()
{
	// 1.开辟空间
	struct st_type* ps = (struct st_type*)malloc(sizeof(struct st_type) + 10 * sizeof(int));//44
	if (NULL == ps)
	{
		perror("fail malloc");
		return 1;
	}

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

	// 3.打印
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", ps->a[i]);
	}

	struct st_type* tmp = (struct st_type*)realloc(ps, sizeof(struct st_type) + 20 * sizeof(int));
	if (NULL == tmp)
	{
		perror("fail realloc");
		exit(-1);
	}
	else 
	{
		ps = tmp;
	}

	// 2.初始化空间
	for (int i = 10; i < 20; i++)
	{
		ps->a[i] = 'W';
	}

	for (int i = 10; i < 20; i++)
	{
		printf("%c ", ps->a[i]);
	}


	// 4.释放
	free(ps);
	ps = NULL;

	return 0;
}
  • 可以观察到,这个柔性数组确实产生了扩容,后面新增了我们再次初始化的数据

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

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

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

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

  • 此时我将结构体中的数组定义成了一个字符型指针
typedef struct st_type
{
	int i;
	char* a;	
}type_a;
  • 然后一样利用malloc的形式去申请内存空间,不过这里结构体的空间和数组的空间是分开申请的,只有当结构体的内存空间申请完后,我们才去确立这个数组的大小
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;
  • 接下去的话也是一样切进行初始化、打印、扩容等操作即可
struct st_type
{
    int i;
    int* a;    //柔性数组成员
};

int main()
{
    // 1.开辟空间
    struct st_type* s = (struct st_type*)malloc(sizeof(struct st_type)); // 分配结构体空间
    if (NULL == s)
    {
        perror("fail malloc");
        return 1;
    }

    s->a = (int*)malloc(sizeof(int) * 10); // 为柔性数组成员分配内存空间
    if (NULL == s->a)
    {
        perror("fail malloc");
        free(s);
        return 1;
    }

    s->i = 100;
    for (int i = 0; i < 10; i++)
    {
        s->a[i] = i;
    }

    // 3.打印
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", s->a[i]);
    }

    // 4.增加
    int* tmp = (int*)realloc(s->a, sizeof(int) * 20); // 使用原指针进行扩展内存空间
    if (NULL == tmp)
    {
        perror("fail malloc");
        return 1;
    }
    else
    {
        s->a = tmp;
    }
    return 0;
}
  • 然后去运行就可以发现,也是同样可以正常使用这数组的,完全用不到像【柔性数组】那样的东西,使用我们平常这样的写法也是可以的

  • 那此时这个结构体的内存分布就是下面这样的,有一个结构体指针指向了一块内存空间,里面存放了两个结构体成员,分别是ia,其中后者是一个字符型指针,又指向了内存中的一块连续区域,它们都是在堆中的malloc出来的

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

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

【对比分析】:

从下图来分析两种形式我们可以观察到三个不同点:

  • 对于柔性数组来说都是一次malloc一次free
  • 对于动态数组来说都是两次malloc两次free
  • 柔性数组所在的结构体内存空间都是连续的;动态数组所在的结构体内存空间不一定是连续的,因为两次【malloc】的地址可能不一样;

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

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

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

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

因篇幅问题不能全部显示,请点此查看更多更全内容

Copyright © 2019- gamedaodao.com 版权所有 湘ICP备2022005869号-6

违法及侵权请联系:TEL:199 18 7713 E-MAIL:2724546146@qq.com

本站由北京市万商天勤律师事务所王兴未律师提供法律服务