还不知道动态内存管理吗?一篇带你了解!

37 阅读14分钟

动态内存管理

目录:

1.前言

2.用于申请内存的几个库函数

3.容易犯的错误

4.C/C++中的内存开辟

5.柔性数组

6.总结

1.前言

之前我们一起了解了自定义类型,其包含了:结构体,枚举,联合体,其功能各有用武之地,而今天,我们就来一起来看看C语言中的一个重点内容,其关乎我们之后对于数据结构的理解,那废话不多说,就开始我们对于动态内存管理的探讨之旅吧!!!

2.用于申请内存的几个库函数

用常识可以猜到,通过正常的方法,我们几乎不能像内存中申请空间,所以,就有了我们接下来要讲的这几个贯穿全篇的库函数

分别是:

  1. malloc
  2. calloc
  3. realloc

这几个库函数,都要包含一个名为stdlib.h的头文件,并且都会和一个free的库函数连在一起进行使用,下面就让我给大家一个一个开始解析这些库函数吧!

1.malloc

当我们不知道一个库函数怎么使用的时候,我们通常都可以去cplusplus.com查一查,那我就节省大家的时间,直接把图片截过来了

屏幕截图 2024-11-17 201521.png

下面我就对这些内容稍加解释:

屏幕截图 2024-11-17 201947.png

所以,我们就对这个库函数应该就有了基本的理解,下面我就给大家举一个简单的栗子,便于我们的更好地理解:

//头文件包含的步骤就不写了
int main()
{
    int* arr = (int*)malloc(sizeof(int)*10);
    //当然这里也可以直接写40,但是上面这种写法就不需要我们自己去计算了
    for(int i = 0; i<10; i++)
    {
        arr[i] = i;
    }
    for(int i = 0; i<10; i++)
    {
        printf("%d ",arr[i]);
    }
    printf("\n");
    free(arr);
    return 0;
}

输出的结果:

屏幕截图 2024-11-17 202657.png 这里就是一个很基础的使用了,但应该可以很好的帮助我们对于这个库函数有一个好的理解了

2.calloc

calloc其实跟malloc的功能很相似,但相比于malloc的只是向内存中开辟一段空间不一样,其多了一个将开辟的空间中的元素都初始化为0的操作,下面就还是老规矩,先看看这个库函数在cplusplus.com中的描述

屏幕截图 2024-11-17 203123.png

下面还是依旧对这一面的内容进行简单的讲解:

屏幕截图 2024-11-17 204105.png 我们既然知道了其用法,那下面就举一个简单的栗子让我们更好地去理解其功能:

int main()
{
    int* arr = (int*)calloc(10,sizeof(int));
    for(int i=0; i<10; i++)
    {
        printf("%d ",arr[i]);
        //这里是为了让我们更好地理解其初始化为0的功能
    }
    printf("\n");
    for(int i=0; i<10; i++)
    {
        arr[i] = i;
    }
    for(int i=0; i<10; i++)
    {
        printf("%d ",arr[i]);
    }
    printf("\n");
    return 0;
}

输出结果:

屏幕截图 2024-11-17 204617.png

作为对比,我也将malloc这样代码运行的结果展示一遍:

屏幕截图 2024-11-17 204752.png

这样我们也就能确信calloc的功能了,所以我们也能初步地理解这两种使用环境

malloc:多用于不需要我们对开辟空间元素初始化要求的场景

(当然想将malloc变成calloc一样的功能的话,仅需要在后面对malloc开辟的空间进行一次memset就行了)

calloc:多用于对开辟空间有初始化要求的场景

3.realloc

这个库函数更是重中之重,其的重新分配空间的能力,使我们的代码有了很强大的拓展性,下面还是老规矩来看看其在cplusplus.com中的描述,这可以更加便于我们在下面的讲解

屏幕截图 2024-11-17 205416.png

下面就对这内容进行简单的解释:

屏幕截图 2024-11-17 210638.png

下面我们就来见识一下其重新分配空间的功能吧!

int main()
{
    int* arr = (int*)malloc(sizeof(int)*10);
    for(int i=0; i<10; i++)
    {
        arr[i] = i;
    }
    //此时用malloc分配的空间显然是已经没了
    for(int i=0;i<10;i++)
    {
        printf("%d ",arr[i]);
    }
    printf("\n");
    //但是我们通过使用realloc,将这份空间扩展
    int* aarr = (int*)realloc(arr, sizeof(int)*20);
    if(aarr == NULL)
    {
        //说明此时空间分配失败了
        printf("%s\n",strerror(errno));
        return 1;//这通常来表示程序错误
    }
    arr = aarr;
    for(int i=10; i<20; i++)
    {
        arr[i] = i;
    }
    for(int i=0; i<20; i++)
    {
        printf("%d ",arr[i]);
    }
    printf("\n");
    return 0;
}

输出结果:

屏幕截图 2024-11-17 211441.png

这里我们就能看见其重新分配空间的功能了

当然,其也可以当成malloc来进行使用

int main()
{
    int* arr=(int*)realloc(NULL,sizeof(int)*10);
    for(int i=0; i<10; i++)
    {
        arr[i] = i;
    }
    for(int i=0; i<10; i++)
    {
        printf("%d ",arr[i]);
    }
    printf("\n");
    return 0;
}

当然正是其这强大的功能,其使用的场景有很多,特别是像后面的数据结构中,为了按需索取,就可以使用realloc,因为这样可以在很大程度上避免空间的浪费

3.容易犯的错误

其实在动态内存的使用过程中,其实经常会碰到一些错误,而恰巧这些错误最容易导致更大的错误出现,下面我就开始一个一个讲解

1.忘记释放申请的内存了(最危险的行为)

这是在使用动态内存的时候最容易犯的错误,因为每个人总有那么一个不小心的时候,这时候就会有人忘了free这件事,但恰巧这就是会造成最大问题的关键,因为我们不进行free的操作的话,会导致申请的空间一直处于被占用的状态,所以这时候每当运行一次程序就会导致电脑的内存下降,这就是大名鼎鼎的内存泄露,当发生内存泄露的时候,黑客也就可以很好地黑进你所在的程序,就可以干一些不法行为,而且,这就会导致一些机密泄露了,就大概像这样

int main()
{
    int* arr = (int*)malloc(sizeof(int)*20);
    //......
    //最后在这里忘记了释放内存
    return 0;
}

2.重复的对一片内存进行释放

这个虽然可能不会造成像上面忘记内存释放的后果,但其也是我们很容易犯的错误,因为无论是谁都可能有时一下忘记自己已经做过释放内存的行为,所以最后也可能自然而然地就又对原本就已经释放的空间再次释放,大概就是这样的一种情况

int main()
{
    int* arr = (int*)malloc(sizeof(int)*10);
    //......
    free(arr);
    //......
    free(arr);
    //此时就是重复对一片内存进行释放
    return 0;
}

3.对NULL指针释放

这个有时候也可能会犯,这主要是和上面的情况连在一起的,有的人养成一个好习惯后,就会把内存释放后将原本指向被开辟的内存的指针指向NULL,然后到后面继续写代码就忘了自己已经对内存进行释放过了,然后就对这个NULL进行释放,大概就像下面这样

int main()
{
    int* arr = (int*)malloc(sizeof(int)*10);
    //......
    free(arr);
    arr = NULL;
    //......
    free(arr);
    arr = NULL;
    //就大概是这样子的
    return 0;
}

4.对开辟外的空间进行访问

这种就是很经典的越界访问,这就不多介绍了,就直接来看看这个例子就行了

int main()
{
    int* arr = (int*)malloc(sizeof(int)*10);
    for(int i=0; i<=10; i++)
    {
        arr[i] = i;
        //这里就越界访问了,也就是我们只开辟了10个int类型元素的空间
        //但是我却用到了第11个空间,所以就是越界了
    }
    free(arr);
    arr = NULL;
    return 0;
}

5.释放的空间不完全

这个很好理解,就是我释放空间的时候,不是从最开始的位置进行释放,这个可以从下面这个例子很好地理解

int main()
{
    char* str = (char*)malloc(sizeof(char)*10);
    str = "abcdefg";
    char str1[] = "abcdef";
    while(*str && *str1 && *str==*str1)
    {
        str++;
        str1++;
    }
    free(str);
    //这里释放的就只是g和后面跟着的'\0'的空间,前面的abcdef的空间都没得到释放
    return 0;
}

6.对非动态开辟的空间进行释放

这个主要发生在代码一次性写久后,整个人都昏昏的状态下去写的,这时候就容易忘了那一块才是动态开辟的空间,然后就会写出这种代码

int main()
{
    int* arr1 = (int*)malloc(sizeof(int)*10);
    int arr2[]={0};
    //......
    free(arr2);
    //这里就是释放空间错误了
    return 0;
}

7.小结

所以每当我们写完代码的时候要注意自己写的代码里是否用到了动态内存,假如用到了就应该注意自己对于这部分空间的使用状况了,还有可以多用assert这个库函数,当我们出问题的时候,可以一下子知道大概是哪里出的问题,这样也可以加快我们修bug的效率

4.C/C++中内存开辟

下面就可以开始讲解像为什么需要free的问题,还有内存中有哪些区的这类问题了

1.内存的分区

我们的内存其实分了这么四个区:

栈区

堆区

代码段

静态区

栈区就是我们正常使用的时候向内存申请的空间,就是从栈区来的,这部分空间当我们程序结束的时候,系统就会自动地将这部分空间回收

堆区就是我们用开辟动态内存的时候就是从这里获得的空间,这部分空间有一个特点就是需要我们手动的对这部分空间手动释放,系统部会自动帮我们做这件事(在C/C++中是这样的,python,java这种系统会自动帮我们完成这件事情),所以,这也就决定了C/C++这种编程语言会多与底层打交道,还有这就是为什么我们需要free掉空间,特别是在一些函数中,因为函数结束后,这部分空间就直接还给内存了,这时候里面用到的动态空间就再也找不到了,这也就导致这部分空间永远得不到释放了

代码段这个也就是我们写的函数存放的地方了

静态区这个就是存放全局变量,静态数据(像常量这种,以及被static修饰的变量就是所谓的静态数据)的地方

2.栈区和堆区在内存中的使用方式

"栈区向下生长,堆区向上生长"

这句话想必应该都听过很多次了,这也就是我们要讲的内容了

栈区在内存中的使用是向申请的空间,这个我们可以看看

int main()
{
    int a = 0;
    int b = 0;
    int c = 0;
    return 0;
}

我们通过调试看到内存是这样的

屏幕截图 2024-11-18 150553.png

这里我们就能看见空间是向下生长的

这时候我们也能对堆区进行测试一下

int main()
{
    int* arr1 = (int*)malloc(sizeof(int));
	int* arr2 = (int*)malloc(sizeof(int));
	int* arr3 = (int*)malloc(sizeof(int));
	free(arr1);
	arr1 = NULL;
	free(arr2);
	arr2 = NULL;
	free(arr3);
	arr3 = NULL;
	return 0;
}

内存结果:

屏幕截图 2024-11-18 152045.png 当然这种向上生长不是绝对的,因为实际上,当下面的的堆被释放后,下一次我们再进行开辟空间的时候,系统会优先考虑下面的空间,而不是上面的空间,但是这也很形象地将堆展示出来了,也就是一点一点堆上去的,也就是从下至上的感觉

5.柔性数组

讲了这么多,下面我们就来讲讲柔性数组,柔性数组其实算是动态内存在结构体中的一个应用,下面我们就来见识一下什么是柔性数组吧!

1.构成

柔性数组毕竟是结构体的一种形式,那么自然而然地肯定有结构体,然后又说是数组,所以显然是有数组的,我们假设这个数组叫int arr[]

struct 结构体名

{

​ 成员1;

​ 成员2;

​ ......

​ int arr[];//这个就是顺序表

};

这就是柔性数组的一个基本形态,我们看这个数组也许有些疑问,这个数组不需要定义大小吗?这里就跟数组不一样了,数组就有明确的要求容量大小了,而柔性数组叫柔性就是体现在其可以按需所用

2.特殊性

柔性数组除了上面的可以不需要定义其大小外还有两点特殊性:

  1. 必须放在结构体成员的最后一位
  2. 在计算该结构体的大小时不会被记入计算中

对于这两点,我们就一起来看看吧!

特殊性一:

这个特殊性我们直接举一个反例就知道了

struct S
{
    int val1;
    int arr[];
    int val2;
};

int main()
{
    //......
    return 0;
}

此时编译器就直接报错了:

屏幕截图 2024-11-18 181107.png

那么相反我们将这个数组放到最后会发生同样的问题吗?

那么下面我们就来试试:

struct S
{
    int val1;
    int val2;
    int arr[];
};

int main()
{
    //......
    return 0;
}

屏幕截图 2024-11-18 181321.png

此时编译器上面就不会再出现刚刚的问题了,那么有人会问:说不定是跟位置有关系,说不定放到第一位也可以?

struct S
{
    int arr[];
    int val1;
    int val2;   
};

int main()
{
    //......
    return 0;
}

此时编译器依然出现了刚刚的错误:

屏幕截图 2024-11-18 181512.png

这也就说明了这个柔性数组只能放在结构体的最后一位

特殊性二:

这个很好证明,下面用一个例子就知道了

struct S
{
    int val;
    int arr[];
};

int main()
{
    printf("%d\n",sizeof(struct S));
    return 0;
}

输出结果:

屏幕截图 2024-11-18 181825.png

这里也就能知道这里的特殊性二了

3.使用

既然都说了其是动态内存在结构体中的使用,那么一定要用到动态内存了,那下面就一起来看看吧

typedef struct S
{
    int val;
    int arr[];
}S;

int main()
{
    
    S* s = (S*)malloc(sizeof(int) + sizeof(int) * 10);
    for (int i = 0; i < 10; i++)
    {
        s->arr[i] = i;
    }
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", s->arr[i]);
    }
    printf("\n");
    //扩容
    s = (S*)realloc(s,sizeof(int) + sizeof(int) * 20);
    //防止扩容失败
    if (s == NULL)
    {
        printf("扩容失败\n");
        return 1;
    }
    for (int i = 10; i < 20; i++)
    {
        s->arr[i] = i;
    }
    for (int i = 10; i < 20; i++)
    {
        printf("%d ", s->arr[i]);
    }
    printf("\n");
    free(s);
    s= NULL;
    return 0;
}

运行结果:

屏幕截图 2024-11-18 182913.png

当然,这时候有人可能就会想说顺序表了,也就是这里不用柔性数组,而是用一个指针,那我们就还是拿这个例子进行对比一下

typedef struct S
{
    int val;
    int* arr;
}S;

int main()
{
    
    S* s = (S*)malloc(sizeof(S));
    //注意这里不要写像s,我们得先知道这是先运行右边
    //然后才将结果赋值给s
    s->arr=(int*)malloc(sizeof(int)*10);
    for (int i = 0; i < 10; i++)
    {
        s->arr[i] = i;
    }
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", s->arr[i]);
    }
    printf("\n");
    //扩容
    s->arr = (int*)malloc(sizeof(int)*20);
    //防止扩容失败
    if (s->arr == NULL)
    {
        printf("扩容失败\n");
        return 1;
    }
    for (int i = 10; i < 20; i++)
    {
        s->arr[i] = i;
    }
    for (int i = 10; i < 20; i++)
    {
        printf("%d ", s->arr[i]);
    }
    printf("\n");
    free(s->arr);
    s->arr=NULL;
    free(s);
    s= NULL;
    //释放两次空间
    return 0;
}

运行结果依然是那样的:

屏幕截图 2024-11-18 183535.png

这里我们就能大概看见顺序表的雏形了

那么将这两者放在一起对比,我们就很容易知道

  1. 下面的这种指针需要释放两次空间,而柔性数组只需要释放一次空间,这也就是柔性数组的一个优势,因为这在一定程度上降低了内存泄露的风险

  2. 还有一个就是我们知道malloc太多了会导致内存碎片增多,这会导致我们的空间浪费,这也是柔性数组的另一大优势

这样我们就能明白了柔性数组了

6.总结

看完这篇博客,想必大家已经对动态内存管理有了一个最基本的理解了吧!那么希望我们在下一站的会面!