4. 内存泄漏定义与危害:泄漏类型、内存碎片与 OOM Killer 触发机制
内存泄漏(Memory Leak)是 Linux 服务端开发中最常见、最隐蔽的稳定性隐患之一。它并非指物理内存凭空消失,而是指堆内存被程序申请后,不再使用却未被释放,且程序已丢失对该内存块的所有引用,导致该内存既无法被应用程序回收,也无法被操作系统重新分配。
核心定义:内存泄漏 = 已分配堆内存 + 丢失引用 + 无法回收。泄漏的内存会持续占用虚拟地址空间和物理页帧,直到进程退出或系统崩溃。
4.1 内存泄漏的两种类型:显式泄漏与隐式泄漏
根据泄漏的成因和表现形式,可将内存泄漏分为两大类:
| 类型 | 定义 | 典型场景 | 检测难度 |
|---|---|---|---|
| 显式泄漏 | 通过 malloc()/new 分配的内存,指针被覆盖或丢失,未调用 free()/delete |
函数内局部指针未释放、链表节点删除后未释放节点内存 | 低(Valgrind 可直接定位) |
| 隐式泄漏 | 内存仍有引用,但已无实际用途,且持续增长无法回收 | 全局缓存无限增长、std::vector 反复 push_back 不 shrink、线程局部存储未清理 | 高(需结合堆快照分析) |
工程经验:生产环境中,隐式泄漏的危害往往大于显式泄漏。显式泄漏通常能被 Valgrind 或 ASan 在测试阶段捕获;而隐式泄漏常以“内存占用缓慢增长”的形式潜伏数月,最终在流量高峰触发 OOM。
4.1.1 显式泄漏示例
// 显式泄漏:指针被覆盖,丢失释放机会
void leak_example() {
char *buf = (char *)malloc(1024);
buf = (char *)malloc(2048); // 第一次分配的 1024 字节已丢失引用
// 没有 free(buf);
}
4.1.2 隐式泄漏示例
// 隐式泄漏:全局容器无限增长
std::vector<std::string> g_cache;
void on_request(const std::string &data) {
g_cache.push_back(data); // 只增不减,从未清理
}
注意:隐式泄漏的代码在语法上完全合法,所有指针都有引用,编译器不会报错,静态检查工具也难以发现。必须通过内存 profiling 工具(如
perf mem、heaptrack)观察堆增长趋势才能定位。
4.2 内存泄漏的连锁危害
内存泄漏并非孤立问题,它会引发一系列系统级灾难:
- 虚拟地址空间耗尽:32 位进程地址空间仅 3GB(用户态),泄漏可导致
mmap()/malloc()返回ENOMEM。 - 物理内存与 Swap 耗尽:泄漏的页被写入 Swap,导致磁盘 I/O 激增,系统响应变慢。
- 内存碎片化加剧:频繁分配/释放不同大小的块,导致 buddy 系统无法合并连续页。
- 触发 OOM Killer:系统内存不足时,内核选择“最坏”进程杀死。
4.3 内存碎片:泄漏的“帮凶”
内存碎片分为内部碎片和外部碎片。泄漏会显著加剧外部碎片:
| 碎片类型 | 成因 | 与泄漏的关系 |
|---|---|---|
| 内部碎片 | 分配器返回的块大于请求大小(如 slab 对齐) | 泄漏导致内部碎片累积,无法被其他分配复用 |
| 外部碎片 | 已释放的小块不连续,无法满足大块分配 | 泄漏的块“钉”在地址空间中,阻止相邻空闲块合并 |
关键指标:通过
/proc/buddyinfo 可以观察外部碎片程度。如果高阶 order(如 order 3 以上)的可用块数量持续为 0,说明碎片严重,即使总空闲内存充足,大块分配也会失败。
4.4 OOM Killer 触发机制
当系统物理内存 + Swap 全部耗尽,且内核无法通过回收页缓存(Page Cache)、Slab 可回收对象等方式满足分配请求时,OOM Killer(Out-Of-Memory Killer) 被激活。
4.4.1 触发流程
- 分配失败:
__alloc_pages_nodemask()在慢路径中遍历所有 zone 后仍无法分配。 - 唤醒 OOM 线程: 调用
out_of_memory(),唤醒内核oom_reaper线程。 - 选择目标进程: 通过
oom_badness()函数计算每个进程的“坏分值”。 - 发送 SIGKILL: 向选中的进程发送 SIGKILL 信号,强制终止。
- 释放内存: 进程退出后,其占用的所有物理页被回收。
4.4.2 oom_badness 评分公式
// 内核源码 mm/oom_kill.c (简化)
unsigned long oom_badness(struct task_struct *p, struct mem_cgroup *memcg,
const nodemask_t *nodemask, unsigned long totalpages)
{
long points;
// 基础分 = 进程 RSS + 页缓存 + Swap 使用量
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
// root 进程特权降权
if (has_capability_noaudit(p, CAP_SYS_ADMIN))
points -= (points >> 3);
// 根据 oom_score_adj 调整(-1000 到 1000)
points += p->signal->oom_score_adj;
return points > 0 ? points : 0;
}
关键参数:
/proc/<pid>/oom_score:当前进程的原始坏分值(只读)。/proc/<pid>/oom_score_adj:可调参数,范围 -1000(禁止杀死)到 1000(优先杀死)。/proc/sys/vm/panic_on_oom:设为 1 时,OOM 直接触发内核 panic 而非杀死进程。
4.4.3 OOM Killer 触发决策流程
4.4.4 如何避免被 OOM Killer 误杀
| 方法 | 操作 | 适用场景 |
|---|---|---|
| 调整 oom_score_adj | echo -1000 > /proc/<pid>/oom_score_adj |
关键服务进程(如数据库、监控代理) |
| 设置内存 cgroup 限制 | 通过 memory.max_in_bytes 限制进程组内存上限 |
容器化环境,防止单个容器耗尽宿主机内存 |
| 启用 early oom | 内核参数 vm.early_oom_kill=1 |
在内存完全耗尽前提前杀死内存占用过高的进程 |
| 使用 MGLRU 回收 | 启用 CONFIG_LRU_GEN 内核特性 |
改善内存回收效率,降低 OOM 触发概率 |
生产警示:不要依赖 OOM Killer 作为内存管理手段。OOM Killer 选择进程时基于“坏分值”,可能杀死你的关键业务进程而非泄漏进程。正确的做法是通过内存 profiling 工具主动发现并修复泄漏,同时配置 cgroup 内存上限作为安全网。
4.5 本章小结
- 显式泄漏:指针丢失,工具易检测;隐式泄漏:引用未断,容器无限增长,检测难度大。
- 内存碎片:泄漏块阻止连续页合并,导致大块分配失败,即使总空闲内存充足。
- OOM Killer:内核最后的“杀手锏”,通过
oom_badness()评分选择牺牲进程,可通过oom_score_adj和 cgroup 进行防护。 - 根本解决:建立内存泄漏的自动化检测体系,在 CI/CD 阶段集成 Valgrind、ASan,在线上部署
heaptrack或memleak(bcc)进行持续监控。