Linux 内核内存管理深度实战:从虚拟地址到物理页面的全链路解析
内存管理是 Linux 内核最复杂、最核心的子系统之一。无论是页面回收导致的服务延迟毛刺,还是 THP 引发的数据库性能崩溃,亦或是容器 OOM 带来的线上事故 —— 这些问题的根源都在于对内存子系统缺乏深度理解。本文从硬件 TLB 出发,完整拆解虚拟地址转换、物理分配器、反向映射、页面回收、大页机制和 cgroup 内存控制器的全链路原理,并给出生产环境调优的实战方案。
一、虚拟内存与页表遍历:CPU 视角的地址转换
现代处理器通过 MMU(Memory Management Unit)完成虚拟地址到物理地址的转换。x86_64 架构采用四级页表结构,在 57 位虚拟地址空间(5-Level Paging)下扩展为五级:
CR3 → PGD → P4D → PUD → PMD → PTE → Physical Page (4KB)
每个页表项(PTE)占用 8 字节,包含物理页框号(PFN)、权限位(R/W/X)、User/Supervisor 标志、Accessed/Dirty 位以及 NX(No-Execute)位。以 4KB 页为例,每级页表恰好占一个页面,可容纳 512 项(9 级索引)。
关键代码路径位于 arch/x86/mm/pgtable.c:
// 虚拟地址分解为页表索引
static inline pgd_t *pgd_offset(struct mm_struct *mm, unsigned long addr)
{
return mm->pgd + pgd_index(addr);
}
// 遍历页表的典型实现
pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{
return (pte_t *)pmd_virtual(*pmd) + pte_index(address);
}
TLB(Translation Lookaside Buffer) 是 MMU 的缓存,现代 CPU 通常具备 L1 iTLB(128 项)、L1 dTLB(64 项)和 L2 STLB(1536-2048 项)。TLB miss 会触发硬件 Page Walk,在最坏的四级页表场景下需要 5 次内存访问 —— 这也是为什么大页(2MB/1GB)能显著提升 TLB 命中率。
当进程切换时,Linux 通过加载新进程的 mm->pgd 物理地址到 CR3 寄存器来切换地址空间。从内核 5.10 开始,PCID(Process-Context Identifier)支持允许 TLB 条目在进程间保留,避免每次上下文切换都冲刷 TLB,显著降低了多进程场景下的切换开销。
二、物理内存分配器:Buddy System 与 SLUB
Buddy System
Linux 将物理内存组织为 mem_map[] 数组,每个元素对应一个 struct page。物理内存按 Zone 划分:ZONE_DMA(16MB)、ZONE_DMA32(4GB)、ZONE_NORMAL、ZONE_HIGHMEM(仅 32 位系统)。
Buddy System 维护 11 个 free_area 链表(order 0-10),分别管理 4KB、8KB、...、4MB 大小的连续物理块:
struct zone {
free_area_t free_area[MAX_ORDER]; // order N: 2^N pages
};
// 分配 2^order 个连续页面
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
分配过程从合适的 order 链表取出一个块,若该 order 链表为空,则从更高 order 链表取块并"分裂"(buddy split)。释放时,检查 buddy 块是否空闲,若空闲则合并(coalesce)为更大的块。
SLUB 分配器
SLUB(Unqueued Slab Allocator)是 Linux 默认的小对象分配器,替代了早期的 SLAB 和 SLOB。每个 cache 对应一种内核对象类型(如 task_struct、inode、dentry):
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // Per-CPU 快速路径
struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点
unsigned int object_size;
unsigned int offset;
struct kmem_cache_order_objects oo; // 最佳 order
};
SLUB 的核心优化是 Per-CPU 的 freelist:每个 CPU 维护一个本地空闲对象链表,分配和释放时无需加锁,仅在本地链表为空时才从 partial 链表补充。这对于高频分配/释放场景(如网络收发包中的 sk_buff)至关重要。
生产环境诊断:
# 查看 slab 内存占用分布
cat /proc/slabinfo | head -30
# slabtop -o 实时查看 slab 占用
slabtop -o
# 如果 dentry 或 inode_cache 增长异常:
# 说明可能存在文件描述符泄漏或目录遍历
三、反向映射与页面回收
Reverse Mapping (rmap)
当物理页面需要被回收时,内核需要找到所有映射该页面的 PTE 并将其标记为 not-present。反向映射正是解决这一问题的机制,分为两类:
-
匿名页(Anonymous Page):通过
anon_vma链表。每个mm_struct中的匿名页面都有一个anon_vma结构,形成树状结构以便 fork 后的 COW 共享。回收时从anon_vma->rb_root遍历所有关联的 VMA。 -
文件页(File-backed Page):通过
address_space的 radix tree / XArray。文件映射的页面按文件偏移量组织在文件的address_space中,回收时直接定位到文件对应的 page cache 节点。
// 反向映射的核心入口
int try_to_unmap(struct page *page, enum tmu_flags flags)
{
if (PageAnon(page))
return try_to_unmap_anon(page, flags);
else
return try_to_unmap_file(page, flags);
}
LRU 页面回收
Linux 使用 LRU(Least Recently Used)算法管理页面回收。从内核 5.0 开始采用 Active/Inactive 双链表设计:
- Active List:最近被访问过的页面
- Inactive List:长期未访问的候选回收页面
页面在两个链表间移动:第二次访问时从 Inactive 提升到 Active;当 Active 链表过大时,尾部的页面被降级到 Inactive。回收时优先扫描 Inactive 链表尾部。
页面回收由 kswapd 后台线程驱动,当空闲内存低于 wmark_low 时唤醒。从内核 5.0 引入的 PSI(Pressure Stall Information)接口可以更精确地监控页面回收带来的调度延迟:
# 查看 CPU/IO/内存压力
cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=123456789
# 设置内存压力阈值(cgroup v2)
echo "some 50000 100000" > /sys/fs/cgroup/myapp/memory.pressure
OOM Killer
当所有回收手段用尽仍无法释放足够内存时,OOM Killer 被触发。Linux 使用 oom_score 评估每个进程的"可杀性":
// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
// 基于 RSS、swap 占用、CPU 时间、oom_score_adj 综合计算
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
points *= 1000;
points /= totalpages;
// 应用 oom_score_adj 调整
points += p->signal->oom_score_adj;
return points > 0 ? points : 1;
}
生产建议: 对关键数据库和缓存服务设置 oom_score_adj=-1000 防止被 OOM;对批处理任务设置正值加速被杀。
四、大页与透明大页
HugePages(静态大页)
静态大页需要在系统启动时通过 hugepages=1024 内核参数预分配,或在运行时通过 sysctl vm.nr_hugepages=1024 在 NUMA 节点分配。2MB 大页的 TLB 覆盖面积为 2MB × TLB entries,相比 4KB 小页的覆盖能力提升 512 倍。
# 查看当前大页配置
cat /proc/meminfo | grep Huge
# 预留 1024 个 2MB 大页
echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 巨页文件系统挂载
mount -t hugetlbfs hugetlbfs /mnt/huge
使用静态大页需要在程序中显式映射(mmap(MAP_HUGETLB)),PostgreSQL 和 DPDK 等高性能应用广泛使用这种方式。
透明大页(THP)
透明大页(Transparent Huge Pages)是内核自动的透明合并机制:当检测到连续的 4KB 小页满足合并条件时,khugepdd 守护进程自动合并为 2MB 大页。
# THP 三种模式:always / madvise / never
cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
THP 的生产陷阱: 对于数据库工作负载(PostgreSQL、MongoDB、Redis),THP 经常导致性能下降。原因是: 1. THP 失败时的 compaction 操作造成严重延迟尖峰 2. 数据库的随机访问模式使得大页的 TLB 优势无法体现 3. Defragmentation 的开销抵消了 TLB 收益
建议: 数据库和内存缓存系统建议设置 transparent_hugepage=never,网络代理和 HPC 应用建议设置为 always 或显式 madvise(MADV_HUGEPAGE)。
五、KSM:内核同页合并
KSM(Kernel Same-page Merging)是内核的去重机制,最初为 KVM 虚拟机密度优化设计。KSM 定期扫描内存区域,将内容相同的物理页面合并为同一只读页面,多余的页面被释放。
# 启用 KSM
echo 1 > /sys/kernel/mm/ksm/run
# 合并一个 VMA 区域(用户态调用 madvise)
madvise(addr, length, MADV_MERGEABLE);
核心参数调优:
# 每次扫描的页数(默认 100)
echo 1000 > /sys/kernel/mm/ksm/pages_to_scan
# 两次扫描间的毫秒数(默认 200)
echo 20 > /sys/kernel/mm/ksm/sleep_millisecs
KSM 的收益与代价需要权衡:高密度虚拟机部署可节省 50%+ 内存,但 CPU 开销显著。建议仅在同构虚拟机或重复内容较多的场景启用。
六、cgroup v2 内存控制器
cgroup v2 提供了精细的内存控制能力:
# 设置内存硬限制
echo "2G" > /sys/fs/cgroup/myapp/memory.max
# 设置内存软限制(高压力下生效)
echo "1.5G" > /sys/fs/cgroup/myapp/memory.high
# 设置 swap 限制
echo "512M" > /sys/fs/cgroup/myapp/memory.swap.max
# 查看 OOM 事件计数
cat /sys/fs/cgroup/myapp/memory.events
关键参数说明:
| 参数 | 作用 | 生产建议 |
|---|---|---|
memory.max |
硬限制,触发 cgroup OOM | 设置为容器规格的 1.1-1.2 倍 |
memory.high |
软限制,触发回收和限流 | 设置为容器规格的 80-90% |
memory.low |
保护阈值,避免被其他 cgroup 挤占 | 设置为关键业务最小保障 |
memory.min |
绝对保护,不被回收 | 仅用于极关键页面 |
memory.swap.max |
swap 限制 | 有状态服务建议 0(禁用 swap) |
PSI 与 QoS 联动: 在现代 Kubernetes 部署中,建议将 PSI memory pressure 与 HPA 联动:当 memory.some 压力超过阈值时提前扩容,而不是等 OOM 发生后才响应。
七、实战:三个典型内存问题的排查与修复
场景一:数据库延迟毛刺
现象: Redis 平均延迟 0.5ms,P99 达 200ms,每秒数百次毛刺。
排查: 通过 /proc/pressure/memory 发现 some avg10 周期性飙升至 80%+。进一步通过 perf 跟踪发现是 khugepaged 触发的页面 compaction 占据了 IO 带宽。
# 解决方案:禁用透明大页
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 验证:设置后毛刺消失,P99 降至 2ms
场景二:容器频繁 OOM
现象: Java 容器每天 OOM 3-5 次,heap 仅使用 60%。
排查: 通过 memory.stat 发现 page cache 占用 1.5GB,远超过 JVM heap 之外剩余空间。JVM 的 NIO Direct ByteBuffer 和 memory-mapped file 绕过 cgroup 内存统计。
# 方案一:降低 cgroup limit,为 page cache 留出空间
echo "3G" > /sys/fs/cgroup/java/memory.max # 原 4G
# 方案二:限制 JVM MaxDirectMemorySize
java -XX:MaxDirectMemorySize=256m -XX:MaxDirectMemorySize=512m ...
# 方案三:使用 jemalloc 替代 glibc malloc,更好控制 RSS
场景三:slab 内存泄漏
现象: K8s 节点可用内存持续下降,重启 pod 后短暂恢复但很快复现。
排查:
# 检查 slab 分布变化
watch -n1 "cat /proc/slabinfo | awk '{print \$1, \$2*\$3/1024\"KB\"}' | sort -k2 -nr | head"
# 发现 dentry 和 inode_cache 持续 200MB/分钟增长
# 定位到应用在不断创建临时文件但未清理
# 查看具体缓存对象
slabtop -o | head
# 临时清理(测试用)
echo 2 > /proc/sys/vm/drop_caches # 清除 slab 和 page cache
八、总结
Linux 内存管理是一个环环相扣的系统工程:TLB 命中率决定地址转换开销,Buddy System 保证物理连续性,SLUB 优化小对象高频分配,反向映射使页面回收可行,LRU 双链表兼顾命中率与回收效率,大页机制放大 TLB 覆盖范围,KSM 释放冗余内存,cgroup 提供精细的 QoS 分层。
生产环境中的内存问题往往不是单一模块的故障,而是多个环节级联反应的结果。建议构建以下监控体系:
- page-level:
pgscan_kswapd,pgsteal_kswapd,pswpin/pswpout追踪回收与 swap - slab-level:定期快照
/proc/slabinfo监控异常增长 - cgroup-level:
memory.current,memory.events.oom,memory.pressure - application-level:GC pause, RSS, PSS, USS 多维交叉分析
理解这些底层机制,才能在面对复杂内存问题时快速定位根因,而非救火式地重启服务。

发表评论 取消回复