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 写在同一屏幕内。如果做不到,说明函数太长了,需要重构。这个规则虽然粗暴,但效果出奇的好。
内存泄漏全景图 内存泄漏 泄漏原因 泄漏后果 常见场景 忘记释放 指针覆盖 循环泄漏 嵌套未释放 内存耗尽 程序崩溃 性能下降 难以排查 函数提前返回 指针重新赋值 循环内分配 异常路径

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

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