4. 内存性能优化:内存分配与回收、内存泄漏检测、Swap与OOM Killer、大页内存

内存,是系统性能的命脉。我见过太多线上事故,最后都指向了内存问题。说白了,内存管理就是一场「空间与时间」的博弈。这一章,我们聊聊那些真正能落地的内存优化手段。

核心观点:内存优化的本质是减少不必要的分配、缩短回收路径、避免泄漏,并让硬件TLB(页表缓存)发挥最大效率。
内存性能优化知识体系 内存性能优化 分配与回收 泄漏检测 Swap & OOM 大页内存 malloc / free mmap / brk Valgrind AddressSanitizer swappiness OOM Score HugeTLB 透明大页 目标:减少分配开销 → 消除泄漏 → 控制Swap → 提升TLB命中率

4.1 内存分配与回收:从用户态到内核态

内存分配,不是简单的 malloc 就完事了。你想想看,每次 malloc 背后,glibc 可能走 brk 也可能走 mmap。我习惯先搞清楚这两个路径的区别。

分配方式 系统调用 适用场景 释放特点
brk sbrk / brk 小内存(< 128KB) 连续释放,堆顶下移
mmap mmap / munmap 大内存(≥ 128KB) 独立映射,释放即归还OS

我个人习惯用 strace 看实际调用。有一次线上服务内存持续增长,用 strace -e brk,mmap -p PID 一看,发现大量 mmap 调用没匹配 munmap。嗯,这就是泄漏的早期信号。

小技巧: 调整 M_MMAP_THRESHOLD 环境变量可以控制 mmap 阈值。比如 export MALLOC_MMAP_THRESHOLD_=65536,让 64KB 以上的分配走 mmap,减少堆碎片。

回收方面,glibc 的 free 并不总是把内存还给 OS。它会把小块内存缓存到 fastbin 或 tcache 里。说白了,这是「以空间换时间」。但如果你用 malloc_trim(0),可以强制归还堆顶的空白页。

// 强制归还堆内存
#include <malloc.h>
malloc_trim(0);  // 0 表示尽量多归还

// 查看当前堆状态
// cat /proc/PID/status | grep VmRSS
// cat /proc/PID/status | grep VmSwap
注意: 频繁调用 malloc_trim 会导致性能抖动。我建议只在低峰期或检测到 RSS 异常增长时触发。

4.2 内存泄漏检测:从 Valgrind 到 ASan

内存泄漏是慢性毒药。我曾经接手过一个 Java 转 Go 的项目,C 代码里到处是 malloc 没有 free。跑了两周,内存从 2GB 涨到 30GB,直接 OOM。

检测工具,我按场景分三类:

  • Valgrind (memcheck):最全面,但慢(慢 10-20 倍)。适合单元测试和集成测试阶段。
  • AddressSanitizer (ASan):编译时插桩,快很多(慢 2-3 倍)。适合 CI 流水线。
  • mtrace / bfd:轻量级,适合线上临时排查。

举个例子,用 ASan 编译:

gcc -fsanitize=address -g -O1 -o test test.c
./test
# 如果有泄漏,会输出类似:
# ==12345==ERROR: LeakSanitizer: detected memory leaks
# Direct leak of 1024 byte(s) in 1 object(s) allocated from:
#     #0 0x7f... in malloc
#     #1 0x400... in main test.c:10
我的经验: 线上环境用 /proc/PID/smaps 观察 Pss(比例集大小)的变化趋势。如果 Pss 持续增长且没有回落,大概率有泄漏。配合 perf record -e mem:malloc -p PID 可以抓取分配栈。

4.3 Swap 与 OOM Killer:当内存不够时

Swap 是双刃剑。用好了是缓冲,用不好是性能杀手。我见过有人把 swappiness 设成 100,结果 SSD 的寿命被 Swap 读写耗掉一半。

先看几个关键参数:

参数 位置 推荐值 说明
swappiness /proc/sys/vm/swappiness 10-30 越低越倾向不换出
min_free_kbytes /proc/sys/vm/min_free_kbytes 物理内存 1%-3% 保留给紧急分配
oom_adj /proc/PID/oom_adj -17(禁用OOM) 调整进程被杀的优先级

为什么会 OOM?说白了,内核有一个 oom_killer 机制。当内存耗尽且 Swap 也满了,内核会选一个进程杀掉。选谁?看 oom_score。分数越高,越容易被杀。

避坑指南: 我曾经把数据库的 oom_adj 设成 -17,以为能保它不死。结果内核杀了它的日志进程,数据库写不了日志直接挂掉。嗯,后来我学乖了——关键进程要配 memory cgroup 做硬限制,而不是靠 oom_adj 硬扛。

监控 Swap 使用,我常用:

# 查看 Swap 总量和使用量
free -h

# 查看每个进程的 Swap 使用
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
  swap=$(grep VmSwap /proc/$pid/status 2>/dev/null | awk '{print $2}')
  if [ -n "$swap" ] && [ "$swap" != "0" ]; then
    echo "PID $pid: Swap $swap"
  fi
done | sort -k3 -rn | head -10
注意: 如果发现大量 Swap 使用,先检查是不是 swappiness 太高,或者有进程在疯狂分配内存。不要盲目关 Swap,否则 OOM 会更频繁。

4.4 大页内存:让 TLB 飞起来

大页内存(HugePages)是我在数据库场景里最常用的优化手段。你想想看,默认 4KB 的页,一个 2MB 的大页能覆盖 512 个普通页。TLB 的条目数有限,用大页等于让 TLB 的覆盖范围扩大 512 倍。

Linux 支持两种大页:

  • HugeTLB (静态大页):启动时预留,需要应用显式使用 mmap 或 shmget 加 MAP_HUGETLB 标志。
  • 透明大页 (THP):内核自动将连续 4KB 页合并为 2MB 大页,对应用透明。

配置 HugeTLB 的步骤:

# 预留 1024 个 2MB 大页
echo 1024 > /proc/sys/vm/nr_hugepages

# 查看大页使用情况
cat /proc/meminfo | grep Huge
# HugePages_Total:    1024
# HugePages_Free:     1024
# HugePages_Rsvd:        0
# HugePages_Surp:        0
# Hugepagesize:       2048 kB

透明大页虽然方便,但有个坑——它会在后台做内存规整(compaction),导致 CPU 突刺。我遇到过 MySQL 因为 THP 的 compaction 导致查询延迟从 1ms 飙到 200ms。

我的建议: 对于延迟敏感的应用(数据库、缓存),关掉 THP,手动配置 HugeTLB。对于批处理或吞吐型应用,可以保留 THP。

关闭 THP 的方法:

echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

用大页分配内存的代码示例:

#include <sys/mman.h>
#include <stdio.h>

int main() {
    // 分配 2MB 大页
    void *ptr = mmap(NULL, 2 * 1024 * 1024,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap hugepage failed");
        return 1;
    }
    // 使用内存...
    munmap(ptr, 2 * 1024 * 1024);
    return 0;
}
小技巧: 用 perf stat -e dTLB-load-misses,iTLB-load-misses ./app 观察 TLB 缺失率。如果缺失率超过 1%,大页优化通常能带来明显收益。

最后说一句,内存优化没有银弹。分配策略、泄漏检测、Swap 控制、大页配置,这四块要结合起来看。我习惯先做监控,再定策略,最后调参数。嗯,这样不容易翻车。