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;
}
💡 我的经验:写代码时,每写一个 malloc,立刻在函数末尾写上对应的 free。这叫「配对编程法」。虽然看起来啰嗦,但能避免 90% 的内存泄漏。

6.4 内存泄漏的「幽灵」本质

为什么我管它叫「幽灵」?

因为它看不见、摸不着。程序运行的时候,一切正常。没有段错误,没有警告。但内存就在那里,一点一点地消失。

你想想看,一个长期运行的服务——比如路由器、基站、服务器——如果每天泄漏 1MB,一个月就是 30MB。半年后,系统内存耗尽,所有服务一起崩溃。

更可怕的是,泄漏的代码可能只在某个特定条件下才执行。比如「每月 1 号凌晨 3 点的定时任务」。平时测试根本跑不到那条路径。等上线了,一个月后才出问题。

我曾经排查过一个泄漏,代码里有个 if (year % 400 == 0) 的分支。好家伙,闰年才执行。测试只跑了普通年份,上线后第二年 2 月 29 号,系统挂了。

核心总结:内存泄漏不是 bug,是「定时炸弹」。它不会立刻炸,但一定会炸。

6.5 如何预防

说几个我自己的习惯:

  • 写 malloc 就写 free——哪怕先空着函数体
  • 统一出口——用 goto 或 cleanup 标签
  • 工具辅助——Valgrind、AddressSanitizer 跑一跑
  • 代码审查——重点看分配和释放是否成对
  • 封装分配/释放函数——比如 my_malloc / my_free,在里面加日志

嗯,工具很重要。但说到底,还是靠习惯。我见过用 Valgrind 查出几百个泄漏的工程师,改完代码后照样漏。为什么?因为没有养成「分配即释放」的肌肉记忆。

💡 避坑指南:我曾经在项目里强制要求:所有 malloc 必须和 free 写在同一屏幕内。如果做不到,说明函数太长了,需要重构。这个规则虽然粗暴,但效果出奇的好。
内存泄漏全景图 内存泄漏 泄漏原因 泄漏后果 常见场景 忘记释放 指针覆盖 循环泄漏 嵌套未释放 内存耗尽 程序崩溃 性能下降 难以排查 函数提前返回 指针重新赋值 循环内分配 异常路径

这张图把内存泄漏的来龙去脉理清楚了。左边是原因,中间是后果,右边是场景。你写代码时,只要发现自己的代码落入了右边任何一个场景,就要立刻警惕。

记住一句话:内存泄漏不是技术问题,是习惯问题。养成好习惯,幽灵自然远离你。