# 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.
heaptrack_print heaptrack.
# 查看分配点、泄漏点、调用栈
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
// … 使用 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
std::shared_ptr
~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
std::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
// 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不着急」,回复”资料”获取内存学习路线图。*
评论