一、引言:物理内存——内核一切资源管理的基石
在第 2 章:系统调用中,我们看到用户程序通过 brk()、mmap() 等系统调用向内核申请内存。但你有没有想过:内核自己用什么来满足这些请求?当 malloc() 在用户态分配了一块内存,真正映射到物理 RAM 上的那一页帧是谁分配的?当内核需要为一个新的 task_struct 分配空间时,它又从哪里拿到物理页?
答案就在本章的主题——物理内存管理。
物理内存管理是整个内存子系统的”地基”。虚拟内存、页缓存、共享内存——所有这些上层机制,最终都要落实到物理页帧的分配与回收。Linux 内核为此设计了一套精巧而高效的分层体系:从最底层的 struct page 页帧描述符,到 NUMA 节点与 Zone 区域的组织,再到伙伴系统(Buddy System)的 2^n 分配算法,以及 Slub 分配器对小对象的精细管理。当内存紧张时,kswapd 守护进程和 OOM Killer 依次登场,确保系统在极端情况下依然可控。
本章将自底向上,逐一剖析这些机制的设计与实现。
二、struct page:物理页帧的”身份证”
1.1 每一页物理内存都需要元数据
Linux 以页(Page)为物理内存管理的基本单位。在 x86_64 架构上,一页的大小为 4 KB(4096 字节)。内核为系统中的每一个物理页帧都维护一个 struct page 结构体,它记录了该页帧的状态、引用计数、所属区域等关键信息。
你可以把 struct page 想象成物理页帧的”身份证”——没有它,内核就不知道某一页内存是否空闲、是否被缓存占用、是否可回收。
// include/linux/mm_types.h — 简化版 struct pagestruct page { unsigned long flags; // 页状态标志位(PG_locked, PG_dirty, PG_lru 等) union { struct { // 页缓存使用 struct list_head lru; struct address_space *mapping; pgoff_t index; unsigned long private; }; struct { // Slab 分配使用 void *freelist; // Slub 空闲对象链表 union { struct kmem_cache *slab_cache; struct { unsigned inuse:16; unsigned objects:15; unsigned frozen:1; }; }; }; struct { // 伙伴系统使用 unsigned long _mapcount; unsigned long _refcount; }; };} _struct_page_alignment;struct page 使用了大量 union(联合体),因为一个物理页帧在不同场景下扮演不同角色——它可能属于页缓存、可能被 Slab 分配器管理、也可能在伙伴系统中空闲。union 让不同用途共享同一块内存,将 struct page 的大小严格控制在 64 字节(x86_64),这对减少内存开销至关重要。
1.2 mem_map 数组
所有 struct page 组成一个全局数组 mem_map(在 NUMA 系统中,每个节点有自己的 node_mem_map)。物理页帧的编号(pfn,Page Frame Number)就是该数组中的索引:
// 通过 pfn 获取对应的 struct page#define pfn_to_page(pfn) (mem_map + (pfn))// 通过 struct page 获取 pfn#define page_to_pfn(page) ((unsigned long)((page) - mem_map))这意味着:如果系统有 16 GB 物理内存,就有约 400 万个页帧,对应 400 万个 struct page,占用约 244 MB 内存。这是内核为管理物理内存必须付出的”税”。
在较新的内核中,struct page 的存储方式从连续数组演进为 SPARSEMEM_VMEMMAP 模式——将 mem_map 映射到一段连续的虚拟地址空间,使得即使物理内存存在”空洞”(某些地址范围没有 RAM),也能高效地通过 pfn 索引到对应的 struct page。
1.3 页状态标志位
struct page 的 flags 字段是一个位图,记录了该页帧的当前状态。常用的标志位包括:
| 标志位 | 含义 |
|---|---|
PG_locked | 页已被锁定,正在 I/O 操作中 |
PG_dirty | 页内容已被修改(脏页) |
PG_lru | 页在 LRU 链表上(可回收) |
PG_slab | 页属于 Slab 分配器 |
PG_writeback | 页正在被写回磁盘 |
PG_swapcache | 页在交换缓存中 |
PG_compound | 页是复合页(Transparent Huge Page)的一部分 |
这些标志位是内核各子系统协作的”信号灯”——伙伴系统检查页是否空闲,页缓存检查页是否脏,kswapd 检查页是否可回收,都依赖这些标志位。
三、NUMA 节点与 Zone 区域:物理内存的组织结构
2.1 NUMA 架构与 pg_data_t
现代多路服务器普遍采用 NUMA(Non-Uniform Memory Access) 架构:每个 CPU 插槽(Socket)有自己的本地内存,访问本地内存的速度远快于访问远端内存。Linux 用 struct pglist_data(别名 pg_data_t)来描述一个 NUMA 节点:
// include/linux/mmzone.h — 简化版 pg_data_ttypedef struct pglist_data { struct zone node_zones[MAX_NR_ZONES]; // 该节点包含的 Zone 数组 struct zonelist node_zonelists[MAX_ZONELISTS]; // Zone 分配优先级列表 int nr_zones; // 该节点 Zone 的数量 unsigned long node_start_pfn; // 起始页帧号 unsigned long node_present_pages; // 实际存在的页数 unsigned long node_spanned_pages; // 跨越的页数(含空洞) int node_id; // 节点编号 wait_queue_head_t kswapd_wait; // kswapd 等待队列 struct task_struct *kswapd; // kswapd 内核线程} pg_data_t;在 UMA(Uniform Memory Access)系统——也就是大多数个人电脑和虚拟机——上,整个系统只有一个 NUMA 节点,所有 CPU 对内存的访问延迟相同。内核通过将 UMA 模拟为单节点 NUMA 来统一处理。
# 查看系统的 NUMA 拓扑numactl --hardware# 输出示例(双路服务器):# available: 2 nodes (0-1)# node 0 size: 32768 MB# node 1 size: 32768 MB2.2 Zone:同一节点内的功能分区
每个 NUMA 节点内的物理内存又被划分为若干 Zone,划分依据是物理地址范围和用途限制:
各 Zone 的含义与存在原因:
| Zone | 物理地址范围 | 存在原因 |
|---|---|---|
| ZONE_DMA | 0 ~ 16 MB | ISA 总线时代的 DMA 引擎只能访问低 16 MB 内存。现代系统极少需要,但为兼容性保留 |
| ZONE_DMA32 | 0 ~ 4 GB | 32 位 DMA 设备(部分网卡、RAID 卡)只能访问 4 GB 以下内存 |
| ZONE_NORMAL | 4 GB ~ 物理内存上限 | 内核直接映射区,kmalloc() 默认从此分配。x86_64 上可覆盖全部物理内存 |
| ZONE_HIGHMEM | — | 仅 32 位系统存在。内核空间只有 1 GB 虚拟地址,无法直接映射全部物理内存,超出部分为高端内存 |
| ZONE_MOVABLE | — | 用于反碎片化和内存热插拔,其中的页可被迁移,不会产生不可移动的”钉子页” |
Zone 之间存在**备用分配(fallback)**机制:当 ZONE_NORMAL 空闲不足时,内核可以从 ZONE_DMA32 借用页帧——但反过来不行,DMA 设备需要的低地址内存不能被普通分配占用。这种”向下借用、不向上侵占”的策略保护了稀缺的 DMA 内存。
2.3 struct zone 的关键成员
// include/linux/mmzone.h — 简化版 struct zonestruct zone { unsigned long _watermark[NR_WMARK]; // 水位线:min / low / high unsigned long managed_pages; // 被伙伴系统管理的页数 unsigned long present_pages; // 实际存在的页数
struct free_area free_area[MAX_ORDER]; // 伙伴系统空闲链表(11 个阶) struct per_cpu_pageset __percpu *pageset; // Per-CPU 页缓存
spinlock_t lock; // Zone 级自旋锁 unsigned long flags; // Zone 状态标志
/* 内存回收相关 */ unsigned long dirty_balance_reserve; int compact_considered; unsigned long compact_defer_shift;};其中 free_area[] 是伙伴系统的核心数据结构,_watermark[] 是内存水位线,pageset 是 Per-CPU 缓存——这些将在后续章节逐一展开。
# 查看 Zone 信息cat /proc/zoneinfo# 输出示例:# Node 0, zone Normal# pages free 234567# min 8192# low 12288# high 16384# ...# Node 0, zone DMA32# ...四、伙伴系统:2^n 的分配艺术
3.1 为什么需要伙伴系统?
物理内存的分配面临一个经典难题——外碎片化(External Fragmentation):经过反复分配和释放后,空闲内存被切割成零散的小块,虽然总空闲量足够,却无法满足一个较大的连续分配请求。
伙伴系统(Buddy System)通过巧妙的 2^n 分配与合并策略,极大地缓解了外碎片化问题。它的核心思想是:
- 分配:将空闲块不断对半分裂,直到得到所需大小的块
- 释放:检查释放块的”伙伴”是否也空闲,若空闲则合并为更大的块,递归向上
3.2 伙伴系统的数据结构
每个 Zone 维护一个 free_area[] 数组,共 MAX_ORDER(默认 11)个元素,分别对应阶(order)0 ~ 10:
- order 0:1 页(4 KB)
- order 1:2 页(8 KB)
- order 2:4 页(16 KB)
- …
- order 10:1024 页(4 MB)
struct free_area { struct list_head free_list[MIGRATE_TYPES]; // 按迁移类型分组的空闲链表 unsigned long nr_free; // 该阶空闲块的数量};每个 free_area 中的空闲块按**迁移类型(Migrate Type)**进一步分组:MIGRATE_UNMOVABLE(不可移动)、MIGRATE_MOVABLE(可移动)、MIGRATE_RECLAIMABLE(可回收)等。这种分组策略是反碎片化的关键——不可移动的”钉子页”不会与可移动页混在一起,避免碎片扩散。
3.3 分配过程详解
假设系统需要分配 order=3(8 页 = 32 KB)的连续块,而 order=3 的空闲链表为空:
伙伴的判定规则:两个大小为 2^order 的块互为伙伴,当且仅当它们的起始 pfn 满足:
buddy_pfn = pfn ^ (1 << order)即 pfn 的第 order 位取反。这个位运算的精妙之处在于:合并时只需一次异或操作就能找到伙伴的位置。
3.4 释放与合并过程
释放一个 order=n 的块时:
- 计算伙伴的 pfn:
buddy_pfn = pfn ^ (1 << n) - 检查伙伴是否在 order=n 的空闲链表上
- 若伙伴空闲且在同一个 Zone,将伙伴从空闲链表摘除,合并为 order=n+1 的块
- 递归向上尝试合并,直到伙伴不可合并或达到
MAX_ORDER
伙伴系统的合并效率极高——最坏情况下,一次释放操作最多触发 MAX_ORDER - 1 次合并尝试。而且,由于每次合并都是 2^n 对齐的,合并后的块天然满足伙伴系统的对齐要求,不会产生”无法合并的碎片”。
3.5 核心分配/释放 API
// 分配 2^order 个连续页帧,返回指向第一个 struct page 的指针struct page *alloc_pages(gfp_t gfp_mask, unsigned int order);
// 释放通过 alloc_pages 分配的页帧void __free_pages(struct page *page, unsigned int order);
// 分配单个页帧的便捷宏#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0)
// 将 struct page �转换为虚拟地址void *page_address(const struct page *page);
// 分配页帧并直接返回虚拟地址unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);gfp_mask(Get Free Pages mask)是分配行为的关键控制参数,它告诉内核:从哪个 Zone 分配、是否可以阻塞、是否可以触发 I/O 等。常用的 GFP 标志:
| GFP 标志 | 含义 | 典型场景 |
|---|---|---|
GFP_KERNEL | 可阻塞、可触发回收、从 ZONE_NORMAL 分配 | 内核一般分配 |
GFP_ATOMIC | 不可阻塞、不可触发回收 | 中断上下文、自旋锁内 |
GFP_DMA | 从 ZONE_DMA 分配 | ISA 设备 DMA 缓冲区 |
GFP_DMA32 | 从 ZONE_DMA32 分配 | 32 位 DMA 设备 |
GFP_HIGHUSER | 从 ZONE_NORMAL 分配、可映射到用户空间 | 用户态 mmap |
# 查看伙伴系统各阶空闲块数量cat /proc/buddyinfo# 输出示例:# Node 0, zone DMA 3 2 1 1 2 1 1 0 1 1 3# Node 0, zone DMA32 127 85 42 21 12 5 3 2 1 0 0# Node 0, zone Normal 512 256 128 64 32 16 8 4 2 1 0# 每列对应 order 0~10 的空闲块数量3.6 Per-CPU Pageset:减少锁竞争的利器
伙伴系统的 free_area[] 由 Zone 级自旋锁保护。在多核系统上,频繁的分配/释放会导致严重的锁竞争。为此,内核为每个 CPU 在每个 Zone 上维护了一个 Per-CPU Pageset:
// include/linux/mmzone.h — 简化版struct per_cpu_pageset { struct per_cpu_pages pcp;};
struct per_cpu_pages { int count; // 当前缓存页数 int high; // 高水位(超过则归还给伙伴系统) int batch; // 一次批量分配/释放的页数 struct list_head lists[MIGRATE_TYPES]; // 按迁移类型分组的页链表};工作流程:
- 分配:优先从当前 CPU 的 pageset 中取页,无需加 Zone 锁
- 释放:优先归还到当前 CPU 的 pageset,无需加 Zone 锁
- 溢出处理:当 pageset 中的页数超过
high水位时,批量归还batch个页给伙伴系统
这种”本地缓存 + 批量同步”的设计,将大部分分配/释放操作变成了无锁操作,极大提升了多核场景下的性能。
五、Slab 分配器:小对象的精细管理
4.1 为什么需要 Slab?
伙伴系统只能分配 2^n 个连续页帧,最小粒度是一页(4 KB)。但内核中有大量远小于 4 KB 的对象需要分配——task_struct 约 9 KB、dentry 约 192 字节、inode 约 600 字节……如果每个对象都分配一整页,内存浪费将不可接受。
Slab 分配器在伙伴系统之上构建了小对象分配的精细管理层:它从伙伴系统申请连续页帧,然后将其切割为固定大小的对象,通过空闲链表管理。
4.2 从 Slab 到 Slub 到 Slob:三兄弟的演进
Linux 内核先后实现了三种 Slab 分配器:
| 分配器 | 特点 | 适用场景 |
|---|---|---|
| Slab | 最早实现(1996 年,Sun 公司 Jeff Bonwick 设计)。每个 CPU 维护本地缓存(array_cache),每个 slab 有专用管理结构。功能完善但内存开销大 | 已被 Slub 取代 |
| Slub | 当前默认分配器(2.6.22 引入)。简化了管理结构,将空闲对象指针直接嵌入对象本身(freelist),减少了元数据开销。调试功能更强 | 通用服务器、桌面 |
| Slob | 极简实现,代码量仅约 600 行。使用简单的首次适配算法,适合嵌入式等内存极度受限的场景 | 嵌入式系统 |
三种分配器共享同一套 API(kmalloc、kmem_cache_alloc 等),内核配置时选择其一即可。现代 Linux 默认使用 Slub。
4.3 Slub 分配器的核心概念
Slub 的组织层次为:kmem_cache → slab → object
// mm/slub.c — kmem_cache 简化版struct kmem_cache { const char *name; // 缓存名称(如 "task_struct") unsigned int size; // 对象大小(含对齐填充) unsigned int object_size; // 对象原始大小 unsigned int offset; // freelist 指针在对象内的偏移 unsigned int objs_per_slab; // 每个 slab 中的对象数 unsigned int order; // slab 占用的页帧阶数
struct kmem_cache_cpu __percpu *cpu_slab; // Per-CPU slab 缓存 struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点 partial slab 链表};关键设计:
- Per-CPU slab:每个 CPU 维护当前正在使用的 slab(
cpu_slab),从中分配对象无需任何锁 - freelist 嵌入:空闲对象的第一个
sizeof(void*)字节存储下一个空闲对象的指针,无需额外元数据 - partial 链表:每个 NUMA 节点维护一个部分空闲的 slab 链表,当 Per-CPU slab 耗尽时从中补充
分配流程:
- 从当前 CPU 的
cpu_slab获取 freelist - 若 freelist 非空,取出第一个空闲对象,freelist 指向下一个
- 若 freelist 为空,从 partial 链表取一个 slab,或从伙伴系统分配新 slab
释放流程:
- 将对象放回当前 CPU slab 的 freelist 头部
- 若 slab 变为全空,根据策略决定是否归还给伙伴系统
4.4 kmalloc:通用对象分配器
内核不仅需要为特定类型(如 task_struct)分配对象,还需要分配任意大小的内存块。kmalloc() 就是为此设计的通用分配器:
void *kmalloc(size_t size, gfp_t flags);void kfree(const void *objp);kmalloc 内部维护了一组预定义的 kmem_cache,覆盖了从 8 字节到 8 KB 的各种大小(对齐到 2 的幂次):
kmalloc-8 kmalloc-16 kmalloc-32 kmalloc-64kmalloc-96 kmalloc-128 kmalloc-192 kmalloc-256kmalloc-512 kmalloc-1k kmalloc-2k kmalloc-4k kmalloc-8k当你调用 kmalloc(100, GFP_KERNEL) 时,内核会选择 kmalloc-128 缓存(大于等于 100 的最小可用大小),从中分配一个 128 字节的对象。
kmalloc 分配的内存在物理上是连续的,但大小受限于伙伴系统的最大阶(order-10,即 4 MB)。对于更大的分配,需要使用 vmalloc(见下文)。
4.5 专用缓存 vs 通用缓存
内核为高频分配的对象创建了专用 kmem_cache:
// 创建专用缓存struct kmem_cache *kmem_cache_create(const char *name, unsigned int size, unsigned int align, slab_flags_t flags, void (*ctor)(void *));// 从专用缓存分配void *kmem_cache_alloc(struct kmem_cache *s, gfp_t flags);// 释放到专用缓存void kmem_cache_free(struct kmem_cache *s, void *objp);// 销毁专用缓存void kmem_cache_destroy(struct kmem_cache *s);专用缓存的优势:
- 精确大小:对象大小与实际需求完全匹配,无内部碎片
- 构造/析构:可指定构造函数,每次分配新 slab 时自动初始化对象
- 统计追踪:每个缓存有独立的统计信息,便于性能分析
# 查看 Slab 缓存信息cat /proc/slabinfo# 或使用更友好的 slabtop 工具slabtop -o# 输出示例:# OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME# 12345 10000 80% 0.19K 617 20 9872K dentry# 8765 7000 79% 0.62K 876 10 14016K inode_cache# 5432 4000 73% 9.22K 5432 1 86912K task_struct六、kmalloc vs vmalloc:两种分配策略的抉择
5.1 物理连续 vs 虚拟连续
| 特性 | kmalloc | vmalloc |
|---|---|---|
| 物理内存 | 连续 | 不连续 |
| 虚拟内存 | 连续 | 连续 |
| 最大大小 | 约 4 MB(order-10) | 理论上可达 VMALLOC_SPACE 大小 |
| 分配速度 | 快(Per-CPU 缓存) | 慢(需建立页表映射) |
| TLB 效率 | 高(物理连续,TLB 命中率高) | 低(物理不连续,TLB 失效率高) |
| 适用场景 | DMA 缓冲区、高性能数据结构 | 大块内存、不要求物理连续 |
| GFP 标志 | 可用任意 GFP 标志 | 仅限可睡眠上下文 |
5.2 vmalloc 的实现原理
vmalloc() 在内核的 vmalloc 虚拟地址空间中分配一段连续的虚拟地址,然后从伙伴系统逐页分配物理页帧,建立页表映射将两者关联:
void *vmalloc(unsigned long size);void vfree(const void *addr);vmalloc 的代价:
- 页表建立开销:需要为每个页创建 PTE(Page Table Entry),修改页表时还需刷新 TLB
- TLB 抖动:虚拟连续但物理不连续,访问时 TLB 失效率远高于
kmalloc - 不可用于 DMA:DMA 引擎需要物理连续的缓冲区
内核社区有一个不成文的规则:能用 kmalloc 就不用 vmalloc。只有在确实需要大块内存且无法保证物理连续时,才使用 vmalloc。例如,内核模块的代码段加载、大型的哈希表等。
5.3 其他分配函数
| 函数 | 说明 |
|---|---|
kcalloc(n, size, flags) | 分配 n 个 size 大小的元素,内存清零 |
kzalloc(size, flags) | kmalloc + 清零 |
kvmalloc(size, flags) | 优先 kmalloc,失败则回退到 vmalloc |
dm_alloc_coherent() | DMA 一致性内存分配 |
七、内存水位线与 kswapd:内存回收的守护者
6.1 三条水位线
每个 Zone 维护三条水位线,控制内存回收的触发时机:
enum zone_watermarks { WMARK_MIN, // 最低水位线:内存极度紧张,分配者必须同步回收 WMARK_LOW, // 低水位线:唤醒 kswapd 开始后台回收 WMARK_HIGH, // 高水位线:kswapd 回收至此停止 NR_WMARK};水位线的含义与行为:
| 水位线 | 含义 | 触发行为 |
|---|---|---|
| min | 紧急保留线。低于此线时,分配者必须调用 __alloc_pages_slowpath 同步回收 | alloc_pages 进入慢速路径,直接回收内存 |
| low | 预警线。空闲内存低于此线时唤醒 kswapd | 异步唤醒 kswapd 内核线程 |
| high | 安全线。kswapd 回收内存直到空闲量达到此线 | kswapd 完成回收,重新休眠 |
水位线的计算公式(由 setup_per_zone_wmarks() 初始化):
min = managed_pages * (min_free_kbytes / 1024) // 可通过 /proc/sys/vm/min_free_kbytes 调整low = min * 5 / 4high = min * 3 / 2# 查看当前 min_free_kbytes 设置cat /proc/sys/vm/min_free_kbytes# 典型值:67584(约 66 MB)
# 查看 Zone 水位线cat /proc/zoneinfo | grep -E "Node|zone|min|low|high|free"在生产环境中,适当调大 min_free_kbytes 可以为内存突增留出更多缓冲,减少直接回收(direct reclaim)的发生,但代价是减少了可用内存。对于延迟敏感型应用(如数据库),建议设置为物理内存的 1%~5%。
6.2 kswapd 内核线程
kswapd 是每个 NUMA 节点一个的内核线程,负责在后台回收内存,使空闲量恢复到 high 水位线以上。它的工作流程:
- 休眠等待:平时在
kswapd_wait等待队列上休眠 - 被唤醒:当某 Zone 的空闲内存低于
low水位线时被唤醒 - 回收循环:依次尝试以下回收手段:
- LRU 回收:从活跃 LRU 链表尾部扫描,将最近最少使用的页移到非活跃链表,最终回收
- 脏页写回:将脏页写回磁盘后回收
- Slab 回收:收缩可回收的 Slab 缓存(如 dentry cache、inode cache)
- 完成条件:所有 Zone 的空闲内存均达到
high水位线,或已扫描足够多的页 - 重新休眠
6.3 直接回收(Direct Reclaim)
当分配路径发现空闲内存低于 min 水位线时,分配者自身必须同步执行回收——这就是直接回收。它会阻塞当前进程,执行与 kswapd 类似的回收操作,但代价是分配延迟大幅增加。
直接回收是系统内存紧张的信号。如果你在 perf 或 /proc/vmstat 中观察到大量 pgscan_direct 计数,说明系统需要更多物理内存或调整水位线参数。
# 查看内存回收统计cat /proc/vmstat | grep -E "pgscan|pgsteal|kswapd"# pgscan_kswapd_dma 0# pgscan_kswapd_normal 12345# pgscan_direct_normal 0 # 直接回收次数,理想值为 0# kswapd_inodesteal 678八、内存规整与 OOM Killer:极端情况下的最后防线
7.1 内存规整(Compaction)
伙伴系统解决了外碎片化问题,但长时间运行后仍可能出现:空闲页总量充足,却无法满足高阶(order ≥ 3)的连续分配。这是因为可移动页和不可移动页交错分布,导致空闲页无法合并。
内存规整(Memory Compaction) 通过迁移可移动页来”挤”出大块连续内存:
- 从 Zone 的起始端扫描寻找空闲页
- 从 Zone 的末尾端扫描寻找可移动页
- 将可移动页迁移到空闲页的位置
- 释放原位置,形成更大的连续空闲块
# 查看规整统计cat /proc/vmstat | grep compact# compact_migrate_scanned 12345# compact_free_scanned 67890# compact_isolated 4567# compact_stall 12 # 触发规整的次数# compact_fail 0 # 规整失败的次数# compact_success 12 # 规整成功的次数7.2 OOM Killer:最后的审判者
当所有回收手段(kswapd、直接回收、规整)都无法释放足够内存时,系统面临内存耗尽的危机。此时 OOM Killer(Out-Of-Memory Killer) 登场——它选择一个进程杀死,释放其占用的内存,拯救整个系统。
OOM Killer 的选择策略基于 oom_score(OOM 坏蛋分数),分数越高越容易被杀:
// mm/oom_kill.c — oom_badness() 简化逻辑long oom_badness(struct task_struct *p, unsigned long totalpages) { long points; points = get_mm_rss(p->mm) + // RSS(驻留集大小) get_mm_counter(p->mm, MM_SWAPENTS) + // 交换区使用量 mm_pgtables_bytes(p->mm) / PAGE_SIZE; // 页表占用
// 根据进程的 oom_score_adj 进行调整(-1000 ~ 1000) // oom_score_adj = -1000 表示"永不杀死" // oom_score_adj = 1000 表示"优先杀死" return points;}关键参数:
| 参数 | 路径 | 说明 |
|---|---|---|
oom_score | /proc/<pid>/oom_score | 内核计算的 OOM 分数(只读) |
oom_score_adj | /proc/<pid>/oom_score_adj | 用户调整的权重(-1000 ~ 1000) |
oom_kill_disable | /proc/sys/vm/oom_kill_disable | 是否禁用 OOM Killer(0=启用,1=禁用) |
# 查看进程的 OOM 分数cat /proc/$$/oom_score# 调整 OOM 权重(保护关键进程)echo -1000 > /proc/<pid>/oom_score_adj # 永不杀死echo 1000 > /proc/<pid>/oom_score_adj # 优先杀死
# 查看最近的 OOM 事件dmesg | grep -i "out of memory"# 或查看内核日志journalctl -k | grep -i "oom-kill"禁用 OOM Killer(oom_kill_disable=1)是危险的——当内存耗尽时,系统将无法杀死任何进程,可能导致整个系统挂起。除非你完全清楚自己在做什么,否则不要禁用它。
7.3 完整的内存分配路径
将所有机制串联起来,一次内存分配的完整路径如下:
九、动手实践:观察物理内存管理的运行状态
实践 1:查看 Zone 布局与水位线
# 查看 Zone 详细信息(水位线、空闲页数、伙伴系统状态)cat /proc/zoneinfo
# 重点关注以下字段:# - pages free: 当前空闲页数# - min / low / high: 三条水位线# - managed: 伙伴系统管理的页数# - nr_free_pages: 空闲页数实践 2:观察伙伴系统空闲块分布
# 查看各阶空闲块数量cat /proc/buddyinfo
# 解读示例:# Node 0, zone Normal 512 256 128 64 32 16 8 4 2 1 0# order: 0 1 2 3 4 5 6 7 8 9 10# 含义:order=0 有 512 个 1 页空闲块,order=1 有 256 个 2 页空闲块...# 如果高阶(order ≥ 5)空闲块很少,说明碎片化较严重实践 3:分析 Slab 缓存使用情况
# 查看 Slab 缓存汇总cat /proc/slabinfo
# 使用 slabtop 实时监控(按大小排序)slabtop -o -s c # 按缓存大小排序
# 关注占用最大的缓存:# - dentry: 目录项缓存# - inode_cache: inode 缓存# - task_struct: 进程描述符# - kmalloc-*: 通用分配器缓存
# 手动回收可回收的 Slab 缓存echo 2 > /proc/sys/vm/drop_caches # 释放 dentry 和 inode 缓存echo 3 > /proc/sys/vm/drop_caches # 释放页缓存 + dentry + inode# 注意:生产环境慎用!仅用于调试实践 4:全面了解内存使用状况
# 查看系统内存概览cat /proc/meminfo
# 关键字段解读:# MemTotal: 总物理内存# MemFree: 空闲内存(未使用)# MemAvailable: 可用内存(含可回收的缓存)# Buffers: 块设备缓冲区# Cached: 页缓存大小# Slab: Slab 分配器占用的内存# SReclaimable: 可回收的 Slab(dentry/inode 缓存)# SUnreclaim: 不可回收的 Slab# Mapped: 已映射到用户空间的页# AnonPages: 匿名页(进程堆栈、malloc 分配)# KernelStack: 内核栈占用
# 更友好的工具free -hvmstat 1 5 # 每秒刷新,共 5 次实践 5:触发与观察内存回收
# 查看 kswapd 活动统计cat /proc/vmstat | grep -E "kswapd|pgscan|pgsteal|compact"
# 关键指标:# kswapd_steal: kswapd 回收的页数# pgscan_kswapd_*: kswapd 扫描的页数# pgscan_direct_*: 直接回收扫描的页数(越少越好)# compact_stall: 触发规整的次数# compact_fail: 规整失败的次数
# 调整水位线参数cat /proc/sys/vm/min_free_kbytesecho 131072 > /proc/sys/vm/min_free_kbytes # 设置为 128 MB
# 调整 Swappiness(倾向使用 swap 还是回收页缓存)cat /proc/sys/vm/swappiness# 值范围 0~100,默认 60# 0 = 尽量不用 swap,优先回收页缓存# 100 = 积极使用 swap实践 6:模拟 OOM 场景
# 查看当前进程的 OOM 分数cat /proc/$$/oom_scorecat /proc/$$/oom_score_adj
# 保护关键进程(如 SSH 守护进程)echo -1000 > /proc/$(pgrep sshd)/oom_score_adj
# 使用 stress-ng 模拟内存压力(需安装)stress-ng --vm 4 --vm-bytes 80% --timeout 30s
# 观察 OOM 事件dmesg -w | grep -i "oom"总结:物理内存管理的全景图
本章从最底层的 struct page 出发,逐层向上构建了 Linux 物理内存管理的完整图景:
- struct page 是物理页帧的元数据,
mem_map数组通过 pfn 索引到每一个页帧 - NUMA 节点(pg_data_t) 描述多路系统的内存拓扑,Zone 按物理地址和用途分区
- 伙伴系统 通过 2^n 分配与合并算法,高效管理连续页帧分配,缓解外碎片化
- Per-CPU Pageset 为伙伴系统提供无锁缓存,减少多核锁竞争
- Slub 分配器 在伙伴系统之上管理小对象,
kmalloc提供通用分配,kmem_cache提供专用分配 - kmalloc 保证物理连续、速度快;vmalloc 仅保证虚拟连续、适合大块分配
- 三条水位线(min/low/high) 控制 kswapd 的唤醒与停止,直接回收 在紧急时同步执行
- 内存规整 通过迁移可移动页来消除碎片,OOM Killer 在内存耗尽时选择进程杀死
参考资料
内核源码
| 文件 | 内容 |
|---|---|
mm/page_alloc.c | 伙伴系统核心实现(alloc_pages、__free_pages、水位线计算) |
mm/slub.c | Slub 分配器实现(kmem_cache、kmalloc) |
mm/vmalloc.c | vmalloc 分配器实现 |
include/linux/mmzone.h | Zone、pg_data_t、水位线、Per-CPU pageset 数据结构定义 |
include/linux/mm_types.h | struct page 定义 |
mm/oom_kill.c | OOM Killer 实现(oom_badness、out_of_memory) |
mm/compaction.c | 内存规整实现 |
mm/vmscan.c | kswapd 与页面回收实现 |
mm/slab.h | Slab 分配器公共头文件 |
经典书籍
- 《深入理解 Linux 内核》(Daniel P. Bovet 等)— 第 8 章”内存管理”对伙伴系统和 Slab 有详细描述
- 《Linux 内核设计与实现》(Robert Love)— 第 12 章”内存管理”提供了清晰的概述
- 《Understanding the Linux Virtual Memory Manager》(Mel Gorman)— 内存管理子系统的权威参考,作者本人就是 Linux 内存管理核心开发者
- 《奔跑吧 Linux 内核》(笨叔)— 结合最新内核版本,有大量图解和代码分析
在线资源
- Linux 内核文档 — Memory Management
- Mel Gorman’s VM Documentation — 伙伴系统与 Zone 分配的经典文档
- Bootlin Elixir — mm/page_alloc.c — 在线源码浏览
- Kernel Newbies — Memory Management — 内核开发入门资源
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






