写点什么

【C 语言内功修炼】动态内存管理的奥秘

作者:Albert Edison
  • 2022 年 10 月 08 日
    四川
  • 本文字数:6135 字

    阅读完需:约 20 分钟

【C语言内功修炼】动态内存管理的奥秘

1. 为什么存在动态内存分配

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


char ch = 'a'; // 在栈空间上开辟1个字节int arr[10] = { 0 }; // 在栈空间上开辟40个字节的连续空间
复制代码


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


(1)空间开辟大小是固定的。


(2)数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配。


这时候就会出现一些问题:有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。


这时候就只能试试动态存开辟了。


那么动态内存分配所涉及到的 内存 在哪儿去开辟呢?


回顾一下之前学过的知识,内存由上到下分为:栈区堆区静态区


栈区: 存放局部变量、形式参数等临时变量;


堆区:用于动态内存分配,在堆区上申请的空间可以变大变小


静态区:存放静态变量、全局变量等;


2. 动态内存函数的介绍

动态内存函数有:malloccallocreallocfree,下面将依次讲解;

🍑 malloc

C 语言提供了一个动态内存开辟的函数:


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


malloc:这个函数向内存申请一块 连续 可用的空间,并返回指向这块空间的 指针


注意:


(1)如果开辟成功,则返回一个指向开辟好空间的指针。


(2)如果开辟失败,则返回一个 NULL 指针,因此 malloc 的返回值一定要做检查。


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


(4)如果参数 size0malloc 的行为是标准是未定义的,取决于编译器。


假设我们要开辟 10 个整型的空间:


📝 代码示例


int main(){  // 因为malloc函数的返回值为void*,所以需要强制类型转换为对应类型。  int* ptr = (int*)malloc(10 * sizeof(int));
if (ptr == NULL) { printf("内存开辟失败\n"); return; } // 开辟成功就使用 for (int i = 0; i < 10; ++i) { *(ptr + i) = i; } // 打印 for (int i = 0; i < 10; ++i) { printf("%d ", *(ptr + i)); }
// 使用完以后再释放 free(ptr); //释放ptr所指向的动态内存 ptr = NULL; // 当释放后,ptr就是野指针了,所以需要把ptr置为NULL return 0;}
复制代码


🌟 运行结果


🍑 free

C 语言提供了另外一个函数 free,专门是用来做动态内存的释放和回收的,函数原型如下:


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


其实上面的 malloc 代码中,就已经用到了 free,它是用来释放动态开辟的内存。


注意:


(1)如果参数 ptr 指向的空间不是动态开辟的,那 free 函数的行为是未定义的。


(2)如果参数 ptrNULL 指针,则函数什么事都不做。


(3)mallocfree 都声明在 stdlib.h 头文件中。

🍑 calloc

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


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


calloc 函数的功能:把 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为 0


说白了也是开辟指定大小的内存空间,如果开辟成功就返回该空间的首地址,如果开辟失败就返回一个 NULL


与函数 malloc 的区别:calloc 会在返回地址之前把申请的空间的每个字节初始化为全 0。


📝 代码示例


#include <stdio.h>#include <stdlib.h>
int main(){ int* ptr = (int*)calloc(10, sizeof(int));
if (ptr == NULL) { printf("内存开辟失败\n"); return; }
// 不初始化直接打印 for (int i = 0; i < 10; ++i) { printf("%d ", *(ptr + i)); }
// 使用完以后再释放 free(ptr); //释放ptr所指向的动态内存 ptr = NULL; // 当释放后,ptr就是野指针了,所以需要把ptr置为NULL return 0;}
复制代码


🌟 运行结果



那么 callocmalloc 那个效率更高呢?


malloc 函数不会进行初始化,直接返回地址,而 calloc 函数是初始化完以后才返回地址,显而易见,肯定是 malloc 函数效率更高。


但是以后的使用过程中也是看情况而且,想初始化就用 calloc,不想初始化就用 malloc

🍑 realloc

有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的时候内存,我们一定会对内存的大小做灵活的调整。


realloc 函数就可以做到对动态开辟内存大小的调整,它的出现让动态内存管理更加灵活。


函数原型如下:


void* realloc (void* ptr, size_t size);
复制代码


(1)ptr 是要调整的内存地址(如果 ptr 是个空指针的话,它的功能就和 malloc 一样);


(2)size 调整之后新大小;


(3)返回值为调整之后的内存起始位置。


(4)realloc 这个函数在调整原内存空间大小的基础上,还会将原来内存中的数据移动到 的空间。


📝 代码示例


#include <stdio.h>#include <stdlib.h>
int main(){ int* ptr = (int*)calloc(10, sizeof(int));
if (ptr == NULL) { printf("内存开辟失败\n"); return; }
// 空间不够,需要增容 int* new_ptr = (int*)realloc(ptr, 80); //将空间扩展为80个字节大小 if (NULL != new_ptr) { ptr = new_ptr; }
//继续使用
free(ptr); ptr = NULL; return 0;}
复制代码


注意: realloc 函数调整动态内存大小的时候会有三种情况:


假如我们要将一个大小为 40 个字节的空间调整为 80 个字节。


情况一👇


realloc 函数直接在原空间后方进行扩展,并返回该内存空间首地址(即原来的首地址)。



就拿上面的代码可以调试看一下,此时 new_ptr 的地址就是 ptr 的地址,说明是直接在原空间后方进行扩展的。



情况二👇


realloc 函数会在堆区中重新找一块满足要求的内存空间,把原空间内的数据拷贝到新空间中,并主动将原空间内存释放(即还给操作系统),返回新内存空间的首地址。



此时,new_ptr 是一个新的地址,说明是把原空间内的数据拷贝到新空间中,然后返回新内存空间的首地址。



情况三👇


如果需扩展的空间后方没有足够的空间可供扩展,并且堆区中也没有符合需要开辟的内存大小的空间。 结果就是开辟内存失败,返回一个 NULL。


3. 常见的动态内存错误

在动态内存操作的过程中,会遇到一些动态内存开辟的错误,我们来深入剖析一下

🍑 对 NULL 指针的解引用操作

📝 代码示例


#include <stdio.h>#include <stdlib.h>#include <limits.h>
int main(){ int* p = (int*)malloc(INT_MAX);
int i = 0; for (i = 0; i < 10; ++i) { *(p + i) = i; } return 0;}
复制代码


⚙ 调试结果



由于 malloc 开辟空间过大,当开辟不出来这么大空间的时候,就会返回空指针;


而一旦返回空指针,那么 p 里面就是空指针,不管对 p 进行怎样的操作,都是无法访问的;


那么如何修改呢?可以加一个判断,检测是否为空指针


#include <stdio.h>#include <stdlib.h>#include <limits.h>
int main(){ int* p = (int*)malloc(INT_MAX); // 判断p是否为空指针 if (NULL == p) { return 0; }
int i = 0; for (i = 0; i < 10; ++i) { *(p + i) = i; } return 0;}
复制代码

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

假设我们向内存申请了 10 个字符的空间,然后访问 11 个字节的空间


📝 代码示例


#include <stdio.h>#include <stdlib.h>#include <string.h>#include <errno.h>
int main(){ char* p = (char*)malloc(10 * sizeof(char)); if (p == NULL) { printf("%s\n", strerror(errno)); return 0; }
// 使用 int i = 0; for (i = 0; i <= 10; i++) { *(p + i) = 'a' + i; }
//释放 free(p); p = NULL; return 0;}
复制代码


⚙ 调试结果



需要时刻注意,不能访问未申请的动态内存空间


比如你向动态内存申请了 10 个字节,那就绝不能访问第 11 个字节。

🍑 对非动态开辟内存使用 free 释放

对于动态内存开辟的空间用一个 指针 来维护;


那如果我们不使用动态内存开辟的空间呢?


📝 代码示例


int main(){  int a = 10;  int* pa = &a;
// 使用 // ......
// 释放 free(pa); pa = NULL;
return 0;}
复制代码


a 是函数的局部变量,是分配在 栈区 上的,栈区 上的空间怎么能够进行 free 呢?


⚙ 调试结果



栈区 上的空间,是进入作用域以后就创建,出作用域以后就销毁,它是按照代码的执行逻辑来走的,不需要主动释放。


注意:free 函数只能释放动态开辟的内存空间

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

假设我们要开辟 10 个整型的空间,把前五个元素初始化为:1、2、3、4、5


📝 代码示例


int main(){  int* p = (int*)malloc(40);
if (NULL == p) { printf("%s\n", strerror(errno)); return 0; }
// 使用内存 int i = 0; for (i = 0; i < 5; ++i) { // *(p + i) = i + 1; *p = i + 1; p++; } // 释放 free(p); p = NULL;}
复制代码


⚙ 调试结果



我们可以看到 for 循环里面使用了另外一种赋值方法,这种方法当然是可以赋值成功的,但是还是会有一点小问题;



初始化完以后,程序直接进入到 free(p),那么就是相当于释放了 p 的一部分空间,这种情况是及其危险的,会导致 内存泄漏 的问题;


所以我们应该记录一下 p 的起始位置。

🍑 对同一块动态内存多次释放

假设把动态内存开辟的空间使用以后,需要 释放,但是忘记把 p 置为空指针,接着不小心又对它进行了一次 释放


📝 代码示例


int main(){  int* p = (int*)malloc(40);
if (NULL == p) { printf("%s\n", strerror(errno)); return 0; }
// 使用内存 int i = 0; for (i = 0; i < 5; ++i) { *(p + i) = i + 1; } // 释放 free(p); // 忘记前面释放过了,再释放一次 free(p);
return 0;}
复制代码


⚙ 调试结果



这种情况也是及其危险的,那我们应该怎么办呢?


(1)每次 free 完以后,把 p 置为 空指针


(2)不要对同一块二空间进行多次释放;

🍑 动态开辟内存忘记释放

📝 代码示例


void test(){  int* p = (int*)malloc(100);  if (NULL == p) {    return 0;  }    // 使用.....}
int main(){ test();
return 0;}
复制代码


我们在 test 函数内部,使用动态内存开辟的空间, 当在 主函数 调用完成以后,如果忘记释放,那么在 test 函数外部想释放也不不可能,此时就会出现 内存泄漏 的问题。


当然如果把 p 返回给 main 函数,也是可以释放的👇


void test(){  int* p = (int*)malloc(100);  if (NULL == p) {    return 0;  }    // 使用.....
// 返回 return p;}
int main(){ // 接收 int* ptr = test();
// 释放 free(p);
return 0;}
复制代码


如果你既不返回,也不释放,那么就 完蛋🤣


切记:动态开辟的空间一定要释放,并且正确释放 。

4. 动态内存经典笔试题

这里有几道关于 动态内存开辟 的经典笔试题,大家可以分析这些代码的运行结果、以及代码有什么问题?

🍑 题目一

📝 代码示例


void GetMemory(char* p){  p = (char*)malloc(100);}
void Test(void){ char* str = NULL; GetMemory(str); strcpy(str, "hello world"); printf(str);}
int main(){ Test();
return 0;}
复制代码


🌟 分析


首先进入 mian 函数会直接调用 Test() 函数;


Test 函数内部:创建了一个 char* 的 str 变量,里面放了一个 空指针


然后调用 GetMemory() 函数,此时是传值,p 相当于是 str 的一份临时拷贝,所以当前 p 里面放的是 空指针,然后 malloc 函数在 堆区 申请了 100 字节的空间,假设这 100 空间的地址是 0x0012FF80,然后把这个 地址 放到 p 里面去,那么 p 就有能力指向这块儿空间;


此时出了 GetMemory() 函数以后,因为 p 是形参,并且是在函数内部定义的,是 char 的指针,也就是 4 字节,所以一旦出了函数以后,p 就销毁了,但是 malloc 开辟的 100 字节没有释放呀,还是存在的,那么这 100 字节的空间地址就找不到了;


因为 p 的改变不会影响 str,所以 str 还是个空指针,那么 strcpy(str, "hello world") 就会非法访问内存,程序就会崩溃;


既然程序到 strcpy 就崩溃了,那么下面的 printf 肯定就会打印失败了


注意: printf(str) 是正确的,等同于 printf("hello world")


🍑 题目二

📝 代码示例


char* GetMemory(void){  char p[] = "hello world";  return p;}
void Test(void){ char* str = NULL; str = GetMemory(); printf(str);}
int main(){ Test();
return 0;}
复制代码


🌟 运行结果



🌟 分析


首先创建了一个 str 变量,里面是个 空指针,然后让 str 接收 GetMemory() 函数的返回值,下一步调用 GetMemory() 函数;


进入 GetMemory() 函数内部以后,创建了一个局部临时数组 p ,里面存放了 hello world,假设 p 的地址是 0x12FF40,然后返回 p 的地址;


回到 Test 函数以后,str 里面放的就是 0x12FF40,那也就意味着,当 p 返回去以后,str 指针就指向 0x12FF40 这块空间,那这种做法到底行不行呢?肯定是不行的啦!


虽然把 p 的地址返回去了,然后存到了 str 里面,但是出了 GetMemory() 函数以后,p 指向的这块空间就被回收了呀,因为 p 是创建的局部临时数组,进入 GetMemory() 函数,我们就有使用权,出了 GetMemory() 函数,就还给操作系统了;


所以一旦 p 返回去了,0x12FF40 这块空间就不属于我们了,那么 str 就是个 野指针,所以打印的结果就是 随机值


这种问题统称为:返回栈空间地址的问题!

🍑 题目三

📝 代码示例


void GetMemory(char** p, int num){  *p = (char*)malloc(num);}
void Test(void){ char* str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str);}
int main(){ Test();
return 0;}
复制代码


🌟 运行结果



这段代码运行起来是没问题的,唯一的问题就是没有 释放


打印完 str 以后,应该使用 freestr 释放,然后再把 str 置为 空指针

🍑 题目四

📝 代码示例


void Test(void){  char* str = (char*)malloc(100);  strcpy(str, "hello");  free(str);  if (str != NULL)  {    strcpy(str, "world");    printf(str);  }}
int main(){ Test();
return 0;}
复制代码


🌟 运行结果



可以看到打印出来也是可以的,唯一的问题就是使用 free 释放掉 str 以后,没有置为 空指针


因为前面对 str 指向的空间已经释放,不能再使用,这时候就形成非法访问内存!



所以 free 释放完以后,应该置为 空指针


5. C/C++ 程序的内存开辟

先来看张静经典的 内存区域分布图👇



从图上可以知道,C/C++ 程序内存分配的几个区域如下:


(1)栈区stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。


栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。


(2)堆区heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由 OS(操作系统) 回收 ;分配方式类似于链表。


(3) 数据段静态区):(static)存放全局变量、静态数据;程序结束后由系统释放。


(4)代码段:存放函数体(类成员函数和全局函数)的二进制代码。


那么回想一下之前学的 static 关键字修饰局部变量,配合上面的图,就很容易理解了:


实际上普通的局部变量是在栈区分配空间的,栈区的特点是在上面创建的变量出了作用域就销毁;


但是被 static 修饰的变量存放在数据段(静态区),数据段的特点是在上面创建的变量,直到程序结束才销毁;


所以生命周期变长。

发布于: 2022 年 10 月 08 日阅读数: 18
用户头像

Albert Edison

关注

目前在某大厂担任后端开发,欢迎交流🤝 2022.03.08 加入

🏅️平台:InfoQ 签约作者、阿里云 专家博主、CSDN 优质创作者 🛫领域:专注于C语言、数据结构与算法、C++、Linux、MySQL、云原生的研究 ✨成就:2021年CSDN博客新星Top9,算法领域优质创作者,全网累计粉丝4W+

评论

发布
暂无评论
【C语言内功修炼】动态内存管理的奥秘_C语言_Albert Edison_InfoQ写作社区