C语言内存精讲系列(二十九):C 语言堆区内存进阶与动态内存实战
上节课我们学了extern
关键字的跨文件共享用法,以及堆区内存的基础操作(malloc
/calloc
/free
)。这节课我们聚焦堆区的 “进阶技能”—— 用realloc
调整内存大小,同时通过 “动态存储学生信息”“修复内存泄漏” 两个实战案例,帮大家彻底掌握动态内存的规范用法,避开实际开发中的高频坑点。
一、realloc 函数:堆区内存的 “伸缩工具”
在实际开发中,我们经常会遇到 “堆内存不够用” 或 “堆内存用不完浪费” 的情况:比如用malloc
申请了 10 个学生的内存,后续需要新增 5 个学生;或者申请了 20 个学生的内存,实际只用到 8 个。这时候就需要realloc
函数 —— 它能 “重新分配” 已申请的堆内存大小,是堆区灵活性的核心体现。
1. realloc 的函数原型与核心逻辑
首先明确realloc
的基本用法,它需要包含<stdlib.h>
头文件:
void* realloc(void* ptr, size_t size);
- 参数 1:
void* ptr
:必须是之前用malloc
/calloc
/realloc
申请过的 “有效堆内存地址”(不能是栈地址,也不能是未初始化的野指针); - 参数 2:
size_t size
:重新调整后的总字节数(不是 “增加 / 减少的字节数”,比如原内存是5*sizeof(int)
,想扩到 10 个int
,size
要写10*sizeof(int)
); - 返回值:成功时返回 “新的堆内存起始地址”,失败时返回
NULL
(原内存不会丢失,需手动释放)。
2. realloc 的两个关键内存调整策略
realloc
调整内存时,会根据 “原内存后面是否有足够空闲空间”,采用两种不同策略,这直接影响程序效率和内存安全:
策略 1:“原地扩展 / 缩减”(高效)
如果原堆内存的后续连续内存是空闲的(没有被其他堆变量占用),realloc
会直接在原内存后面 “追加 / 缩减” 空间,此时新地址和原地址完全相同。
- 比如原内存是
5*sizeof(int)
(地址 0x1000~0x1013),后续 0x1014~0x1027 是空闲的,想扩到 10 个int
(40 字节),realloc
会直接把 0x1014~0x1027 分配进来,新地址还是 0x1000。
策略 2:“异地迁移”(低效但必要)
如果原内存后续有其他堆变量占用(空间不够),realloc
会做三件事:
- 在堆区找一块 “大小足够的新空闲内存”;
- 把原内存里的所有数据完整复制到新内存;
- 自动释放原内存(避免内存泄漏);
- 返回新内存的起始地址(此时新地址≠原地址)。
- 比如原内存 0x1000~0x1013 后面被 0x1014~0x1017 的堆变量占用,想扩到 10 个
int
,realloc
会找一块新地址(比如 0x2000~0x2027),复制原数据后释放 0x1000~0x1013,返回 0x2000。
3. realloc 的规范用法(必须避免的 3 个坑)
realloc
是动态内存中 “最容易用错” 的函数,必须严格遵循 “先判断返回值,再更新指针” 的流程,我们用 “扩展学生成绩数组” 的例子演示:
#include <stdio.h>
#include <stdlib.h>int main() {int student_num = 5; // 初始学生数// 1. 先用malloc申请5个int的堆内存(存储5个学生成绩)int* score_arr = (int*)malloc(student_num * sizeof(int));if (score_arr == NULL) { // 申请失败判断printf("初始内存申请失败!\n");exit(1);}// 2. 给初始5个学生赋值(100~96分)for (int i = 0; i < student_num; i++) {score_arr[i] = 100 - i;}printf("初始5个学生成绩:");for (int i = 0; i < student_num; i++) {printf("%d ", score_arr[i]); // 输出:100 99 98 97 96}printf("\n");// 3. 需求:新增3个学生,需扩到8个int的内存(总字节数=8*sizeof(int))int new_num = 8;// 关键:用临时指针接收realloc返回值,不要直接用原指针!int* temp_ptr = (int*)realloc(score_arr, new_num * sizeof(int));if (temp_ptr == NULL) { // 扩容失败判断printf("内存扩容失败!\n");free(score_arr); // 失败时原内存还在,必须手动释放,避免泄漏score_arr = NULL;exit(1);}// 扩容成功:更新原指针,指向新内存score_arr = temp_ptr;temp_ptr = NULL; // 临时指针置空,避免后续误操作// 4. 给新增的3个学生赋值(95~93分)for (int i = student_num; i < new_num; i++) {score_arr[i] = 100 - i;}printf("扩容后8个学生成绩:");for (int i = 0; i < new_num; i++) {printf("%d ", score_arr[i]); // 输出:100 99 98 97 96 95 94 93}printf("\n");// 5. 最终释放内存free(score_arr);score_arr = NULL;return 0;
}
必须记牢的 3 个规范点:
- 用临时指针接收返回值:如果直接写
score_arr = (int*)realloc(score_arr, ...)
,一旦realloc
返回NULL
,原score_arr
会变成NULL
,原内存地址丢失,导致 “内存泄漏”; - 扩容失败必须释放原内存:
realloc
返回NULL
时,原内存没有被释放,必须手动free(score_arr)
,否则内存会一直占用; - 缩减内存时数据安全:如果
size
比原内存小(比如从 8 个int
缩到 5 个),realloc
会保留前 5 个int
的数据,后面 3 个会被自动丢弃,无需手动处理。
二、实战案例 1:动态存储学生完整信息(结构体 + 堆区)
之前我们只存储了学生成绩(单一int
类型),实际开发中需要存储 “姓名 + 学号 + 成绩” 等多字段信息,这时候需要结合结构体和堆区 —— 用堆内存动态存储结构体变量,灵活应对 “不确定数量的学生”。
案例需求:
- 让用户输入学生人数;
- 动态申请对应数量的 “学生结构体” 堆内存;
- 输入每个学生的姓名、学号、成绩;
- 遍历输出所有学生信息;
- 释放堆内存,避免泄漏。
完整代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <string.h> // 用strcpy给字符串赋值// 1. 定义学生结构体(包含姓名、学号、成绩)
typedef struct Student {char name[20]; // 姓名(固定20字节,也可动态申请字符串,后续进阶)int id; // 学号int score; // 成绩
} Student;int main() {int student_num;Student* stu_arr = NULL; // 指向学生结构体数组的堆指针,初始置空// 2. 输入学生人数printf("请输入学生人数:");scanf("%d", &student_num);// 吸收scanf后的换行符,避免后续fgets读取姓名时出错getchar();// 3. 动态申请student_num个Student结构体的堆内存// 总字节数 = 学生数 * 单个结构体字节数(sizeof(Student))stu_arr = (Student*)malloc(student_num * sizeof(Student));if (stu_arr == NULL) {printf("学生内存申请失败!\n");exit(1);}// 4. 输入每个学生的信息for (int i = 0; i < student_num; i++) {printf("\n请输入第%d个学生的信息:\n", i + 1);printf("姓名:");// 用fgets读取姓名(含空格),避免scanf("%s")无法读取空格的问题fgets(stu_arr[i].name, sizeof(stu_arr[i].name), stdin);// 去掉fgets读取的换行符(fgets会把回车也读入字符串)stu_arr[i].name[strcspn(stu_arr[i].name, "\n")] = '\0';printf("学号:");scanf("%d", &stu_arr[i].id);printf("成绩:");scanf("%d", &stu_arr[i].score);getchar(); // 吸收score后的换行符,为下一次fgets做准备}// 5. 遍历输出所有学生信息printf("\n========== 学生信息列表 ==========\n");for (int i = 0; i < student_num; i++) {printf("第%d个学生:\n", i + 1);printf("姓名:%s\n", stu_arr[i].name);printf("学号:%d\n", stu_arr[i].id);printf("成绩:%d\n", stu_arr[i].score);printf("------------------------\n");}// 6. 释放堆内存(结构体数组只需释放一次首地址)free(stu_arr);stu_arr = NULL;return 0;
}
案例关键知识点:
- 结构体堆内存的计算:
student_num * sizeof(Student)
——sizeof(Student)
会自动计算结构体总字节数(姓名 20 + 学号 4 + 成绩 4 = 28 字节,不同编译器可能有内存对齐差异,但无需手动计算); - 字符串读取问题:用
fgets
读取姓名(支持含空格的姓名,如 “张三 三”),避免scanf("%s")
遇到空格就停止的问题; - 内存释放:结构体数组是 “连续的堆内存”,只需
free(stu_arr)
一次(释放首地址即可),不需要循环释放每个结构体。
三、实战案例 2:识别并修复内存泄漏(高频面试题)
内存泄漏是动态内存开发中最常见的问题 —— 申请了堆内存但忘记释放,导致内存一直被占用,程序运行时间越长,泄漏的内存越多,最终会耗尽系统内存导致崩溃。下面我们通过 “错误代码” 和 “修复代码” 的对比,掌握内存泄漏的排查思路。
错误代码(含 2 处内存泄漏):
#include <stdio.h>
#include <stdlib.h>// 错误1:函数内申请堆内存,未释放且未返回,导致内存泄漏
void create_leak1() {int* p = (int*)malloc(10 * sizeof(int));// 只申请不释放,函数结束后p被销毁(栈区变量),堆内存地址丢失,无法释放
}// 错误2:realloc失败时,未释放原内存,导致泄漏
void create_leak2() {int* p = (int*)malloc(5 * sizeof(int));if (p == NULL) return;// 直接用原指针接收realloc返回值,若失败,p变成NULL,原内存地址丢失p = (int*)realloc(p, 10 * sizeof(int));if (p == NULL) {printf("realloc失败!\n");// 此处未释放原内存,导致原5个int的内存泄漏return;}free(p); // 若realloc成功,此处释放有效;若失败,p已为NULL,free无意义p = NULL;
}int main() {create_leak1(); // 调用一次,泄漏40字节(10*4)create_leak2(); // 调用一次,若realloc失败,泄漏20字节(5*4)return 0;
}
修复后的正确代码:
#include <stdio.h>
#include <stdlib.h>// 修复1:要么在函数内释放,要么返回指针让调用者释放
void fix_leak1() {int* p = (int*)malloc(10 * sizeof(int));if (p == NULL) return;// 若函数内用不到p,直接释放free(p);p = NULL;// 若函数需要给调用者返回数据,可改为返回指针(需在调用者处释放)// return p;
}// 修复2:realloc用临时指针接收,失败时释放原内存
void fix_leak2() {int* p = (int*)malloc(5 * sizeof(int));if (p == NULL) return;// 用临时指针接收realloc返回值int* temp = (int*)realloc(p, 10 * sizeof(int));if (temp == NULL) {printf("realloc失败!\n");free(p); // 修复:失败时释放原内存p = NULL;return;}p = temp;temp = NULL;free(p); // 成功时释放新内存p = NULL;
}int main() {fix_leak1(); fix_leak2(); return 0;
}
内存泄漏的 3 个排查原则:
- “申请 - 释放” 成对出现:每一个
malloc
/calloc
/realloc
(成功的),都要有对应的free
,且不能遗漏; - 函数返回前检查:函数内申请的堆内存,若不返回给调用者,必须在函数返回前
free
;若返回给调用者,需在文档中明确 “调用者需释放”; - realloc 必用临时指针:永远不要用原指针直接接收
realloc
返回值,必须用临时指针,失败时手动释放原内存。
四、总结:堆区内存的 “黄金法则”
到这里,我们已经学完了堆区内存的所有核心操作(malloc
/calloc
/realloc
/free
),以及实战案例,最后总结 6 条 “黄金法则”,帮大家彻底避开坑点:
- 申请必判空:
malloc
/calloc
/realloc
返回后,必须判断是否为NULL
,否则访问NULL
会崩溃; - realloc 用临时指针:避免原内存地址丢失,失败时手动释放原内存;
- 释放后置 NULL:
free
后必须把指针置为NULL
,避免野指针(后续误操作会报错,便于排查); - 不重复释放:同一个堆地址只能
free
一次,重复释放会报错; - 不释放非堆内存:
free
只能释放malloc
/calloc
/realloc
申请的地址,不能释放栈地址(如&a
)或NULL
以外的野指针; - 函数内内存不 “丢”:函数内申请的堆内存,要么在函数内释放,要么返回给调用者释放,避免地址丢失导致泄漏。