Linux 内核内存管理深度剖析:从物理页框到虚拟地址空间
内存管理是 Linux 内核最核心、最复杂的子系统之一。它是连接硬件(物理内存、MMU、TLB)与上层应用(进程、文件系统、网络栈)的关键桥梁。理解内核内存管理,不仅是构建高性能系统程序的必备知识,更是排查 OOM、内存泄漏、Page Fault 性能瓶颈的基本功。本文将从物理页框分配出发,深入 Buddy System、Slab Allocator、VMA 页表管理,直至 OOM Killer 与生产实践。
一、物理内存模型:节点、区域与页框
1.1 NUMA 与内存节点
现代服务器普遍采用 NUMA(Non-Uniform Memory Access)架构。内存被划分为多个节点(pg_data_t),每个 CPU 访问本地节点的内存延迟最低,跨节点访问则延迟显著增加。
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; // 包含空洞的总页数
};
通过 numactl --hardware 可查看机器的 NUMA 拓扑。Linux 提供了 set_mempolicy()、mbind() 等系统调用来控制进程的内存分配策略。
1.2 内存区域(Zone)
每个节点内部又被划分为多个区域,反映不同 DMA 边界和内核访问能力:
- ZONE_DMA(0-16MB):传统 ISA 设备只能寻址低端内存。
- ZONE_DMA32(16MB-4GB):32 位 DMA 设备可访问范围。
- ZONE_NORMAL:内核线性映射区域,直接映射物理页框。
- ZONE_MOVABLE:可迁移页框区域,支持内存热插拔和碎片整理。
- ZONE_HIGHMEM:32 位系统上超过 ~896MB 的高端内存(64 位系统不再需要)。
通过 /proc/zoneinfo 可查看各区域实时状态:
$ cat /proc/zoneinfo | head -20
Node 0, zone DMA
pages free 3840
min 128
low 2004
high 2728
scanned 0
spanned 4095
present 3997
其中的 min/low/high 水位线直接决定了 page reclaim 的触发时机:低于 min 时直接回收同步阻塞;kswapd 在 min 与 high 之间异步回收;高于 high则认为内存充足。
1.3 页描述符(struct page)
物理内存的最小管理单位是页(通常 4KB)。每个物理页框都有一个对应的 struct page 描述符,全局数组 mem_map(或每个节点的 node_mem_map)保存所有描述符:
struct page {
unsigned long flags; // 页状态标志(pgtable/locked/dirty等)
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射到多少个页表
unsigned long private; // 私有数据指针(不同用途意义不同)
struct address_space *mapping; // 反向映射
pgoff_t index; // 在映射中的位置
struct list_head lru; // LRU 链表节点
};
一个 struct page 在 64 位系统上约 64 字节,意味着每 4GB 物理内存消耗约 64MB 页描述符——这是内核"永久"占用的一部分内存。
二、Buddy System:伙伴分配器
2.1 核心设计思想
Buddy System(伙伴系统)负责物理页框的分配与释放。它将空闲页框组织为 11 个链表,分别管理 2^0 ~ 2^10(即 1页 ~ 1024页/4MB)大小的连续块,满足 alloc_pages() 请求:
// mmzone.h
#define MAX_ORDER 11
struct free_area {
struct free_list free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
struct zone {
struct free_area free_area[MAX_ORDER];
...
};
当请求 2^n 个连续页框时:
- 在 order-n 链表中查找,有则直接返回。
- 无则向 order-(n+1) 取一块,均分后一半返回、一半插入 order-n 链表。
- 递归向上直至成功或失败。
释放时则相反:检查"伙伴"(地址相邻且同阶)是否也在空闲链表中,是则合并为 2^(n+1) 的块。
2.2 页面迁移类型(MIGRATE_TYPES)
Linux 进一步将每个 free_area 按迁移类型分类,减少碎片:
- MOVABLE:用户空间页面、页缓存,可迁移。
- RECLAIMABLE:不可移动但可回收(如 slab 对象)。
- UNMOVABLE:内核直接映射的页面、
kmalloc分配。 - PCP(Per-CPU Pageset):每个 CPU 的高速缓存,减少锁争用。
将可移动的页面聚集在一起,使得内存碎片整理(compact_zone())成为可能。
2.3 反碎片与 compaction
长期运行后,UNMOVABLE 页面散布各地会导致无法分配大块连续内存(如大页 hugepage、DMA 缓冲区)。Linux 引入:
- __GFP_MOVABLE/__GFP_RECLAIMABLE 分配标志:按类型分流。
- Memory Compaction(
kcompactd/compact_zone()):扫描 zone,将可移动页面迁移,腾出连续空间。 - 可配置:
/proc/sys/vm/compact_memory(sysrq 触发)、/sys/kernel/mm/compact。
2.4 水线机制与直接回收
分配失败时的回退路径:
- 异步回收:
kswapd在空闲页低于low时后台唤醒。 - 直接回收(Direct Reclaim):分配者同步执行,阻塞当前进程,扫描 LRU 链表回收页面。
- OOM Killer:回收仍不足时触发,杀死进程。
直接回收对延迟影响极大,生产环境应尽量避免触发。典型调优:
# 提高 kswapd 唤醒阈值(默认64,提高则回收更激进但减少直接回收)
sysctl -w vm.min_free_kbytes=262144 # 256MB,保留更多空闲
sysctl -w vm.swappiness=1 # 避免 swap,优先回收 page cache
三、Slab Allocator:内核对象分配
3.1 为什么需要 Slab
Buddy System 最小分配单位是页框(4KB),但内核中频繁创建销毁的对象远小于这个尺寸——如 task_struct、inode、dentry、socket buffer 等。若直接通过 Buddy System 分配,内部碎片会极其严重。因此 Linux 在 Buddy System 之上实现了 Slab Allocator:
- SLOB:极简实现,适用于嵌入式。
- SLAB:Linux 最初的 slab 实现,已弃用。
- SLUB:现代默认分配器,专为 SMP 优化。
3.2 SLUB 核心结构
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // 每 CPU 快速路径
struct kmem_cache_node *node[MAX_NUMNODES]; // 节点级慢路径
unsigned int size; // 对象大小
unsigned int object_size; // 用户请求大小
unsigned long flags;
const char *name;
...
};
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表
unsigned long tid; // 全局事务 ID
struct page *page; // 当前 CPU slab 页
struct page *partial; // 部分空闲 slab
};
SLUB 的设计哲学是:尽量减少全局锁争用,每 CPU 维护独立管理数据,因此"无锁"快速路径在热路径上几乎无可匹敌。
3.3 分配路径详解
kmem_cache_alloc() 的路径:
- CPU freelist(热路径):直接从 per-CPU freelist 取对象,无锁 O(1)。
- CPU page partial:freelist 为空但从 CPU 的 partial 页填充。
- Node partial:CPU partial 也空,从 NUMA 节点 partial 链表获取。
- 分配新 slab:全部耗尽时,通过 Buddy System 申请一个
kmem_cache->oo大小的页块。
3.4 kmalloc 与 vmalloc 的区别
kmalloc() 基于 SLUB,返回物理连续的内存(通过 Buddy System),适用于 DMA、硬件访问场景。vmalloc() 从虚拟地址空间分配一个虚拟连续但物理不连续的大块,仅适合纯软件使用。
关键权衡:
| 特性 | kmalloc | vmalloc |
|---|---|---|
| 物理连续 | 是 | 否 |
| 最大尺寸 | ~4MB(实际取决于 buddy order) | 接近寻址上限(128TB) |
| TLB 效率 | 高(可能用大页映射) | 低(多个 TLB 项) |
| 适用场景 | DMA,硬件交互,高频小对象 | 大缓冲区,模块加载,ioremap |
| 底层 | SLUB(或 SLOB) | Buddy + 映射 VMALLOC 区 |
注意:vmalloc 区域在内核地址空间有限(x86_64 上通常 128TB),且映射可能触发 TLB 刷新。
3.5 Slab 信息查看与监控
$ cat /proc/slabinfo
kmalloc-1k 2416 2520 1024 16 4 : tunables ...
kmalloc-512 3180 3328 512 16 8 : tunables ...
task_struct 312 360 4096 8 4 : tunables ...
dentry 9264 10080 192 21 2 : tunables ...
# slabtop 实时查看
$ slabtop -o
sar 工具可展示 slab 缓存活动:
$ sar -r ALL 1
02:30:01 PM kbmemfree kbmemused %memused kbbuffers kbcommit
02:30:01 PM 8912340 74218640 89.22 341828 12029572
四、虚拟内存与页表管理
4.1 VMA(Virtual Memory Area)
进程的虚拟地址空间由一系列 vm_area_struct 描述,每个 VMA 代表一段具有相同权限和映射类型的连续虚拟地址范围:
struct vm_area_struct {
unsigned long vm_start; // 区间起始地址
unsigned long vm_end; // 区间结束地址
struct mm_struct *vm_mm; // 所属地址空间
pgprot_t vm_page_prot; // 页保护属性
unsigned long vm_flags; // 标志(VM_READ/WRITE/EXEC/SHARED)
struct vm_operations_struct *vm_ops; // 缺页、保护操作回调
unsigned long vm_pgoff; // 文件映射偏移
struct file *vm_file; // 映射的文件对象
struct rb_node vm_rb; // 红黑树节点
};
所有 VMA 通过红黑树(mm->mm_rb)和双向链表(mm->mmap)管理。访问时的查询,do_page_fault 通过红黑树 O(log n) 定位包含目标地址的 VMA。
可通过 /proc/<pid>/maps 查看进程 VMA 布局:
7f3a1a2b0000-7f3a1a4b0000 r-xp 00000000 08:01 131075 /usr/lib/libc.so
7f3a1a6b0000-7f3a1a6c0000 rw-p 00200000 08:01 131075 /usr/lib/libc.so
7f3a1a6c0000-7f3a1a6d0000 rw-p 00000000 00:00 0 [anon]
55c8d0e00000-55c8d0e20000 r-xp 00000000 08:01 262147 /bin/bash
55c8d1020000-55c8d1030000 rw-p 00020000 08:01 262147 /bin/bash
7ffd1a000000-7ffd1a021000 rw-p 00000000 00:00 0 [stack]
4.2 VMA 操作:创建与合并
创建新 VMA 的典型入口:
mmap():创建文件/匿名映射。do_brk():扩展堆(brk()/sbrk()调用)。elf_map():加载 ELF 可执行文件。setup_arg_pages():创建栈 VMA。
内核在插入前会检查是否能与前后相邻 VMA 合并(相同权限、同一文件映射且偏移连续),从而减少 VMA 碎片。该过程在 vma_merge() 中实现。
4.3 多级页表
x86_64 使用四级页表(Linux 5.x 支持五级):
| 层级 | 名称 | 索引位数 | 用途 |
|---|---|---|---|
| PGD | Page Global Directory | 9 bits | 顶层页目录,每个进程 PGD 独立 |
| P4D | 4th level Directory | 9 bits | 五级页表扩展(57位 VA) |
| PUD | Page Upper Directory | 9 bits | 大页 1GB 映射 |
| PMD | Page Middle Directory | 9 bits | 大页 2MB 映射 |
| PTE | Page Table Entry | 9 bits | 最终 4KB 页映射 |
虚拟地址共 48 位(256TB),加上页内 12 位 offset,总 57 位表示支持 P4D(5级页表)。
CR3 寄存器保存 PGD 物理地址,进程切换时内核会写 CR3(实际是 ASID/PCID 优化避免全 TLB 刷新)。mmu_gather 机制批量释放 PTE 以提升效率。
4.4 缺页中断(Page Fault)
当线性地址无有效 PTE 或权限不足时触发 Page Fault,进入 do_page_fault() -> handle_mm_fault():
两种主要类型:
- Minor Fault:页已在 page cache 或交换区,只需建立映射,快速。
- Major Fault:需从磁盘读入数据,慢路径,涉及 I/O 等待。
关键处理路径:
- 查找 VMA:红黑树定位包含地址的 VMA;未找到则 SIGSEGV。
- 权限检查:写只读 mapping 时触发 COW(写时复制)。
- Demand Paging:匿名页首次访问时分配零页(
alloc_zeroed_user_highpage()或clear_page())。 - Page Cache 回写:文件映射从 inode 读取。
- 页面换入:换出页面的
do_swap_page()路径。
COW 机制是 fork() 的核心优化——父子进程在 COW 触发前共享同一物理页,仅在写入时才复制。
4.5 反向映射(Reverse Mapping / RMAP)
回收匿名页或文件缓存时,需除去所有进程 PTE 的映射。Linux 实现了两级反向映射:
- 匿名页 RMAP:通过
page->anon_vma链表遍历共享此页的所有 VMA。 -
文件页 RMAP:通过
page->mapping的优先搜索树定位含此文件 offset 的所有 VMAs。
RMAP 让"找到 page 的所有引用者"成为可能,是 try_to_unmap() 无映射回收页的前提,也是 fork() 后 COW 追踪的基础。
五、页面回收与交换
5.1 LRU 算法
内核使用近似 LRU 算法:每个 zone 维护两个 LRU 链表——活跃的(active)和非活跃的(inactive)。最近被访问两次以上进入 active 链表,一段时间后被移至 inactive,回收时优先摘取 inactive 链表尾部的页框。
两 list 交替算法(lru_gen):Linux 6.1 引入了多代 LRU(Multi-Gen LRU),以代代替访问时延,避免了旧 LRU"哑铃"问题——短暂大量扫描导致频繁误杀。
查看 LRU 状态:
$ cat /proc/meminfo | grep -E "Active|Inactive|Cached|Dirty|Writeback"
Active: 3567896 kB
Inactive: 7890123 kB
Active(file): 2345678 kB
Inactive(file): 5678901 kB
Active(anon): 1222218 kB
Inactive(anon): 2211222 kB
Dirty: 23456 kB
Writeback: 12345 kB
5.2 页面换出(Swap)
kswapd 发现内存紧张时,将匿名页写入交换分区/文件。注意:文件缓存页不需要换出,只需丢弃或回写(shrink_page_list() 中的分离)。
swap 的处理链:
- 从
inactiveLRU 匿名链表尾部抓取页面。 - 加入 swap cache(swap_map 中分配 slot)。
- 无映射引用页丢弃;有映射但只读则直接丢弃(读回时从文件缓存取)。
- 写入 swap 设备(同步或异步回写)。
- 更新 PTE 内容——现在的 PTE 指向 swap entry,下次访问触发
do_swap_page()。
注意 zswap/zram:压缩再写,减少实际 I/O 量。
5.3 OOM Killer
当所有回收手段均无法满足分配需求时,内核调用 out_of_memory() 选择受害者:
// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
// 目标:权衡内存占用与重要性
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm->nr_ptes;
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) return 0; // 永不 kill
points = points * 1000 / totalpages; // 内存占比
points += adj * totalpages / 1000; // adj 加权
return clamp_t(unsigned long, points, 1, totalpages);
}
OOM 评分规则(核心思想):
- 占内存越多分数越高。
oom_score_adj调整值:-1000 永不 kill,0 默认,+1000 必死。- 根进程(
PID 1)、内核线程有保护。 - 可以选择先杀死处于可中断等待(TASK_UNINTERRUPTIBLE) 且占用大的受害者。
/proc/<pid>/oom_score 反映实时评分;/proc/<pid>/oom_score_adj 可由用户设置。
六、大页(Huge Pages)机制
6.1 Transparent HugePages(THP)
THP 由内核自动将符合条件的虚拟内存区域以 2MB 大页(x86_64)映射,减少 TLB miss:
# 三种模式
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
$ cat /sys/kernel/mm/transparent_hugepage/defrag
always defer defer+madvise [madvise] never
其工作机制包括:
- khugepaged:后台守护线程,扫描进程地址空间,对连续 4KB 页机会性提升为 2MB。
- deferred splitting:只读部分失效大页,不全拆分。
- 内存开销:大页无法被 swap,必须常驻(
vm.nr_overcommit_hugepages)。
数据库场景建议关闭 THP,因扩展/收缩可能导致延迟尖刺。
6.2 显式大页(Explicit HugePages / hugetlbfs)
应用通过 mmap() 访问 hugetlbfs 文件系统(如 Oracle 的 ARCHIVELOG、DPDK)。优势是静态预留、无法被回收、TLB 效率最佳;劣势是灵活性差、管理员需预先规划。
$ mount -t hugetlbfs huverse /dev/hugepages
$ echo 20 > /proc/sys/vm/nr_hugepages # 预留 20 个 2MB 大页 = 40MB
libhugetlbfs 提供 mmap(..., MAP_HUGETLB) 标志,用户态可直接请求。
七、内核地址空间布局
x86_64 Linux 内核虚拟地址空间布局(48 位):
| 区域 | 地址范围 | 说明 |
|---|---|---|
| 用户空间 | 0x0000 0000 0000 - 0x0000 7FFF FFFF | 256TB 用户态 |
| 非规范区 | 0x0000 8000 0000 - 0xFFFF 7FFF FFFF | 不可访问 |
| 直接映射区 | FFFF 8880 0000 - ... | 线性映射所有物理内存(page_offset_base) |
| VMALLOC 区 | FFFF C900 0000 - ... | vmalloc/ioremap 区域 |
| 模块区 | FFFF FFFF 6000 - ... | 内核模块加载 |
| 固定映射 | FFFF FFFF F000 - ... | FIXADDR_START/TOP |
| vsyscall | FFFF FF60 0000 - ... | 快速系统调用(legacy) |
直接映射区是 va = pa + page_offset_base 的线性映射,用于 kmalloc、virt_to_phys() 等。KASLR(内核地址空间布局随机化)会随机化 page_offset_base、vmalloc_base、vmemmap_base,增强安全性。
八、生产实践与调优
8.1 相关 sysctl 参数
# 文件页缓存脏页回写
vm.dirty_background_ratio = 10 # 脏页占总内存 10% 时后台回写启动
vm.dirty_ratio = 20 # 20% 时阻塞进程回写
vm.dirty_expire_centisecs = 3000 # 脏页过期 30 秒
vm.dirty_writeback_centisecs = 500 # kwb 检查间隔
# 过度提交策略
vm.overcommit_memory = 0 # heuristic,大请求拒绝
vm.overcommit_memory = 1 # 永远允许(危险)
vm.ovecommit_memory = 2 # 严格,限制于 swap + overcommit_ratio%
# 内存回收
vm.swappiness = 60 # 0 尽量避免 swap;100 更激进地 swap
vm.min_free_kbytes = 262144 # 保留 256MB
vm.vfs_cache_pressure = 100 # 100 平衡回收 cache/dentry/inode
# THP
always [madvise] never
# 用户态 mmap 锁限制
vm.max_map_count = 65530 # ES/IntelMQ 增大到 262144
8.2 内存监控工具
定位内存问题时的常用工具链:
- free / vmstat / sar:宏观指标(空闲、buffer/cache、si/so)。
- /proc/buddyinfo:Buddy System 各阶空闲块数,碎片预警。
- /proc/pagetypeinfo:迁移类型分类信息。
- perf c2c:伪共享时定位到缓存行行动。
- bpftrace/bcc:跟踪
kmem_cache_alloc、mm_page_alloc等高开销函数。 - valgrind massif:用户态堆内存分析(创建火焰图效果)。
8.3 常见问题诊断
Q: 为什么 slab 增长后不收缩?
Slab 对象频繁创建后释放,kmem_cache 仍保留 partial slab 以备下次分配。这是正常行为,不消耗物理内存(仅浪费虚拟地址空间)。/proc/sys/vm/drop_caches 可强制释放:
echo 2 > /proc/sys/vm/drop_caches # 仅 slab,不要常用
Q: 高 si/so(swap 进出)意味着什么?
系统内存pressure极高。vmstat 1 的 si/so 长期 > 0,需立即排查:内存泄漏、过多进程、不当配置。D 状态进程比例高也是一个信号。
Q: Linux 是否有内存碎片?
页级别碎片不严重(内部碎片每页最多浪费 ~4KB),物理块级别碎片存在于长期运行的高端。/proc/buddyinfo 查看 max order 空闲块数。DEVMEM/KASAN 可能加剧。
Q: 为什么 free 内存显示为 0 但系统不卡?
free 已计入 buffers/cache 到 available,available 列才是真正可用的内存逼近值。available ≈ free + (buffers/cache 可流失部分)。
九、内核演进与展望
- zswap/zram:压缩交换,减少 I/O 并加速低内存设备。
- Multi-Gen LRU(6.1+):替代传统双 list LRU,减少误杀。
- Landlock 沙箱:用户态内存访问限制。
- DAMON:Data Access MONitor,内核提供原始数据以构建精确的内存回收策略。
- CHMEM/Sealed memfd:更细粒度的共享内存与 seal 保护,主要用于安全沙箱。
- CXL.mem:连接持久内存和池化内存,未来带来更复杂的层次化模型。
总结
Linux 内核内存管理是一个从硬件到软件、从物理到虚拟的精密协同系统:
- Buddy System 管理物理页框,解决外部碎片与连续分配。
- SLUB 实现内核对象的高效分配与回收。
- VMA + 多级页表 + Page Fault 构建灵活而高效的虚拟地址空间。
- LRU + Swap + OOM 形成三级防线应对内存压力。
- THP/HugePages 与 RMAP/DAMON 持续演进,应对数据密集型负载。
对这些机制的理解,是构建可靠、高性能与可观测系统的基石。建议配套工具(/proc/meminfo、/proc/slabinfo、/proc/buddyinfo、bpftrace)进行实机分析,将理论与实践相结合。

发表评论 取消回复