第十七章 动态内存管理:malloc/free陷阱、内存泄漏检测、Valgrind使用
动态内存管理,说白了就是C语言里最让人又爱又恨的东西。爱它,是因为它给了我们极大的灵活性——程序跑起来之后,想分配多少内存就分配多少。恨它,是因为一旦用不好,程序就会莫名其妙地崩溃,或者内存像漏水的桶一样,一点一点被耗尽。
我个人习惯把动态内存管理比作“借和还”。你找系统借了一块内存(malloc),用完了就得还回去(free)。不还?那就是内存泄漏。还错了?那就是野指针、double free。还早了?那就是悬空指针。嗯,这里面的门道,咱们今天一条一条捋清楚。
17.1 malloc/free 的基本规则
先看一个最简单的例子:
#include <stdlib.h>
int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) {
// 处理分配失败
return -1;
}
// 使用 p...
free(p);
p = NULL; // 我建议加上这一句
这段代码看起来没什么问题,对吧?但我在项目中遇到过,有人把 p = NULL 这句给省了。结果后面不小心又用了一次 p,程序直接崩溃。你想想看,free 只是释放了内存,但指针的值还在,它指向的是一块已经归还给系统的地址。这就是所谓的“悬空指针”。
17.2 常见的 malloc/free 陷阱
陷阱一:忘记检查 malloc 返回值。你以为 malloc 一定会成功?别天真了。在嵌入式系统或者内存紧张的环境下,malloc 返回 NULL 是常有的事。我曾经在一个网络服务器项目里,就因为没检查返回值,程序跑了两天后突然崩溃,查了半天才发现是内存分配失败导致的。
// 错误写法
int *p = (int *)malloc(100 * sizeof(int));
p[0] = 42; // 如果 p 是 NULL,这里就崩了
// 正确写法
int *p = (int *)malloc(100 * sizeof(int));
if (p == NULL) {
fprintf(stderr, "malloc failed\n");
exit(EXIT_FAILURE);
}
陷阱二:越界访问。你 malloc 了 10 个 int,结果写到了第 11 个位置。C 语言不会拦你,但系统会在某个时刻给你颜色看。这种 bug 最难查,因为它可能不会立即崩溃,而是等到你释放内存的时候才炸。
陷阱三:重复释放(double free)。
int *p = (int *)malloc(sizeof(int));
free(p);
free(p); // 未定义行为!可能崩溃
为什么会这样?因为第一次 free 之后,那块内存已经被标记为可用。第二次 free 时,内存管理器的内部数据结构可能已经被改写了,结果就是程序挂掉。我曾经在一个多线程程序里遇到过这种问题,两个线程同时释放同一个指针,排查过程简直让人抓狂。
17.3 内存泄漏:看不见的杀手
内存泄漏,就是借了不还。程序跑得越久,占用的内存越多,最后系统扛不住了,进程被 kill 掉。这种问题在服务器端尤其致命。
看一个典型的泄漏场景:
void leaky_function() {
int *p = (int *)malloc(100 * sizeof(int));
// 忘记 free(p);
return;
}
每次调用这个函数,就泄漏 400 字节(假设 int 是 4 字节)。如果这个函数被调用 100 万次,那就是 400MB 的内存没了。嗯,服务器撑不过一天。
17.4 使用 Valgrind 检测内存问题
手动找内存泄漏?太累了。我推荐用 Valgrind。它是一个强大的内存调试工具,能帮你自动检测内存泄漏、越界访问、使用未初始化内存等问题。
安装很简单:
# Ubuntu/Debian
sudo apt-get install valgrind
# CentOS/RHEL
sudo yum install valgrind
使用方法:
gcc -g -o myprogram myprogram.c
valgrind --leak-check=full ./myprogram
注意编译时一定要加 -g 选项,这样 Valgrind 才能显示具体的行号。
假设我们有这样一个有问题的程序:
#include <stdlib.h>
int main() {
int *p = (int *)malloc(10 * sizeof(int));
p[10] = 42; // 越界访问
// 忘记 free(p)
return 0;
}
运行 Valgrind 后,你会看到类似这样的输出:
==12345== Invalid write of size 4
==12345== at 0x4005E4: main (test.c:5)
==12345== Address 0x51f4068 is 0 bytes after a block of size 40 alloc'd
==12345== at 0x4C2B0E0: malloc (vg_replace_malloc.c:299)
==12345== by 0x4005D7: main (test.c:4)
==12345==
==12345== HEAP SUMMARY:
==12345== in use at exit: 40 bytes in 1 blocks
==12345== total heap usage: 1 allocs, 0 frees, 40 bytes allocated
==12345==
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2B0E0: malloc (vg_replace_malloc.c:299)
==12345== by 0x4005D7: main (test.c:4)
看到了吗?Valgrind 清清楚楚地告诉你:第 5 行有越界写入,第 4 行分配的内存没有释放。这就是它的威力。
17.5 Valgrind 常用选项
| 选项 | 作用 |
|---|---|
| --leak-check=full | 显示每个泄漏的详细信息 |
| --show-leak-kinds=all | 显示所有类型的泄漏(definite, indirect, possible, reachable) |
| --track-origins=yes | 追踪未初始化值的来源 |
| --log-file=output.log | 将输出保存到文件 |
我个人习惯用这个组合:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./myprogram
17.6 内存泄漏的常见模式
模式一:函数内部分配,外部忘记释放。
char *get_string() {
char *s = (char *)malloc(100);
sprintf(s, "hello");
return s;
}
int main() {
char *str = get_string();
// 使用 str...
// 忘记 free(str);
return 0;
}
模式二:循环中分配,但只在特定条件下释放。
for (int i = 0; i < 1000; i++) {
int *p = (int *)malloc(sizeof(int));
if (some_condition) {
free(p);
continue;
}
// 如果 some_condition 为假,p 就泄漏了
}
模式三:结构体嵌套,只释放了外层。
typedef struct {
int *data;
int size;
} Buffer;
Buffer *create_buffer(int n) {
Buffer *buf = (Buffer *)malloc(sizeof(Buffer));
buf->data = (int *)malloc(n * sizeof(int));
buf->size = n;
return buf;
}
void destroy_buffer(Buffer *buf) {
free(buf); // 只释放了 buf 本身,buf->data 泄漏了
}
17.7 动态内存管理的核心流程图
下面这张图总结了动态内存管理的完整生命周期,以及常见的错误点:
从这张图可以看得很清楚:每一步都有对应的陷阱。malloc 之后要检查 NULL,使用时要小心越界,free 之后要置 NULL。任何一个环节出问题,都会导致程序不稳定。
17.8 避坑指南
我曾经在一个金融交易系统里排查过一个内存泄漏问题。系统跑了三天后,内存占用从 200MB 涨到了 2GB,最后被 OOM killer 干掉了。用 Valgrind 一查,发现是一个日志模块里,每次写日志都 malloc 一块缓冲区,但只有在日志级别达到某个阈值时才 free。结果生产环境日志量巨大,泄漏的速度远超预期。
从那以后,我给自己定了几条规矩:
- 每个 malloc 写完之后,立刻写对应的 free,哪怕先注释掉。这叫“配对编码法”。
- 复杂的数据结构,写专门的创建函数和销毁函数,销毁函数里一定要递归释放所有子结构。
- 每次提交代码前,用 Valgrind 跑一遍单元测试,确保没有泄漏。
- 在服务器端程序里,加上内存使用量的监控,一旦发现异常增长,立刻报警。
好了,这一章的内容就到这里。记住,内存管理是 C 语言的基本功,也是区分新手和老手的分水岭。多练、多查、多总结,你也能写出健壮的程序。
公众号:蓝海资料掘金营,微信deep3321