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 个连续页框时:

  1. 在 order-n 链表中查找,有则直接返回。
  2. 无则向 order-(n+1) 取一块,均分后一半返回、一半插入 order-n 链表。
  3. 递归向上直至成功或失败。

释放时则相反:检查"伙伴"(地址相邻且同阶)是否也在空闲链表中,是则合并为 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 水线机制与直接回收

分配失败时的回退路径:

  1. 异步回收:kswapd 在空闲页低于 low 时后台唤醒。
  2. 直接回收(Direct Reclaim):分配者同步执行,阻塞当前进程,扫描 LRU 链表回收页面。
  3. 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() 的路径:

  1. CPU freelist(热路径):直接从 per-CPU freelist 取对象,无锁 O(1)。
  2. CPU page partial:freelist 为空但从 CPU 的 partial 页填充。
  3. Node partial:CPU partial 也空,从 NUMA 节点 partial 链表获取。
  4. 分配新 slab:全部耗尽时,通过 Buddy System 申请一个 kmem_cache->oo 大小的页块。

3.4 kmalloc 与 vmalloc 的区别

kmalloc() 基于 SLUB,返回物理连续的内存(通过 Buddy System),适用于 DMA、硬件访问场景。vmalloc() 从虚拟地址空间分配一个虚拟连续但物理不连续的大块,仅适合纯软件使用。

关键权衡:

特性kmallocvmalloc
物理连续是否
最大尺寸~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 支持五级):

层级名称索引位数用途
PGDPage Global Directory9 bits顶层页目录,每个进程 PGD 独立
P4D4th level Directory9 bits五级页表扩展(57位 VA)
PUDPage Upper Directory9 bits大页 1GB 映射
PMDPage Middle Directory9 bits大页 2MB 映射
PTEPage Table Entry9 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 等待。

关键处理路径:

  1. 查找 VMA:红黑树定位包含地址的 VMA;未找到则 SIGSEGV。
  2. 权限检查:写只读 mapping 时触发 COW(写时复制)。
  3. Demand Paging:匿名页首次访问时分配零页(alloc_zeroed_user_highpage() 或 clear_page())。
  4. Page Cache 回写:文件映射从 inode 读取。
  5. 页面换入:换出页面的 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 的处理链:

  1. 从 inactive LRU 匿名链表尾部抓取页面。
  2. 加入 swap cache(swap_map 中分配 slot)。
  3. 无映射引用页丢弃;有映射但只读则直接丢弃(读回时从文件缓存取)。
  4. 写入 swap 设备(同步或异步回写)。
  5. 更新 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 FFFF256TB 用户态
非规范区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
vsyscallFFFF 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)进行实机分析,将理论与实践相结合。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }