Linux内核内存管理深度工程实战:从SLUB分配器到页面回收与OOM调优的完整内存子系统设计
一、内存管理子系统架构全景
Linux内核的内存管理子系统(MM)是整个操作系统中最复杂、最核心的模块之一。它不仅负责物理内存的分配与回收,更是连接处理器架构、进程管理、文件系统和设备驱动的关键枢纽。理解内存管理的完整设计,是掌握Linux内核工程实战的必经之路。
Linux内存管理采用分层架构,从底层到上层依次为:
- 物理内存模型层:NUMA/Zone/Page三级结构,管理物理内存拓扑
- 分配器层:Buddy分配器(页级)→ SLAB/SLUB/SLOB(对象级)→ kmalloc/vmalloc
- 虚拟内存层:VMA管理、缺页异常、COW(写时复制)、HugeTLB
- 回收与压缩层:LRU页面回收、KSM、Zswap/Zram、Swap
- 控制层:cgroup内存控制、OOM Killer、内存热插拔
二、物理内存模型:NUMA、Zone与Node
2.1 NUMA拓扑与pg_data_t
在现代多核服务器架构中,NUMA(Non-Uniform Memory Access)拓扑决定了内存访问延迟的差异。Linux内核通过pg_data_t数据结构描述每个NUMA节点:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 节点的内存区域
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
int nr_zones; // 区域数量
struct page *node_mem_map; // 页描述符数组
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 可用页数
unsigned long node_spanned_pages; // 总页数(含空洞)
int node_id; // 节点ID
// ...
} pg_data_t;
每个NUMA节点被划分为多个Zone,常见的Zone类型包括:
- ZONE_DMA:0-16MB,用于ISA设备DMA
- ZONE_DMA32:4GB以内,32位DMA设备
- ZONE_NORMAL:直接映射区(x86_64上为1GB-物理内存大小)
- ZONE_MOVABLE:可移动内存区,用于内存热插拔
- ZONE_DEVICE:设备内存映射区
2.2 页描述符struct page
物理内存的最小管理单位是页(通常4KB),每个物理页对应一个struct page描述符:
struct page {
unsigned long flags; // 页状态标志(PG_locked, PG_dirty等)
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射计数
unsigned long private; // 私有数据指针
struct address_space *mapping; // 地址空间(文件页/匿名页)
pgoff_t index; // 在映射中的偏移
struct list_head lru; // LRU链表节点
// ...
};
_refcount是页的引用计数:0表示空闲,大于0表示被使用。_mapcount记录页表映射次数:-1表示未映射,0表示仅内核映射,大于0表示有用户空间映射。
三、Buddy分配器:页级物理内存分配
3.1 伙伴算法核心原理
Buddy分配器是物理页分配的基础设施,通过维护11个空闲链表(order 0-10)来管理不同大小的连续页块:
- order 0:单页(4KB)
- order 1:2页(8KB)
- order 2:4页(16KB)
- order 10:1024页(4MB)
分配时从满足要求的最小order开始查找,若无可用块则向更大order递归查找并分裂;释放时将伙伴块合并为更大的空闲块。
// 伙伴判断static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1UL << order);
}
伙伴关系的判断非常精妙:对order n的块,其伙伴的页帧号与当前块页帧号仅在第n位不同。这意味着伙伴块在物理地址和总是(2^n) * PAGE_SIZE的整数倍,保证了合并后总是对齐的。
3.2 per-cpu page allocator (PCP)
为了减少多核竞争和避免虚假共享,Linux引入了per-cpu页缓存(PCP)。每个CPU维护一个本地空闲页列表,小分配可以直接从本地列表获取:
struct per_cpu_pages {
int count; // 列表中页数
int high; // 高水位,超过则归还buddy
int batch; // 每次从buddy申请的数量
struct list_head lists; // 空闲页链表
};
当PCP列表低于low水位时,从Buddy分配器批量申请batch个页;当超过high水位时,归还多余页面给Buddy。
3.3 分配掩码GFP flags
GFP(Get Free Pages)掩码编码了分配行为和约束:
- __GFP_RECLAIM:允许直接回收
- __GFP_IO:允许I/O操作(写脏页)
- __GFP_FS:允许文件系统操作
- __GFP_ZERO:清零分配的页
- __GFP_NOFAIL:分配不可失败(可能无限循环回收)
- __GFP_NOWARN:分配失败不打印警告
- GFP_KERNEL:内核常规分配(可能休眠,可回收)
- GFP_ATOMIC:原子上下文分配(不休眠,优先使用紧急储备)
四、SLUB分配器:内核对象分配的核心引擎
4.1 SLAB分配器家族演进
- SLOB:简单链表式分配器,适用于嵌入式系统
- SLAB:Solaris设计,全局的kmem_cache管理
- SLUB:默认分配器,简化设计,NUMA友好,低碎片
SLUB的核心思想是:每个CPU维护本地部分空页(partial page),优先从本地分配;NUMA系统为每节点维护本地缓存。
4.2 kmem_cache核心数据结构
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // per-cpu缓存
unsigned long flags;
unsigned int size; // 对象大小
unsigned int object_size; // 实际对象大小
unsigned int offset; // 下一个空闲对象偏移
struct kmem_cache_node *node[MAX_NUMNODES]; // per-node缓存
struct kmem_cache_order_objects oo; // 最佳order
// ...
};
分配器层级关系:kmem_cache → kmem_cache_node → page → object。每个cache管理特定大小的对象,所有cache通过双向链表slab_caches连接。
4.3 SLUB分配流程详解
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfpflags)
{
// 1. 检查cpu_slab->freelist(per-cpu空闲对象)
void *object = smp_load_acquire(&s->cpu_slab->freelist);
if (likely(object)) {
// 快速路径:直接从per-cpu获取
s->cpu_slab->freelist = get_freepointer(s, object);
return object;
}
// 2. 检查cpu_slab->page是否有空闲位
// 通过检查page的inuse位
// 3. 检查cpu_slab->partial是否有空页
// 4. 从node->partial获取部分空页
// 5. 从buddy分配新页
}
SLUB的快速路径(fast path)极为高效:仅需一次内存加载和指针更新即可返回对象。这是SLUB在高性能场景下的核心优势。
4.4 SLUB空闲对象链表:侵入式空闲指针
SLUB巧妙利用内存本身存储空闲对象链表指针。当对象未被分配时,其前8字节(64位系统)存储下一个空闲对象的地址指针:
static inline void *get_freepointer(struct kmem_cache *s, void *object)
{
return freelist_dereference(s, object + s->offset);
}
static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
unsigned long freeptr_addr = (unsigned long)object + s->offset;
*(void **)freeptr_addr = fp;
}
注意offset参数:当对象大小小于指针大小时,offset=0(直接使用对象起始位置存指针);当对象较大时,offset等于对象大小(指针存在对象末尾)。
4.5 SLUB的Red Zone与Poison检测
SLUB内置强大的越界访问和释放后重用检测机制:
- Red Zone:在对象前后填充
0x12347和0x12349魔数,检测写越界 - Poison:释放后填充
0x5a或0xa5,检测释放后重用 - 跟踪信息:记录每次alloc/free的调用栈
开启CONFIG_DEBUG_SLUB后,这些机制可有效捕获内核内存损坏问题。
五、页面回收与LRU算法
5.1 LRU链表设计
Linux采用近似LRU的双链表设计(active/inactive),每个zone维护两组链表:
- active_list:活跃页,最近被访问过
- inactive_list:非活跃页,回收候选
页的活跃度通过PG_active标志区分。当inactive页被访问时,通过mark_page_active提升至active链表尾部;当active链表远大于inactive时,将active页的PG_active清除,移入inactive链表。
5.2 回收扫描:shrink_lruvec
页面回收的核心入口是shrink_lruvec():
static unsigned long shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
{
unsigned long nr[NR_LRU_LISTS];
unsigned long reclaim_nr;
// 统计各inactive页数量
// 按比例扫描active/inactive列表
// 回收inactive列表尾部的页
// 匿名页:写入swap area再释放
// 文件页:若脏则写回,否则直接释放
}
扫描控制参数scan_control包含:nr_to_scan(目标扫描数)、priority(扫描优先级,越高越激进)、may_writepage(是否允许写页)等。
5.3 回收水位的触发机制
内核通过三个水位触发内存回收:
- free > high watermark:无需回收
- low < free ≤ high:唤醒kswapd后台回收 li>min < free ≤ low:直接回收(direct reclaim),分配者同步阻塞回收
- free ≤ min:紧急回收 + OOM风险
水位计算:min = managed_pages * min_free_kbytes / 100 / 4,low = min * 5/4,high = min * 3/2。
5.4 Swap与匿名页回收
匿名页(堆栈、mmap匿名映射)无法直接丢弃,必须写入Swap area才能释放:
- 选择牺牲页(inactive列表尾部,优先无访问位)
- 分配swap slot
- 构造bio并写入swap设备
- 建立PTE条目(指向swap entry)
- 访问时触发缺页,再从swap读取回来
页面回收中的二次机会算法:检查页的PG_referenced位,若被访问过则给予第二次机会(清除accessed bit后放回LRU)。
六、KSM、Zswap与Zram:内存压缩技术
6.1 KSM(Kernel Samepage Merging)
KSM通过扫描查找内容相同的页,合并为一份物理副本+多份COW映射。适用于虚拟化场景(大量相同OS页面):
// KSM主要数据结构
struct rmap_item {
struct rmap_item *rmap_list; // 相同值的rmap链表
struct anon_vma *anon_vma; // 反向映射
struct mm_struct *mm; // 所属进程
unsigned long address; // 虚拟地址
unsigned int checksum; // 内容校验和(快速筛选)
};
KSM工作流程:将页面插入稳定树(red-black tree),新页面通过checksum和逐页比较找到重复项。合并时设置写保护(COW),写时分离。
6.2 Zswap:压缩交换缓存
Zswap在页面写入swap前,先用LZ4/ZSTD压缩存储在内存缓存中:
- 压缩比通常2:1到3:1
- 压缩失败的页直接写入磁盘swap
- LRU淘汰最旧的压缩缓存页到磁盘swap
- 完全透明:对上层仍是常规swap行为
参数/sys/module/zswap/parameters/:enabled(开关)、compressor(压缩算法)、max_pool_percent(占内存最大百分比)。
6.3 Zram:内存中的块设备
Zram创建基于内存的块设备用作swap空间:
# 创建zram swap
echo lz4 > /sys/block/zram0/comp_algorithm
echo 2G > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon /dev/zram0 -p 100
Zram适合内存较小的嵌入式设备或容器环境,但也可能增加CPU开销。
七、OOM Killer:最后的防线
7.1 OOM触发条件与流程
OOM Killer在所有回收尝试失败且无法从buddy获得页时触发:
- page分配器遍历zonelist,尝试回收
- 直接回收失败,尝试OOM前先再尝试一次分配
- 仍然失败 → 调用
out_of_memory() select_bad_process()选择终止目标oom_kill_process()发送SIGKILL
7.2 OOM评分算法:oom_badness
每个进程通过oom_badness()计算得分:
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
// 基础分:占总内存百分比(每1% = 1分)
// CPU占用时间加权(CPU时间越长,分数越高)
// oom_score_adj调整(-1000到+1000)
// 特殊进程(init、内核线程)跳过
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm_pmd_pages;
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) return 0; // 永不杀死
points = points * 1000 / totalpages;
points += adj;
return max(0L, points);
}
7.3 控制OOM行为
- /proc/[pid]/oom_score_adj:-1000(永不kill)到+1000(优先kill)
- /proc/[pid]/oom_adj:旧接口,-17到+15
- vm.overcommit_memory:0=启发式,1=永远允许,2=严格检查
- vm.overcommit_ratio:overcommit=2时可分配内存比例
- vm.panic_on_oom:0=kill进程,1=panic,2=强制panic
- cgroup memory.limit_in_bytes:cgroup级别OOM
八、cgroup内存控制:容器级隔离
8.1 memory controller核心参数
- memory.limit_in_bytes:硬限制,超过触发cgroup OOM
- memory.soft_limit_in_bytes:软限制,仅在系统内存紧张时生效
- memory.kmem.limit_in_bytes:内核内存限制(SLAB、TCP栈等)
- memory.swappiness:该cgroup的swap倾向(v1)
- memory.swap.max:swap使用上限
- memory.oom.group:cgroup整体OOM(v2)
8.2 cgroup v2内存控制
cgroup v2引入了更统一的资源控制:
# cgroup v2 内存配置
echo "512M" > /sys/fs/cgroup/myapp/memory.max # 硬限制
echo "384M" > /sys/fs/cgroup/myapp/memory.high # 高水位(开始回收)
echo "256M" > /sys/fs/cgroup/myapp/memory.low # 低水位(保护)
echo "128M" > /sys/fs/cgroup/myapp/memory.min # 最小保证
echo "0" > /sys/fs/cgroup/myapp/memory.swap.max # 禁用swap
九、HugeTLB与大页内存
9.1 透明大页(THP)
透明大页(Transparent Huge Pages)将普通4KB页透明合并为2MB(AMD64/ARM64)大页,减少TLB miss率:
/sys/kernel/mm/transparent_hugepage/enabled = [always|madvise|never]
always: 全局启用
madvise: 仅在进程调用madvise(MADV_HUGEPAGE)时启用
never: 完全禁用
THP通过khugepaged后台线程扫描并合并页面。适合数据库、Java等有大内存工作集的应用。但可能引入延迟抖动,对延迟敏感场景推荐静态大页。
9.2 静态大页(HugeTLB)
静态大页在启动时预分配,不参与Buddy管理:
# 启动参数hugepages=1024 hugepagesz=2M
# 或运行时
echo 1024 > /proc/sys/vm/nr_hugepages
# 挂载
mount -t hugetlbfs hugetlbfs /dev/hugepages
# 使用mmap
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB, -1, 0);
静态大页不可被swap,无碎片风险,适合DPDK、QEMU/KVM、数据库等场景。
十、vmalloc与高端内存映射
10.1 vmalloc:分配虚拟连续但物理不连续的内存
vmalloc用于分配大块虚拟连续内存(物理页可分散):
void *vmalloc(unsigned long size)
{
// 1. 从Buddy分配物理页
// 2. 在vmalloc区找到虚拟地址空间
// 3. 建立页表映射(可能触发TLB刷新)
// 4. 返回虚拟地址
}
vmalloc的缺点:
- 需要页表操作(可能TLB flush)
- 物理不连续(DMA可能需要特别处理)
- 每次调用可能触发多页分配
- 最大可用空间受vmalloc区限制(通常128MB到数GB)
10.2 高端内存(High Memory)与32位遗留
在32位系统(4GB地址空间)中,内核空间通常1GB,其中低端896MB直接映射,剩余128MB作为vmalloc/ioremap窗口用于访问高端内存。
64位系统(x86_64的47位或56位虚拟地址)有极大地址空间,高端内存几乎不存在。vmalloc主要用于需要虚拟连续但物理不连续的大块分配场景(如模块加载、视频采集卡缓冲)。
十一、内存管理调优实践
11.1 数据库场景(MySQL/PostgreSQL/Redis)
# 禁用THP(减少延迟抖动)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 降低swappiness,避免swap命中buffer pool
sysctl -w vm.swappiness=1
# 增大min_free_kbytes,减少直接回收风险
sysctl -w vm.min_free_kbytes=262144 # 1GB机器
# NUMA本地化
numactl --interleave=all mysqld
11.2 容器/Kubernetes场景
# 确保容器有合理的memory.high,避免OOM during burst
# 配置适当的oom_score_adj保护关键进程
# 使用Zswap减少swap I/O延迟
# 避免swap抖动:设置合理的memory.swap.max
11.3 网络高性能(DPDK/eBPF场景)
# 使用静态大页支持DPDK
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 或1GB大页
echo 8 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages
11.4 高并发Java/Go服务
# THP对Java可能有正面效果(大堆扫描),也可能负面(GC停顿抖动)
# 测试对比后决定
# Go服务建议使用madvise模式避免不必要的大页合并
十二、内存诊断工具箱
12.1 slabtop:实时监控SLAB/SLUB使用
$ slabtop -o
Active / Total Objects (% used) : 125478 / 130205 (96.4%)
Active / Total Slabs (% used) : 3847 / 3847 (100.0%)
Active / Total Caches (% used) : 114 / 164 (69.5%)
Active / Total Size (% used) : 28.45M / 29.12M (97.7%)
Minimum / Average / Maximum Object : 0.01K / 0.22K / 8.00K
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
85714 85714 100% 0.19K 4083 21 16332K dentry
38322 37246 97% 0.10K 983 39 3932K buffer_head
13938 13938 100% 0.57K 1072 13 8576K inode_cache
12.2 vmstat:内存与Swap实时监控
$ vmstat 1
procs -----------memory---------- ---swap-- -----io----
r b swpd free buff cache si so bi bo
0 0 0 524288 81920 1048576 0 0 0 2
1 0 0 522240 81920 1050624 0 0 512 128
关键指标:si/so(swap in/out速率)若持续大于0说明严重的内存压力。
12.3 sar与/proc/meminfo深度分析
$ cat /proc/meminfo
MemTotal: 16777216 kB
MemFree: 524288 kB
MemAvailable: 8388608 kB # 可用的内存(含可回收页)
Buffers: 81920 kB
Cached: 1048576 kB
SwapCached: 0 kB
Active: 6291456 kB
Inactive: 2097152 kB
Active(anon): 4194304 kB
Inactive(anon): 524288 kB
Active(file): 2097152 kB
Inactive(file): 1572864 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 8388608 kB
SwapFree: 8388608 kB
Dirty: 256 kB
Writeback: 0 kB
AnonPages: 4194304 kB
Mapped: 1048576 kB
Shmem: 524288 kB
Slab: 262144 kB
SReclaimable: 196608 kB
SUnreclaim: 65536 kB # 内核不可回收内存
KernelStack: 16384 kB
PageTables: 32768 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 16777216 kB
Committed_AS: 16777216 kB # 已提交的虚拟内存
VmallocTotal: 34359738367 kB
VmallocUsed: 65536 kB
HugePages_Total: 0
HugePages_Free: 0
Hugepagesize: 2048 kB
MemAvailable从3.14内核开始提供,它估算了在不触发OOM的前提下可使用的实际内存量(含page cache中可快速回收的部分)。
12.4 BPF工具追踪内存行为
# 追踪SLUB分配
$ bpftrace -e 'kprobe:slub_alloc { @[comm] = count(); }'
# 查看直接回收延迟分布
$ bpftrace -e 'kprobe:direct_reclaim_begin { @start = nsecs; }
kprobe:direct_reclaim_end /@start/ {
@us = hist((nsecs - @start) / 1000);
delete(@start);
}'
# 追踪OOM事件
$ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill pid %d (%s)\n",
((struct task_struct *)arg0)->pid,
((struct task_struct *)arg0)->comm); }'
十三、NUMA感知内存分配策略
13.1 NUMA分配策略
- LocalAlloc(默认):从请求CPU所在的NUMA节点分配
- Preferred:优先从指定节点分配,失败回退
- Bind:必须在指定节点分配,失败则OOM
- Interleaved:轮询分配,分散负载
13.2 NUMA Balancing(AutoNUMA)
Linux 3.8+引入自动NUMA平衡(AutoNUMA):
- 扫描进程页表,标记远程映射页
- 采样访问延迟(通过页面 fault 时间)
- 将冷页迁移到本地节点
- 甚至迁移整个进程(migrate task to node)
# 查看NUMA状态
$ numastat
node0 node1
numa_local 12345678 98765432
numa_foreign 1234567 9876543
numa_hit 120000000 950000000
numa_miss 123456 9876543
numa_interleaved 1234 98765
numa_miss表示原本应在node0的内存分配到了node1(或反之),值高说明NUMA本地化效果不佳。
十四、生产环境经典案例
案例1:容器OOM导致服务雪崩
某K8s集群中,Java Pod频繁OOM但cgroup限制充足。经诊断发现是Slab中的dentry和inode缓存占用了过多不可回收内存(SUnreclaim)。
解决方案:
- 降低
vfs_cache_pressure加速回收:vm.vfs_cache_pressure=50 - 设置合理的cgroup kmem限制
- 重启问题Pod清理泄漏的shmem
案例2:数据库被Swap打挂
某PostgreSQL实例突然响应延迟飙升。经查vm.swappiness=60导致Swap大量使用。
根因:Slab + buffer cache 消耗了大量内存 → kswapd 唤醒 → 数据库工作集被swap。
解决:sysctl vm.swappiness=1 + 启用Zswap压缩缓存。
案例3:NUMA远程访问导致TPS下降30%
某高频交易系统跨NUMA节点访问内存,每次访问增加~50ns。TPS从100万降至70万。
解决:
- numactl绑核绑内存
- 启用Auto NUMA Balancing
- 调整中断亲和性
核心应用场景的内存本地化至关重要。
十五、前沿演进:内存管理的下一个十年
- CXL(Compute Express Link)内存池化:打破单机内存限制,实现跨节点共享内存池
- Unmapping Guest内存:KVM的MSEV-SNP和TDX加密内存
- Linux Tiered Memory:NPM(Non-Volatile Persistent Memory)多级管理
- Rust for Linux内存安全:引入Rust ABI减少内核内存损坏
- io_uring注册的缓冲区:固定缓冲区减少TLB shootdown
- Transparent Page Placement:异构内存(HBM+DRAM)自动页面分层
- cgroup v2统一控制完善:更精细的递归限制和事件通知
总结
Linux内存管理子系统是一个经过20多年锤炼的精密系统工程。从Buddy分配器的伙伴算法,到SLUB分配器的per-cpu无锁快速路径,从LRU双链表的近似回收,到OOM的最后防线,每一层都经过大量生产环境验证。
掌握这些机制不仅有助于系统级问题诊断,更能指导应用程序设计:理解cgroup内存控制有助于写出更"容器友好"的代码,理解NUMA效应有助于降低内存访问延迟,理解Fragmentation有助于选择合理的分配策略。

发表评论 取消回复