适用对象:学过指针和数组的同学
本章目标:掌握 malloc/free/calloc/realloc 的用法 + 理解堆区概念 + 避免常见内存错误
写在前面
上一篇我们探讨了指针和数组,理清了他们之间的关系。这一篇我们继续深入------动态内存管理。
坦率地讲,动态内存是 C 语言中最容易出问题的环节之一。很多初学者写了数月代码都平安无事,直到某一天程序突然崩溃、内存占用飙升、系统运行变得极其卡顿------追查原因,十有八九出在动态内存上。
但不必担心。本篇将动态内存的来龙去脉掰开揉碎,配合生活化的比喻,帮助你彻底掌握这一核心知识点。
一、为什么需要动态内存分配?
1.1 先说数组的"硬伤"
我们先来回顾一下数组的基本用法:
c
int arr[10]; // 编译时就确定大小,10 个 int
char buf[100]; // 同上,100 个 char
读者,这种写法称为静态内存分配 。简而言之,在编写代码时就人为地决定了需要多少空间。编译器处理时:需要10个int,分配40个字节。
问题随之而来:
- 如果你写的是一个通讯录程序,根本无法预知用户需要存储多少人的信息------10个可能不够,10000个又造成浪费。
- 如果你写的是读取文件内容的程序,每个文件大小不一,数组究竟开多大才合适?
- 如果在函数内部定义了一个巨大的数组,该数组在函数返回后才释放,万一递归层数较深,栈区直接被撑爆。
这就是静态数组的"死穴":大小在编译时固定,使用时缺乏灵活性。
1.2 动态内存登场:你的"临时仓库"
此时,动态内存就派上用场了。
动态内存分配的核心思想是:空间的大小不在编译时硬编码,而是在程序运行期间(runtime)按需申请,使用完毕后归还。这样就能根据实际情况,灵活地申请所需空间。
打个比方:
想象你去火锅店吃串串。静态数组就好比提前跟老板说"给我包5桌",不管今天来多少人,那5桌的位子都为你保留,但可能坐不满,造成浪费。动态内存则不同------到了再喊老板安排,"今天来了8个人,给我整一桌8人位的",人走了桌子就归还,按需分配,灵活高效。
1.3 C 语言的四大内存区域
要理解动态内存,必须先搞清楚程序在内存中的布局。C 程序的内存分为四大区域:
| 区域 | 存储内容 | 生命周期 | 分配方式 |
|---|---|---|---|
| 栈区(Stack) | 函数参数、局部变量 | 函数结束自动释放 | 编译器自动管理 |
| 堆区(Heap) | 动态申请的内存 | 手动申请、手动释放 | malloc/free |
| 数据区(Data) | 全局变量、静态变量 | 程序结束才释放 | 编译器/操作系统管理 |
| 代码区(Text) | 程序的可执行代码 | 程序结束才释放 | 操作系统管理 |
重点强调:malloc、free、calloc、realloc 这些函数操作的是堆区(Heap),不是栈区!
🔥 提醒 :很多初学者容易混淆"栈"和"堆"。记住一个原则------在函数内部定义
int arr[100];这样的局部数组,空间开在栈上 ;使用malloc申请的,才在堆上 。栈上的数据函数结束后自动消失,堆上的数据需要手动调用free释放,否则会造成内存泄漏。
二、malloc 函数------向堆区"要地"
2.1 函数原型
c
void* malloc(size_t size);
- 参数
size:要申请的字节数(size_t即unsigned int,无符号整型) - 返回值 :
void*类型,即"通用指针",返回申请到的那块内存的起始地址 - 申请失败 :返回
NULL,必须检查!
2.2 通俗理解
想象你是个包工头,去找操作系统要一块空地(堆区)来建临时仓库。
malloc(100)就是你跟系统说"给我100平方米的地"。操作系统查了查账本,划了100平方米给你,然后指着入口说"这是钥匙,起始地址是0x1234ABCD"。你拿着这个地址就能在这块地上盖仓库了。
2.3 基本用法
c
// 申请 5 个 int 的空间(5 * 4 = 20 字节)
int *p = (int*)malloc(5 * sizeof(int));
// 重要:必须检查返回值是否为 NULL
if (p == NULL) {
printf("内存申请失败!\n");
return; // 或 exit(1)
}
// 使用这块内存......
p[0] = 10;
p[1] = 20;
p[2] = 30;
// 用完了记得释放
free(p);
p = NULL; // 重要:防止野指针
2.4 逐行解析
第一步 :malloc(5 * sizeof(int))
- 告诉系统"我要5个int大小的空间"
sizeof(int)在大多数系统上是 4 字节,所以实际申请了 20 字节sizeof(int)这种写法是必须的 ,因为不同平台int大小可能不一样(16位系统是2字节,32位/64位系统通常是4字节)。直接写malloc(20)不是不行,但移植性差,容易出bug。
第二步 :void* 返回值
malloc返回的是void*(通用指针),C语言允许任何类型的指针赋值给void*,不需要强制转换(但 C++ 里需要)- 为了代码规范和可读性,通常显式转型:
(int*)malloc(...)
第三步 :if (p == NULL) 检查
🔥 提醒:这个检查绝对不能省!内存申请失败虽然不常见,但一旦发生(系统内存不足的时候),如果不检查就直接使用,轻则段错误(Segmentation Fault),重则程序直接崩溃。为简化操作而省去检查的同学,在调试时往往会感到困惑:"代码明明没问题怎么崩了?"------八成就是没有检查 NULL。
2.5 完整示例
c
#include <stdio.h>
#include <stdlib.h>
int main() {
// 动态申请 5 个 int 的空间
int *arr = (int*)malloc(5 * sizeof(int));
if (arr == NULL) {
printf("内存不够,申请失败!\n");
return 1;
}
// 给数组赋值
for (int i = 0; i < 5; i++) {
arr[i] = (i + 1) * 10; // 10, 20, 30, 40, 50
}
// 打印验证
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
// 释放内存(必须!)
free(arr);
arr = NULL; // 避免野指针
return 0;
}
运行结果:
arr[0] = 10
arr[1] = 20
arr[2] = 30
arr[3] = 40
arr[4] = 50
🔥 提醒 :申请内存后牢记三个步骤------用前检查NULL,用完记得free,free后立刻置NULL。这是动态内存使用的铁律,务必牢记!
三、free 函数------把地还回去
3.1 函数原型
c
void free(void* ptr);
- 参数
ptr:指向要释放的内存块的指针(必须是malloc/calloc/realloc返回的地址) - 返回值:无
- 特殊情况 :如果
ptr == NULL,free什么都不做
3.2 通俗理解
还是包工头的比喻。你之前找系统要了100平方米的地,建了临时仓库。现在活干完了,你得把地还回去,不然系统还以为这块地在用着,结果这块地永远空着浪费了------这就是内存泄漏(Memory Leak)。
3.3 free 的正确姿势
c
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = (int*)malloc(3 * sizeof(int));
if (p == NULL) {
return 1;
}
p[0] = 100;
p[1] = 200;
p[2] = 300;
printf("%d %d %d\n", p[0], p[1], p[2]);
// 用完了,释放!
free(p);
// ⚠️ 关键步骤:置 NULL
p = NULL;
return 0;
}
3.4 容易踩的坑
坑一:重复 free
c
int *p = (int*)malloc(sizeof(int));
free(p);
free(p); // 危险!对同一块内存释放两次,程序可能崩溃
坑二:free 后继续使用指针
c
int *p = (int*)malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p); // 危险!p 现在是"野指针",指向无效内存
坑三:只 free 了指针变量本身,没 free 堆上的内存
c
void foo() {
int *p = (int*)malloc(sizeof(int));
*p = 99;
// 函数结束,p 变量本身被回收(栈上)
// 但堆上的内存没人管了------内存泄漏!
// 正确做法:在 return 之前 free(p)
}
🔥 提醒 :
free的本质是告诉操作系统这块内存现在可以回收了,但它并不会把指针变量本身清零。因此 free 后必须手动将指针设为 NULL。如果 free 后忘了置 NULL,后续代码不小心又用这个指针操作内存------轻则读到垃圾数据,重则程序崩溃,更严重的可能引发安全漏洞。这就是常说的**野指针(Dangling Pointer)**问题,需特别注意。
3.5 内存泄漏是什么感觉?
c
#include <stdlib.h>
void bad_example() {
while (1) {
int *p = (int*)malloc(1024 * 1024); // 每次循环泄漏 1MB
if (p == NULL) break;
}
// 这个函数执行几秒后,系统内存就会被耗光
}
读者,上面这段代码可以在自己电脑上试试(记得 Ctrl+C 赶紧停掉)。它每次循环申请1MB但不释放,执行一会儿电脑就卡得不行。这就是内存泄漏的危害------悄无声息地消耗内存,直到系统撑不住。
四、calloc 函数------连初始化都帮你做了
4.1 函数原型
c
void* calloc(size_t num, size_t size);
- 参数
num:元素的个数 - 参数
size:每个元素的大小(字节) - 返回值 :同 malloc,成功返回起始地址,失败返回
NULL - 关键区别 :malloc 申请的内存不初始化 (里面是随机的垃圾值),calloc 申请的内存初始化为全 0
4.2 malloc vs calloc
c
// malloc:申请空间,但里面的值是随机的(垃圾值)
int *p1 = (int*)malloc(5 * sizeof(int));
printf("%d\n", p1[0]); // 可能打印出 0,也可能是 12345678,随机!
// calloc:申请空间,并且把每个字节都初始化为 0
int *p2 = (int*)calloc(5, sizeof(int));
printf("%d\n", p2[0]); // 一定是 0
4.3 等价关系
c
// 下面两行代码效果几乎等价(除了初始化):
malloc(n * sizeof(type));
calloc(n, sizeof(type));
注意"几乎"------calloc 还会额外设置内存的"已分配"标记,并对内存进行清零操作。如果你需要一块干净的空间,用 calloc 比 malloc 后再手动 memset 更靠谱。
4.4 完整示例
c
#include <stdio.h>
#include <stdlib.h>
int main() {
// 用 calloc 申请 5 个 int,全部初始化为 0
int *arr = (int*)calloc(5, sizeof(int));
if (arr == NULL) {
return 1;
}
// 打印初始值(全为 0)
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
// 给部分元素赋值
arr[0] = 10;
arr[4] = 50;
printf("\n赋值后:\n");
for (int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
free(arr);
arr = NULL;
return 0;
}
运行结果:
arr[0] = 0
arr[1] = 0
arr[2] = 0
arr[3] = 0
arr[4] = 0
赋值后:
arr[0] = 10
arr[1] = 0
arr[2] = 0
arr[3] = 0
arr[4] = 50
🔥 提醒 :
malloc的初始值是随机的,用之前必须手动赋值或memset清零;calloc自动清零,更适合需要"干净内存"的场景,比如要存一个整数数组但不想逐个赋初值。当然,calloc 因为要初始化,会比 malloc 稍微慢一点点,但在绝大多数场景下这点差距可以忽略不计。
五、realloc 函数------临时加盖/缩地
5.1 函数原型
c
void* realloc(void* ptr, size_t new_size);
- 参数
ptr:指向已分配内存块的指针(通常是之前 malloc/calloc/realloc 的返回值) - 参数
new_size:新的总大小(字节) - 返回值:返回调整后的内存起始地址
5.2 两种情况
这是 realloc 最核心也最容易出错的地方:
情况一:原空间后面有足够的空闲内存
操作系统直接把原空间扩大,返回原来的地址(不变)。
原空间: [......]
扩容后: [............] ← 地址不变
情况二:原空间后面不够用
操作系统重新找一块足够大的空闲区域,把旧数据拷贝过去 ,然后释放旧空间 ,返回新的地址。
旧空间: [......] ──拷贝──→ [............] 新地址(返回)
[已释放] ↑
返回值指向这里
🔥 提醒 :情况二是重点!很多同学以为 realloc 只是"扩大"原空间,实际上它可能会给你一个全新的地址。这时如果你还拿着旧的指针去操作------比如
p[10] = 999;用的是老地址------你实际上在访问一块已经被释放的内存,程序的行为就完全不可预测了。这是最常见的 realloc 错误!
5.3 正确的 realloc 用法(必须用临时变量!)
c
#include <stdio.h>
#include <stdlib.h>
int main() {
// 第一步:先申请一块空间
int *p = (int*)malloc(3 * sizeof(int));
if (p == NULL) return 1;
p[0] = 10; p[1] = 20; p[2] = 30;
printf("原地址: %p, p[2] = %d\n", (void*)p, p[2]);
// 第二步:用临时变量接收 realloc 的返回值
int *temp = (int*)realloc(p, 5 * sizeof(int));
if (temp == NULL) {
// realloc 失败,原来的 p 仍然是有效的!
// 注意:这里不需要 free(p),因为它没被释放
printf("扩容失败!\n");
free(p);
return 1;
}
// 第三步:确认 temp 不为 NULL 后,再赋值给 p
p = temp;
temp = NULL; // 用完了顺手置 NULL,好习惯
printf("新地址: %p, 扩容成功!\n", (void*)p);
// 给新加的空间赋值
p[3] = 40;
p[4] = 50;
// 打印全部
for (int i = 0; i < 5; i++) {
printf("p[%d] = %d\n", i, p[i]);
}
free(p);
p = NULL;
return 0;
}
5.4 为什么必须用临时变量?
来看一个反面教材:
c
// ❌ 错误写法(不要这样写!)
p = (int*)realloc(p, 5 * sizeof(int));
问题在哪?
- 如果
realloc内部需要搬迁数据,搬迁完成后它会自动释放旧空间 - 如果此时
realloc失败了,返回NULL p = NULL→ 你丢了原地址!- 原来的内存没人管了,内存泄漏
- 更糟的是,你根本不知道原来的数据在哪了
正确的做法永远是用临时变量中转:
c
// ✅ 正确写法
int *temp = (int*)realloc(p, 5 * sizeof(int));
if (temp == NULL) {
// 扩容失败,p 仍然有效,可以继续用或手动释放
free(p);
return 1;
}
p = temp;
5.5 realloc 的其他注意事项
缩小空间(new_size < 原大小):
c
int *p = (int*)malloc(10 * sizeof(int)); // 10 个 int
// ... 填充数据 ...
int *temp = realloc(p, 5 * sizeof(int)); // 缩小到 5 个 int
if (temp) {
p = temp;
// p[0]~p[4] 保留,p[5]~p[9] 已被"丢弃"(操作系统可回收)
}
将 realloc 的第一个参数设为 NULL:
c
// 等价于 malloc(new_size)!
void *p = realloc(NULL, 100);
// 就是分配一块新的内存,当 malloc 用
将 new_size 设为 0:
c
// 等价于 free(ptr)!但不推荐这样用,可读性差
free(p); // 清晰明了
realloc(p, 0); // 也能释放,但没人这样写
六、综合实战:动态数组
学了上面的知识,接下来写一个实用案例------动态数组。这个例子把 malloc、realloc、free 全都用上了。
6.1 需求
从键盘输入若干整数(不知道有多少个),以 0 结尾,把所有数存起来并打印。
6.2 思路分析
- 用
malloc申请初始空间(比如 4 个 int) - 每当空间不够时,用
realloc扩容 - 输入结束后用
free释放
6.3 完整代码
c
#include <stdio.h>
#include <stdlib.h>
int main() {
int capacity = 4; // 当前容量
int used = 0; // 已使用的数量
int *arr = (int*)malloc(capacity * sizeof(int));
if (arr == NULL) {
printf("初始内存申请失败!\n");
return 1;
}
printf("请输入整数(输入0结束):\n");
int input;
while (scanf("%d", &input) == 1) {
if (input == 0) break; // 输入0,结束
// 空间不够了?扩容!
if (used >= capacity) {
capacity *= 2; // 容量翻倍
int *temp = (int*)realloc(arr, capacity * sizeof(int));
if (temp == NULL) {
printf("扩容失败!\n");
free(arr); // 记得把之前的空间释放掉
return 1;
}
arr = temp;
printf("已扩容,当前容量:%d\n", capacity);
}
arr[used++] = input;
}
printf("\n你输入的 %d 个数:\n", used);
for (int i = 0; i < used; i++) {
printf("%d ", arr[i]);
}
printf("\n");
free(arr); // 程序结束前,释放所有申请的内存
arr = NULL;
return 0;
}
运行示例:
请输入整数(输入0结束):
1 2 3 4 5
已扩容,当前容量:8
6 7 8 9 10 11 12 13 14
已扩容,当前容量:16
0
你输入的 14 个数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
6.4 为什么要"容量翻倍"?
读者,你可能会问:为什么每次扩容是 capacity *= 2 而不是 capacity += 1?
这其实是动态数组实现的一个重要策略:
- 每次增加太少:扩容频繁,每次拷贝数据开销大,程序变慢
- 每次增加太多:预分配太多,用不上,浪费内存
- 翻倍策略:综合了时间和空间的开销,是业界常用做法(很多语言的标准库也用这个策略)
七、常见错误大盘点
| 错误类型 | 代码示例 | 后果 | 解决方案 |
|---|---|---|---|
| 不检查 NULL | int *p = malloc(100); p[0] = 1; |
申请失败时程序崩溃 | 每次 malloc/calloc/realloc 后立即检查 NULL |
| 野指针 | free(p); printf("%d", p[0]); |
访问已释放内存,未定义行为 | free 后立即置 p = NULL |
| 重复 free | free(p); free(p); |
崩溃或内存损坏 | free 前检查非 NULL,free 后置 NULL |
| 内存泄漏 | 函数内 malloc 后直接 return | 内存永远无法回收 | 所有 malloc 路径都要有对应的 free |
| realloc 错误 | p = realloc(p, new_size); |
失败时丢失原指针 | 用临时变量中转 |
| 偏移错误 | malloc(5) 存 6 个 int |
写入越界,踩坏堆结构 | 精确计算所需空间 |
🔥 提醒 :说到内存泄漏的调试,Linux 下有个神器叫
valgrind,用它跑你的程序可以精准定位内存泄漏。Windows 下可以用 Visual Studio 的调试运行时库(启动 CRT 调试)或者 Dr. Memory 工具。建议学有余力的同学去试试,能帮你发现很多肉眼看不到的内存问题。
八、面试题
面试题一:malloc 和 calloc 的区别是什么?
| 对比项 | malloc | calloc |
|---|---|---|
| 参数 | 1 个:size(总字节数) |
2 个:num(个数)size(每个大小) |
| 初始化 | 不初始化,内容是随机垃圾值 | 初始化为 0 |
| 性能 | 稍快(不需要清零) | 稍慢(需要清零操作) |
| 实际效果 | malloc(n * s) ≈ calloc(n, s)(除了初始化) |
--- |
参考回答:
malloc和calloc都用于动态内存分配,核心区别在于初始化。malloc申请内存后,里面的值是未定义的垃圾值;而calloc会把申请的空间全部初始化为 0。从效率上看,malloc稍快一些,因为它跳过了清零的步骤。如果你需要一块干净的内存,calloc更方便;如果你马上要覆盖所有数据,用malloc可以省一次清零的开销。
面试题二:realloc 返回 NULL 时会发生什么?原指针还有效吗?
参考回答:
realloc有两种返回值情况。第一,如果扩容成功,返回新地址(可能和原地址相同,也可能不同)。第二,如果扩容失败,返回NULL。重点 :当realloc返回 NULL 时,原来的内存块完全没有被动过 ,原指针仍然指向有效内存,可以继续正常使用,也可以手动free。正因为如此,正确的写法是用临时变量接收返回值:
cint *temp = realloc(p, new_size); if (temp == NULL) { free(p); // 失败时释放原内存 return; } p = temp; // 成功后才赋值如果直接
p = realloc(p, new_size);,一旦失败p就被覆盖成了NULL,原来的内存就泄漏了。
九、知识总结
9.1 一图总结四大函数
┌─────────────────────────────────────────────────────┐
│ 动态内存管理全家桶 │
├──────────┬──────────┬──────────┬────────────────────┤
│ malloc │ free │ calloc │ realloc │
├──────────┼──────────┼──────────┼────────────────────┤
│ 申请内存 │ 释放内存 │ 申请+清零 │ 调整大小 │
│ 不初始化 │ │ 初始化为0 │ │
│ 单参数 │ 必需对应 │ 两参数 │ 临时变量接收! │
│ 需检查 │ NULL置0 │ 需检查 │ 需检查 │
└──────────┴──────────┴──────────┴────────────────────┘
9.2 核心原则
┌──────────────────────────────────────────────┐
│ 动态内存使用三条铁律 │
│ │
│ ① 申请后立即检查 NULL │
│ ② 不用了立即 free │
│ ③ free 后立即置 NULL │
│ │
│ bonus: realloc 用临时变量中转 │
└──────────────────────────────────────────────┘
十、下一篇章
本篇把 malloc、free、calloc、realloc 四大函数全部过了一遍,读者应该对动态内存分配有了比较清晰的概念了。
但是!还有几个经典的"内存安全问题"没有来得及细讲,比如:
- malloc(0) 会发生什么?
- 如何正确实现一个字符串拼接函数(strcat 的安全版本)?
- 什么是内存对齐?为什么重要?
- 常见的内存错误如何调试?