4. 内存性能优化:内存分配与回收、内存泄漏检测、Swap与OOM Killer、大页内存
内存,是系统性能的命脉。我见过太多线上事故,最后都指向了内存问题。说白了,内存管理就是一场「空间与时间」的博弈。这一章,我们聊聊那些真正能落地的内存优化手段。
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
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。分数越高,越容易被杀。
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
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 的方法:
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 控制、大页配置,这四块要结合起来看。我习惯先做监控,再定策略,最后调参数。嗯,这样不容易翻车。