6、内存泄漏的“幽灵”:什么是内存泄漏、泄漏的后果、常见泄漏场景
内存泄漏,说白了就是「借了不还」。
你找操作系统借了一块内存,用完了没还回去。操作系统以为你还在用,就一直给你留着。日积月累,能用的内存越来越少,程序越来越慢,最后崩了。
我早年做嵌入式开发时,有个设备跑了一个月后突然死机。查了三天,发现是一个循环里每次分配 64 字节没释放。一个月下来,几十 MB 内存全漏光了。嗯,从那以后我对 free() 有了心理阴影。
6.1 什么是内存泄漏
内存泄漏,是指程序中动态分配的内存,在失去所有指针引用后,既无法被程序继续使用,也无法被操作系统回收。
你想想看,C 语言里没有垃圾回收机制。你 malloc 出来的那块内存,只有你自己知道地址。如果你把那个地址弄丢了——比如指针被覆盖、函数返回前没 free——那这块内存就永远「蒸发」了。
举个最简单的例子:
void leak_example() {
int *p = (int *)malloc(100 * sizeof(int));
// 用了一下 p
p = NULL; // 完蛋,原来的地址丢了
// 100 * 4 = 400 字节,永远无法释放
}
这段代码执行完,400 字节就「幽灵」一样飘在内存里。没人能访问它,也没人能释放它。
6.2 泄漏的后果
很多人觉得「不就漏几 KB 吗?没事」。我告诉你,真有事。
| 后果 | 说明 | 严重程度 |
|---|---|---|
| 内存耗尽 | 泄漏持续累积,系统可用内存越来越少 | 高 |
| 程序崩溃 | malloc 返回 NULL,程序未做检查直接使用 | 高 |
| 性能下降 | 内存不足导致频繁换页、swap | 中 |
| 系统不稳定 | 其他进程也被拖累,甚至整个系统卡死 | 高 |
| 难以排查 | 泄漏往往在运行数小时甚至数天后才暴露 | 极高 |
我曾经接手过一个服务器项目,线上每 72 小时准时重启一次。查日志发现是某个消息队列处理函数里,每次处理消息都 new 一个对象,但只在异常分支才 delete。正常路径全漏了。72 小时刚好把 8GB 内存吃光。
6.3 常见泄漏场景
我总结了几个最容易踩坑的场景。你看看自己中过几个。
场景一:忘记释放
最原始、最常见。分配了内存,但代码路径提前返回或跳过了 free。
void process_data() {
char *buf = (char *)malloc(1024);
if (!buf) return;
int ret = do_something(buf);
if (ret != 0) {
return; // 忘了 free(buf)!
}
free(buf);
}
这种错误,我早期写代码时几乎每周犯一次。后来养成了习惯:写 malloc 的同时就写 free,哪怕先空着,也不漏。
场景二:指针被覆盖
分配了内存,但指针又被赋了新值。原来的地址就丢了。
void overwrite_leak() {
int *p = (int *)malloc(10 * sizeof(int));
p = (int *)malloc(20 * sizeof(int)); // 第一次分配的地址丢了!
free(p); // 只释放了第二次的
}
第一次 malloc 的 40 字节,永远找不回来了。
场景三:循环内分配,循环外释放
这个坑在嵌入式开发里特别常见。循环里不断 malloc,但 free 放在循环外面。
void loop_leak() {
for (int i = 0; i < 1000; i++) {
char *tmp = (char *)malloc(64);
// 用 tmp 做点事
// 忘了 free
}
// 循环结束,1000 个 64 字节全漏了
}
正确的做法是:每次分配,用完就释放。或者用栈上的数组代替。
场景四:结构体嵌套释放不完整
这个比较隐蔽。你释放了外层结构体,但忘了释放里面的指针成员。
typedef struct {
char *name;
int age;
} Person;
void free_person(Person *p) {
free(p); // 只释放了结构体本身
// p->name 指向的内存没释放!
}
我见过一个项目,就是因为这种嵌套泄漏,一个对象漏了 200 多字节。对象创建了上万个,你算算漏了多少。
场景五:异常路径未释放
函数里有多个 return 点,有些分支忘了释放。
int read_file(const char *path) {
char *buffer = (char *)malloc(4096);
if (!buffer) return -1;
FILE *fp = fopen(path, "r");
if (!fp) {
return -2; // 忘了 free(buffer)!
}
// ... 读取文件 ...
fclose(fp);
free(buffer);
return 0;
}
我个人习惯:用 goto 统一收尾。一个出口,所有资源都在那里释放。
int read_file_safe(const char *path) {
char *buffer = NULL;
FILE *fp = NULL;
int ret = 0;
buffer = (char *)malloc(4096);
if (!buffer) { ret = -1; goto cleanup; }
fp = fopen(path, "r");
if (!fp) { ret = -2; goto cleanup; }
// ... 读取文件 ...
cleanup:
if (fp) fclose(fp);
if (buffer) free(buffer);
return ret;
}
6.4 内存泄漏的「幽灵」本质
为什么我管它叫「幽灵」?
因为它看不见、摸不着。程序运行的时候,一切正常。没有段错误,没有警告。但内存就在那里,一点一点地消失。
你想想看,一个长期运行的服务——比如路由器、基站、服务器——如果每天泄漏 1MB,一个月就是 30MB。半年后,系统内存耗尽,所有服务一起崩溃。
更可怕的是,泄漏的代码可能只在某个特定条件下才执行。比如「每月 1 号凌晨 3 点的定时任务」。平时测试根本跑不到那条路径。等上线了,一个月后才出问题。
我曾经排查过一个泄漏,代码里有个 if (year % 400 == 0) 的分支。好家伙,闰年才执行。测试只跑了普通年份,上线后第二年 2 月 29 号,系统挂了。
6.5 如何预防
说几个我自己的习惯:
- 写 malloc 就写 free——哪怕先空着函数体
- 统一出口——用 goto 或 cleanup 标签
- 工具辅助——Valgrind、AddressSanitizer 跑一跑
- 代码审查——重点看分配和释放是否成对
- 封装分配/释放函数——比如 my_malloc / my_free,在里面加日志
嗯,工具很重要。但说到底,还是靠习惯。我见过用 Valgrind 查出几百个泄漏的工程师,改完代码后照样漏。为什么?因为没有养成「分配即释放」的肌肉记忆。
这张图把内存泄漏的来龙去脉理清楚了。左边是原因,中间是后果,右边是场景。你写代码时,只要发现自己的代码落入了右边任何一个场景,就要立刻警惕。
记住一句话:内存泄漏不是技术问题,是习惯问题。养成好习惯,幽灵自然远离你。