【C语言】《动态内存管理全解|malloc/calloc/realloc/free + 经典笔试题剖析》
一.动态内存的分配
1.概念:
动态内存分配的核心在于“按需而动”:程序不再需要在运行前就划定死内存大小,而是在执行过程中,根据实时数据量向系统灵活借调内存,用多少就申请多少,用完即刻释放。
2.有动态内存分配的原因
我们已经掌握的内存开辟⽅式有:
int val = 20;//在栈空间上开辟四个字节 char arr[10] = {0};//在栈空间上开辟10个字节的连续空间但是上述的开辟空间的⽅式有两个特点:
- 空间开辟⼤⼩是固定的。
- 数组在申明的时候,必须指定数组的⻓度,数组空间⼀旦确定了⼤⼩不能调整
原因:但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间⼤⼩在程序运⾏的时候才能知道,那数组的编译时开辟空间的⽅式就不能满⾜了。C语⾔引⼊了动态内存开辟,让程序员⾃⼰可以申请和释放空间,就⽐较灵活了。
3.动态内存分布图
二.动态内存函数介绍
1.malloc 函数
cplusplus.com/reference/cstdlib/malloc/?kw=malloc
C语⾔提供了⼀个动态内存开辟的函数:
void* malloc (size_t size);这个函数向内存申请⼀块连续可⽤的空间,并返回指向这块空间的指针。
- 如果开辟成功,则返回⼀个指向开辟好空间的指针。
- 如果开辟失败,则返回⼀个 NULL 指针,因此malloc的返回值⼀定要做检查。
- 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使⽤的时候使⽤者⾃⼰来决定。
- 如果参数 size 为0,malloc的⾏为是标准是未定义的,取决于编译器。
实现方式:
方式一:通过指针偏移访问(推荐)
#include <stdlib.h> int main() { // 申请20个字节 - 存放5个整数 int* p = (int*)malloc(20); if (p == NULL) { perror("malloc"); return 1; } // 使用空间:通过指针 + 偏移量访问 int i = 0; for (i = 0; i < 5; i++) { *(p + i) = i + 1; // 等价于 p[i] = i + 1 } // 释放内存 free(p); // p 仍然是起始地址 p = NULL; return 0; }这里的指向始终保持不变,始终指向内存块的起始位置,释放时直接传递
p,安全可靠,代码清晰,符合大多数人的习惯
说明
*(p + i)与p[i]完全等价,都是通过起始地址加偏移量来定位元素,这种写法不会改变指针变量p本身的值
方式二:通过指针自增访问(错误)
#include <stdlib.h> int main() { // 申请20个字节 - 存放5个整数 int* p = (int*)malloc(20); if (p == NULL) { perror("malloc"); return 1; } // 使用空间:通过指针自增访问 int i = 0; for (i = 0; i < 5; i++) { *p = i + 1; p++; // 每次循环 p 都向后移动 } // 错误:此时 p 已经指向内存块末尾之后,不是起始地址了! free(p); // 程序崩溃 / 未定义行为 p = NULL; return 0; }错误原因
循环中执行了
p++,每循环一次p就向后移动一个int的大小,循环结束后,p指向了内存块最后一个元素之后的位置,free(p)接收的已经不是malloc返回的原始起始地址了,这会导致程序崩溃或内存释放失败(未定义行为)
2.free函数
cplusplus.com/reference/cstdlib/free/
C语⾔提供了另外⼀个函数free,专⻔是⽤来做动态内存的释放和回收的,函数原型如下:
void free (void* ptr);free函数⽤来释放动态开辟的内存。
如果参数 ptr 指向的空间不是动态开辟的,那free函数的⾏为是未定义的。
如果参数 ptr 是NULL指针,则函数什么事都不做。补充注意:
- 使用完之后一定要记得使用 free 函数释放所开辟的内存空间。
- 使用指针指向动态开辟的内存,使用完并 free 之后一定要记得将其置为空指针。
代码示意如下:
#include <stdio.h> #include <stdlib.h> int main() { int arr[10]; // 静态开辟10个整型空间(栈上) int* p = (int*)malloc(10 * sizeof(int)); // 动态开辟10个int大小的空间(堆上) if (p == NULL) // 判断p指针是否为空 { perror("main"); // 输出错误信息 return 1; // 异常退出 } // 使用空间 for (int i = 0; i < 10; i++) { *(p + i) = i; // 通过指针偏移赋值 } for (int i = 0; i < 10; i++) { printf("%d ", p[i]); // 通过数组下标访问,与*(p+i)等价 } // 回收空间 free(p); // 释放p所指向的动态内存 p = NULL; // 必须手动置为空指针 return 0; }问题解答
【问题一】为什么 malloc 前要加上强制类型转换?
int* p = (int*)malloc(10 * sizeof(int));根本原因有两个:
保持类型一致
malloc返回的是void*类型的指针,可以装任何类型的数据。而我们定义的p是int*类型的,专门用来指向整数数据。把通用类型转换成具体类型,让指针和它指向的数据类型相匹配。
兼容不同编译器
这一点更重要。在C语言中,
void*可以自动转换成其他指针类型,不写强转也能编译通过。但在C++中,这种自动转换是不允许的,编译器会报错。为了让代码在C和C++环境下都能顺利编译,加上强转是最稳妥的做法。虽然C语言不要求,但建议加上。这样写出来的代码可移植性更好,也明确告诉读代码的人:这块内存我打算当作int数组来用。
【问题二】为什么 free 后还要把 p 置成 NULL?
free(p); p = NULL;用个生活中的例子解释一下
free只是把房子拆了,但门牌号还在。p这个指针变量里存的地址并没有变,它依然指向那块已经"不属于你"的内存。
free(p); // p 现在是个野指针 // 情况一:再次使用 *p = 10; // 访问已经归还的内存,程序随时可能崩溃 // 情况二:重复释放 free(p); // 双重释放,属于严重的非法操作 // 情况三:误判 if (p != NULL) // p 确实不为空 { *p = 10; // 但指向的内存已经无效,该不该执行呢? }这就像你把房子退了,但手里还攥着旧钥匙。你以为钥匙还在就能开门,但实际上那间房已经租给别人了——你硬要开门,轻则打不开(程序没反应),重则被抓现行(程序崩溃)。
把 p 置为 NULL 之后:
free(p); p = NULL; // 门牌号作废 if (p != NULL) // false,不会进入 { // 安全跳过 } free(p); // 释放空指针是安全的,C标准规定什么都不做free释放的是内存,不是指针变量。指针变量需要你亲手清理。
【问题三】为什么 malloc 后要检查 p 是不是 NULL?
if (p == NULL) { perror("main"); return 1; }因为系统不是每次都有足够的内存给你用。当内存申请失败时,malloc会返回NULL。如果你不检查就直接用:
int* p = malloc(10000000000); // 申请超大内存,大概率失败 *p = 10; // 对 NULL 解引用 --> 程序直接崩溃(段错误)3.calloc函数
https://cplusplus.com/reference/cstdlib/calloc/
C语⾔还提供了⼀个函数叫 calloc , calloc 函数也⽤来动态内存分配。原型如下:
void* calloc (size_t num, size_t size);
- 函数的功能是为 num 个⼤⼩为 size 的元素开辟⼀块空间,并且把空间的每个字节初始化为0。
- 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
示例如下:
#include <stdio.h> #include <stdlib.h> // 提供 calloc 和 free 函数 int main() { // 1. 动态开辟 10 个 int 大小的空间(共 40 字节),并全部初始化为 0 int* p = (int*)calloc(10, sizeof(int)); // 2. 检查内存是否分配成功 if (p == NULL) { return 1; // 分配失败,程序异常退出 } // 3. 打印这 10 个整数的值 for (int i = 0; i < 10; i++) { printf("%d ", *(p + i)); // 全部打印 0 } // 4. 释放内存,归还给系统 free(p); // 5. 指针置空,防止野指针 p = NULL; return 0; }所以如果我们对申请的内存空间的内容要求初始化,那么可以很⽅便的使⽤calloc函数来完成任务。
4.realloc 函数
cplusplus.com/reference/cstdlib/realloc/
- realloc函数的出现让动态内存管理更加灵活。
- 有时会我们发现过去申请的空间太⼩了,有时候我们⼜会觉得申请的空间过⼤了,那为了合理的使⽤内存,我们⼀定会对内存的⼤⼩做灵活的调整。那 realloc 函数就可以做到对动态开辟内存⼤⼩的调整。
函数原型如下:
void* realloc (void* ptr, size_t size);
- ptr 是要调整的内存地址
- size 调整之后新⼤⼩
- 返回值为调整之后的内存起始位置。
- 这个函数调整原内存空间⼤⼩的基础上,还会将原来内存中的数据移动到 新 的空间。
realloc在调整内存空间的是存在两种情况:
realloc会尽量在原位置帮你扩大空间,如果原地不够,就找个新地方把数据搬过去,并自动释放旧内存。
- 情况一:后面够用,地址不变,原地扩容。
- 情况二:后面不够用,地址变了,数据搬家。
情况1
当是情况1 的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发⽣变化。
情况2
当是情况2 的时候,原有空间之后没有⾜够多的空间时,扩展的⽅法是:在堆空间上另找⼀个合适⼤⼩的连续空间来使⽤。这样函数返回的是⼀个新的内存地址。
代码示意:
#include <stdio.h> #include <stdlib.h> int main() { // 1. 先申请一块内存 int* ptr = (int*)malloc(100); if (ptr == NULL) { perror("malloc"); return 1; } // 2. 使用内存(假设存了一些数据) for (int i = 0; i < 25; i++) // 100字节可以存25个int { ptr[i] = i + 1; } // 3. 扩容到 1000 字节 int* tmp = (int*)realloc(ptr, 1000); if (tmp == NULL) { // 扩容失败,旧内存还在 perror("realloc"); free(ptr); // 释放旧内存,避免泄漏 ptr = NULL; return 1; } // 4. 扩容成功,更新指针 ptr = tmp; // 5. 继续使用(原来的数据还在) for (int i = 25; i < 250; i++) // 1000字节可以存250个int { ptr[i] = i + 1; } // 6. 释放 free(ptr); ptr = NULL; return 0; }三.常⻅的动态内存的错误
1. 对NULL指针的解引⽤操作
// 错误代码 void test() { int* p = (int*)malloc(INT_MAX / 4); *p = 20; // 错误:没有检查 p 是否为 NULL free(p); }// 纠正后的代码 void test() { int* p = (int*)malloc(INT_MAX / 4); if (p == NULL) // 添加判断 { return; // 分配失败就返回 } *p = 20; // 安全:此时 p 一定不为 NULL free(p); }错因:
malloc可能分配失败返回 NULL,未做检查就直接解引用,导致对空指针操作,程序崩溃。纠正:使用
if (p == NULL)判断,只有指针非空时才进行解引用操作。
2.对动态开辟空间的越界访问
// 错误代码 void test() { int i = 0; int* p = (int*)malloc(10 * sizeof(int)); // 申请了10个int,共40字节 if (NULL == p) { exit(EXIT_FAILURE); } for (i = 0; i <= 10; i++) // 错误:i <= 10,循环了11次 { *(p + i) = i; // 当 i = 10 时,访问了第11个元素,越界 } free(p); }// 纠正后的代码 void test() { int i = 0; int* p = (int*)malloc(10 * sizeof(int)); // 申请了10个int if (NULL == p) { exit(EXIT_FAILURE); } for (i = 0; i < 10; i++) // 纠正:i < 10,循环10次 { *(p + i) = i; // i 的范围是 0~9,刚好访问10个元素 } free(p); }错因:
malloc申请了 10 个int的空间,有效索引是 0~9,但循环条件i <= 10导致循环了 11 次,当i = 10时访问了p[10],超出了申请的内存范围,造成越界访问。纠正:将循环条件改为
i < 10,确保只访问 0~9 这 10 个有效元素。
3.对⾮动态开辟内存使⽤free释放
// 错误代码 void test() { int a = 10; int *p = &a; free(p); // 错误:p 指向的是栈上的变量 a,不是堆上的动态内存 }// 纠正后的代码 void test() { int a = 10; int *p = &a; // free(p); // 删除:不能对栈上的变量使用 free // 不需要释放,函数结束自动销毁 }错因:
free只能释放通过malloc、calloc或realloc在堆上动态分配的内存。代码中p指向的是栈上的局部变量a,它的内存由系统自动管理,函数结束后自动回收,不需要手动释放。对非动态内存使用free会导致未定义行为,程序可能崩溃。纠正:删除
free(p),栈上的变量不需要也不能手动释放。
4.使⽤free释放⼀块动态开辟内存的⼀部分
// 错误代码 void test() { int *p = (int *)malloc(100); p++; // 错误:改变了 p 的指向,不再指向起始地址 free(p); // free 的参数不是起始地址 }// 纠正后的代码 void test() { int *p = (int *)malloc(100); int *start = p; // 保存起始地址 p++; // 可以移动 p 去访问内存 free(start); // 释放时传入起始地址 start = NULL; p = NULL; }错因:
free必须接收malloc/calloc/realloc返回的原始起始地址。代码中p++后,p偏移了 4 个字节(一个int大小),不再指向内存块的起始位置。此时调用free(p),系统无法正确释放内存,导致未定义行为,程序可能崩溃。纠正:在移动指针之前,用另一个指针(如
start)保存起始地址,释放时传入该备份指针。
5.对同⼀块动态内存多次释放
// 错误代码 void test() { int *p = (int *)malloc(100); free(p); free(p); // 错误:对同一块内存重复释放 }// 纠正后的代码 void test() { int *p = (int *)malloc(100); free(p); p = NULL; // 释放后立即置空 // free(p); // 删除:不再重复释放 // 如果再次执行 free(p),因为 p 已经是 NULL,free(NULL) 是安全的 // free(p); // 安全,但没必要 }错因:第一次
free(p)已经将内存归还给系统,第二次再次调用free(p)是对同一块已释放内存的重复释放,属于未定义行为,程序会崩溃。纠正:
free后立即将指针置为NULL,这样即使不小心再次调用free(p),free(NULL)不会做任何操作,是安全的。但最好的做法是避免重复释放。
6.动态开辟内存忘记释放(内存泄漏)
// 错误代码 void test() { int *p = (int *)malloc(100); if (NULL != p) { *p = 20; } // 错误:没有 free(p),内存泄漏 } int main() { test(); while(1); // 程序一直运行,泄漏的内存一直被占用 }// 纠正后的代码 void test() { int *p = (int *)malloc(100); if (NULL != p) { *p = 20; } free(p); // 释放内存 p = NULL; } int main() { test(); while(1); }错因:函数
test中使用malloc在堆上申请了 100 字节,但函数结束时没有调用free释放,导致这块内存既没有被使用也无法被回收,程序持续运行时会一直占用内存,造成内存泄漏。纠正:在函数结束前加上
free(p)释放内存,并将指针置NULL。内存泄漏的危害:长期运行的程序(如服务器)如果持续泄漏,最终会耗尽系统内存,导致程序崩溃或系统卡死。
注意:
忘记释放不再使⽤的动态开辟的空间会造成内存泄漏。切记:动态开辟的空间⼀定要释放,并且正确释放
四. 动态内存经典笔试题分析
题⽬1:
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; }请问运⾏Test 函数会有什么样的结果?
GetMemory(str)传参时,是把str的值(即NULL)拷贝了一份给p。在函数内部p = malloc(100)确实申请了一块内存,但地址是赋给了p,外面的str还是NULL。函数结束,
p销毁,申请的内存没人知道地址(内存泄漏),而外面的str依然是NULL。接着
strcpy(str, "hello world")相当于往地址0写数据,程序崩溃。
解法:
写法一:用二级指针
void GetMemory(char** p) { *p = (char*)malloc(100); } void Test(void) { char* str = NULL; GetMemory(&str); // 传 str 自己的地址 if (str != NULL) { strcpy(str, "hello world"); printf("%s\n", str); free(str); str = NULL; } }把str自己的地址传给函数,函数通过这个地址直接修改外面的str。
void GetMemory(char** p) // 接收的是 str 的地址 { *p = (char*)malloc(100); // 通过解引用修改外面的 str } // 调用时传 &str GetMemory(&str);写法二:用返回值
char* GetMemory(void) { char* p = (char*)malloc(100); return p; } void Test(void) { char* str = NULL; str = GetMemory(); if (str != NULL) { strcpy(str, "hello world"); printf("%s\n", str); free(str); str = NULL; } }不传参了,函数直接返回申请到的地址,外面用变量接着。
char* GetMemory(void) { char* p = (char*)malloc(100); return p; // 返回地址 } // 调用时接收返回值 str = GetMemory();想在函数内部给外面的指针分配内存,必须传二级指针,或者用返回值。直接用一级指针传进去,外面拿不到地址
题目2:
char *GetMemory(void) { char p[] = "hello world"; return p; } void Test(void) { char *str = NULL; str = GetMemory(); printf(str); } int main() { Test(); return 0; }请问运⾏Test 函数会有什么样的结果?
结果:程序崩溃,问题出在
p是函数GetMemory内部的局部数组,它是在栈上分配的。函数执行结束后,栈帧被回收,p占用的内存会被系统重新使用。
return p返回的是这块已经失效的内存地址,外面的str指向了一块已经被释放的栈空间。再去printf打印它,结果是不确定的——可能打印乱码,可能打印空,也可能直接崩溃。这跟前面
malloc分配堆内存不同,堆内存需要手动free,但栈内存是函数结束自动销毁的,不能把栈空间的地址返回给外部使用。
解法一:改成malloc
char* p = (char*)malloc(20); strcpy(p, "hello world"); return p;
char p[]换成malloc申请的内存。堆上的内存不会随函数结束而销毁,地址有效。用完要free。
解法二:加static
static char p[] = "hello world"; return p;只加了
static。p从栈上搬到静态区,生命周期延长到程序结束。不用free。
题目3:
void GetMemory(char** p, int num) { *p = (char*)malloc(num); } void Test(void) { char* str = NULL; GetMemory(&str, 100); strcpy(str, "hello"); printf(str); }请问运⾏Test 函数会有什么样的结果?
有语法错误,能正常运行,输出
hello。但有一个致命隐患:没有检查malloc的返回值。如果
malloc(100)分配失败,返回NULL,那么*p = NULL,外面的str就是NULL。接着strcpy(str, "hello")试图往NULL地址拷贝数据,程序崩溃。运气好能跑,运气不好就崩。错在了,少了判空。
GetMemory内部申请完内存没有检查是否成功,Test里面拿到str直接使用,也没有检查。内存分配失败是概率事件(尤其内存不足时),不检查等于埋了个定时炸弹。
纠正:
void Test(void) { char* str = NULL; GetMemory(&str, 100); // 加了判空 if (str == NULL) { printf("内存分配失败\n"); return; } strcpy(str, "hello"); printf("%s\n", str); // 加了释放 free(str); str = NULL; }题⽬4:
void Test(void) { char *str = (char *) malloc(100); strcpy(str, "hello"); free(str); if(str != NULL) { strcpy(str, "world"); printf(str); } }请问运⾏Test 函数会有什么样的结果?
free(str)释放了内存,但str本身的值没有变,它仍然指向那块已经归还给系统的地址。
if (str != NULL)这个判断完全无效,因为free不会把str变成NULL。判断通过后,strcpy(str, "world")往一块已经被释放的内存里写数据,属于非法操作,结果无法预测——可能正常,可能乱码,可能崩溃。导致野指针(悬空指针)。
五.总结C/C++中程序内存区域划分
C/C++程序内存分配的⼏个区域:
- 栈区(stack):在执⾏函数时,函数内局部变量的存储单元都可以在栈上创建,函数执⾏结束时这些存储单元⾃动被释放。栈内存分配运算内置于处理器的指令集中,效率很⾼,但是分配的内存容量有限。 栈区主要存放运⾏函数⽽分配的局部变量、函数参数、返回数据、返回地址等。
- 堆区(heap):⼀般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配⽅式类似于链表。
- 数据段(静态区):(static)存放全局变量、静态数据。程序结束后由系统释放。
- 代码段:存放函数体(类成员函数和全局函数)的⼆进制代码。
普通局部变量在栈区分配,出了作用域就销毁。被
static修饰的变量存放在静态区,程序启动时分配,程序结束时才销毁,所以生命周期变长了。