# K09 —— 内存泄漏:为什么 malloc 不 free 会导致内存无法回收

**金句**:程序结束后,系统会帮你回收——但在运行期间,泄漏的内存就是真的泄漏了。

## 什么是内存泄漏

内存泄漏(Memory Leak)是指程序动态分配内存后,没有释放,导致这块内存永远无法被 reuse。

“`
内存泄漏的典型例子:

void leak() {
char *buf = malloc(1024);
if (some_condition) {
return; // 提前返回,buf 没有 free
}
free(buf); // 正常路径释放
}

void real_leak() {
while (1) {
char *buf = malloc(1024);
// 忘记 free(buf);
// 每次循环泄漏 1024 bytes
sleep(1);
}
}
“`

“`
内存泄漏的分类:

1. 真正的泄漏(True Leak):
– 指针丢失,内存无法访问
– 内存无法被任何代码释放
– 必须重启程序才能回收

2. 逻辑泄漏(Logical Leak):
– 指针还在,但不再使用
– 内存仍然可达,但已经是垃圾
– 可以通过代码修改避免(用完 free)

3. 间接泄漏(Indirect Leak / 循环引用):
– A 持有对 B 的引用,B 也持有对 A 的引用
– 形成循环引用,没有外部引用链
– 现代 GC 语言中这个问题明显
– C++ 可以用 weak_ptr 打破循环
“`

## 泄漏是如何发生的

“`
常见的内存泄漏场景:

1. 提前 return:
if (condition) return; // 不 free

free(buf);

2. 错误处理:
if (!buf) return ERROR;
buf = malloc(…);
if (error) return ERROR; // buf 没有 free

free(buf);

3. 循环中的累积:
while (process()) {
char *tmp = malloc(1024);
do_something(tmp);
// 忘记 free(tmp)
}

4. 错误路径遗漏:
int init() {
if (!(p = malloc(…))) return -1;
if (!(q = malloc(…))) { free(p); return -1; }
return 0;
}
“`

## 进程结束后内存会回收吗

“`
进程退出后,内存会被系统回收:

– 进程退出时,所有打开的文件描述符关闭
– 所有内存映射(mmap/vma)被释放
– 所有物理页归还 buddy system
– 进程持有的内存全部回收

所以:
– 泄漏的内存,进程退出后会被回收
– 但如果进程是长期运行的(服务器、守护进程),
泄漏会累积,直到 OOM

泄漏的影响:
– 短命程序(脚本、批处理):泄漏影响小,退出就回收
– 长命程序(服务器、daemon):泄漏累积,最终 OOM
– 内存泄漏是服务器稳定性的大敌
“`

## 泄漏不只有 malloc——生产环境的”隐性泄漏”

“`
除了 malloc,还有很多泄漏被忽视:

1. 文件描述符泄漏:

while (1) {
int fd = open("/dev/null", O_RDONLY);
// 忘记 close(fd)
process();
}
// 每次循环泄漏一个 fd
// 超过 ulimit -n 后无法再打开文件

信号:Too many open files → 程序崩溃

2. mmap 泄漏:

void *p = mmap(NULL, 1024*1024, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 忘记 munmap(p)
// VMA 一直存在,直到进程退出
// 即使物理页归还,虚拟地址空间碎片化

3. shared memory 未删除:

shmget(key, size, IPC_CREAT); // 创建
// … 使用 …
// 忘记 shmctl(shmid, IPC_RMID, NULL)
// 即使进程退出,共享内存段残留(ipcs -m 可见)

4. 线程泄漏:

pthread_t th;
pthread_create(&th, NULL, worker, NULL);
// 忘记 pthread_join(th, NULL) 或 pthread_detach(th)
// 线程退出后,线程描述符(pthread 结构体)未释放
// 大量短生命周期线程会导致线程数超限

5. gdi resource 泄漏(Windows):

HDC hdc = GetDC(hwnd);
// 忘记 ReleaseDC(hwnd, hdc)
// 每次调用泄漏一个 HDC,最终 GDI 资源耗尽
“`

## 检测内存泄漏的方法

“`
1. valgrind(最全面,检测准确):

valgrind –leak-check=full –show-leak-kinds=all ./program

输出示例:
==12345== 120 bytes in 1 blocks are definitely lost in loss record 1 of 2
==12345== at 0x…: malloc (vg_malloc.c:…)
==12345== by 0x…: leak() (leak.c:10)
==12345== by 0x…: main (main.c:5)

leak summary:
definitely lost: 120 bytes ← 真正的泄漏
indirectly lost: 0 bytes ← 间接泄漏(持有泄漏对象的引用)
still reachable: 1024 bytes ← 全局变量持有,未泄漏,可忽略
suppressed: 0 bytes ← 被抑制的(valgrind 自带库泄漏)

2. AddressSanitizer(运行时,快,适合 CI):

# 编译时加 -fsanitize=address
gcc -fsanitize=address -g program.c -o program
./program

# 运行时内存错误(越界、泄漏、双free)都会报告
# 输出包含调用栈

注意:
– ASan 可以检测 malloc 不 free 的泄漏(从 glibc 2.30+)
– 比 valgrind 快 2~5 倍,但有 ~2x 内存开销

3. /proc/PID/status(监控生产进程):

$ cat /proc/1234/status | grep -i vm
VmPeak: 123456 kB // 峰值(进程用过的最大内存)
VmRSS: 12345 kB // 当前物理内存使用
VmSize: 123456 kB // 总虚拟地址空间大小
VmData: 23456 kB // 数据段大小(堆+栈+数据)

如果 VmPeak 持续增长,说明有泄漏

4. ps / top(快速查看):

$ ps -o pid,rss,vsz,comm -p 12345
PID RSS VSZ COMM
12345 45678 123456 myprogram

watch -n 5 ‘ps -o pid,rss,vsz,comm -p $(pgrep myprogram)’
# RSS 持续增长 = 泄漏

5. mtrace(glibc 内置,轻量):

// 代码中加入:
#define MALLOC_TRACE 1
#include
mtrace(); // 开始追踪

// 运行后查看:
MALLOC_TRACE=/tmp/mtrace.log ./program
mtrace ./program /tmp/mtrace.log
# 输出分配/释放不匹配的位置

6. heaptrack(现代替代,快,支持火焰图):

heaptrack ./program
# 生成 heaptrack..json.gz
heaptrack_print heaptrack..json.gz | less
# 查看分配点、泄漏点、调用栈

7. Dr. Memory(Windows/Linux,动态检测):

drmemory ./program
# 检测泄漏、越界、未初始化等

8. mimalloc 内置 profiler(如果使用 mimalloc):

MIMALLOC_PROFILER=1 ./program
# 输出泄漏报告
“`

## 泄漏调试的完整流程(生产实战)

“`
发现问题:
$ watch -n 5 ‘ps -o pid,rss,vsz,comm -p $(pgrep myserver)’
# 发现 RSS 每小时涨 10MB

定位进程:
$ cat /proc/$(pgrep myserver)/status | grep -E ‘Vm|Rss’
VmPeak: 500MB VmRSS: 300MB ← 对比正常基线

缩小范围:
– 加上 –leak-check=full 重新运行,定位具体 malloc 位置
– 如果 prod 无法直接跑 valgrind:staged clone + 重现
– 如果是长命进程:定期打 VmPeak/Rss 的 metric 曲线

复现:
$ valgrind –leak-check=full –log-file=valgrind.log ./myserver
# 找到”definitely lost”的调用栈

修复:
– 检查所有 malloc/free 配对
– 检查所有提前 return/错误路径
– 用 RAII / 智能指针替代裸指针

验证:
$ valgrind –leak-check=full ./myserver_fixed
# still reachable 可以忽略,definitely/indirectly lost 必须为 0
“`

## 防止内存泄漏的工具

“`
智能指针(C++):

#include

void leak() {
auto buf = std::make_unique(1024);
// … 使用 buf …
// 函数结束,buf 自动释放
} // 自动调用 delete[]

RAII(Resource Acquisition Is Initialization):

class Buffer {
char *data;
public:
Buffer(size_t size) { data = new char[size]; }
~Buffer() { delete[] data; } // 析构函数自动释放
};

void func() {
Buffer buf(1024);
// …
} // buf 超出作用域,自动调用析构

C 语言的资源管理:

// 设计原则:谁分配谁释放
char *create_buf() { return malloc(1024); }
void destroy_buf(char *buf) { free(buf); }

// 使用者必须配对
char *buf = create_buf();
use(buf);
destroy_buf(buf);

// 或者用 cleanup 属性(gcc/clang)
void func() {
char *buf __attribute__((cleanup(cleanup_buf))) = malloc(1024);
// …
}

static void cleanup_buf(char **p) { if (*p) free(*p); }
“`

## C++ 循环引用与 weak_ptr 打破

“`
循环引用的例子(错误):

class Node {
public:
std::shared_ptr next;
std::shared_ptr prev;
~Node() { printf(“Node destroyedn”); }
};

void circular_ref() {
auto a = std::make_shared();
auto b = std::make_shared();
a->next = b; // a 引用 b
b->prev = a; // b 引用 a → 循环引用
// 函数结束,a 和 b 的引用计数都是 1(互相持有)
// 析构函数永远不会调用 → 泄漏
}

用 weak_ptr 打破循环引用:

class Node {
public:
std::shared_ptr next;
std::weak_ptr prev; // 改为 weak_ptr,不增加引用计数
~Node() { printf(“Node destroyedn”); }
};

void no_leak() {
auto a = std::make_shared();
auto b = std::make_shared();
a->next = b;
b->prev = a;
// 函数结束,a 和 b 的引用计数都是 1(只有 next 持有)
// prev 的 weak_ptr 不算,析构函数正常调用
}

// weak_ptr 的用法:
// std::weak_ptr wp = a;
// if (auto sp = wp.lock()) { /* a 还活着 */ }
// else { /* a 已经析构 */ }
“`

## 总结

– **内存泄漏**:动态分配后未释放,指针丢失,无法回收
– **长命程序的影响**:短命程序退出自动回收,长命程序累积导致 OOM
– **隐性泄漏**:文件描述符、mmap 映射、shared memory、线程都可能有泄漏
– **检测方法**:valgrind(最全)/ ASan(快)/ heaptrack(火焰图)/ /proc/status(监控)
– **调试流程**:watch RSS 增长 → 定位进程 → valgrind/ASan 复现 → 修复 → 验证
– **防止**:智能指针(C++)/ RAII 析构函数 / 谁分配谁释放原则 / weak_ptr 打破循环引用

**下篇预告(K10)**:NUMA 系统下,内存是怎么分布在多个节点的?numa_bind、mbind、以及内存分配如何选择节点。

*关注公众号「AI不着急」,回复”资料”获取内存学习路线图。*

最后修改: 2024年10月6日

作者

评论

发表评论

您的邮箱地址不会被公开。