Linux 内存子系统深度实战:从物理页框到虚拟地址全链路解析

内存管理是 Linux 内核最复杂、最核心的子系统之一。它不仅负责物理内存的分配与回收,还构建了进程虚拟地址空间的完整抽象层。本文将从物理内存组织出发,深入剖析伙伴系统、Slab 分配器、虚拟内存区域、页表机制、内存回收与 OOM 杀手,最终落地到生产环境调优实践。

一、物理内存组织:NUMA 节点与内存域

现代多核服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,CPU 访问本地内存节点的延迟远低于跨节点访问。Linux 内核通过以下数据结构组织物理内存:

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;        // 总页数(含空洞)
};

每个 NUMA 节点包含多个内存域(Zone),分配请求按优先级遍历节点的 zonelist 回退到其他节点。Zone 的类型包括:

  • ZONE_DMA:前 16MB,旧 ISA 设备需要
  • ZONE_DMA32:4GB 以内,32 位 DMA 设备
  • ZONE_NORMAL:直接映射区,内核线性映射的空间
  • ZONE_MOVABLE:可迁移页,用于内存热插拔
  • ZONE_HIGHMEM(32 位):高端内存,超过 896MB 的部分

在 x86_64 架构上,由于有巨大的虚拟地址空间,ZONE_HIGHMEM 不存在,物理内存大多在 ZONE_NORMAL 中通过直接映射(PAGE_OFFSET + pfn)访问。

二、伙伴系统:物理页框分配引擎

Buddy System 负责管理连续物理页框的分配与释放,解决外部碎片问题。它将所有空闲页框分组为 11 个链表,分别管理 2^0 到 2^10(即 1 页到 1024 页,4KB 到 4MB)大小的连续块。

2.1 分配与释放机制

分配时从合适阶(order)的空闲链表取块;若该阶无空闲块,则从更高阶链表中"分裂"出一半满足请求,另一半插入低阶链表。释放时检查 buddy 块是否空闲,若空闲则向上合并。

// 分配核心逻辑(简化)
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    // 1. 快速路径:从 per-cpu 热缓存直接分配
    // 2. 慢速路径:遍历 zone 的空闲链表
    // 3. 触发内存回收或直接回收
    // 4. 若仍失败且允许,调用 OOM Killer
}

// 释放时合并逻辑(简化)
void __free_pages(struct page *page, unsigned int order)
{
    // 计算 buddy 页的 pfn: buddy_pfn = pfn ^ (1 << order)
    // 若 buddy 空闲则从当前链表移除,合并后向上一级合并
}

GFP(Get Free Pages)标志控制分配行为:

  • GFP_KERNEL:标准内核分配,可能睡眠,可用于进程上下文
  • GFP_ATOMIC:原子分配,不睡眠,中断处理程序和持锁时使用
  • GFP_NOIO:不发起 I/O,块层分配路径使用
  • GFP_NOFS:不发起文件系统操作
  • __GFP_HIGHMEM:允许从高端内存分配
  • __GFP_ZERO:分配后清零
  • __GFP_NOWARN:失败时不打印警告
  • __GFP_RETRY_MAYFAIL:允许重试但不触发 OOM

2.2 反碎片与迁移类型

内核 2.6.24 引入了反碎片机制,按页面可迁移性将页框分组:

  • MIGRATE_UNMOVABLE:内核对象、页缓存等不可移动
  • MIGRATE_MOVABLE:用户页面、可重定位
  • MIGRATE_RECLAIMABLE:不可移动但可回收(如 Slab 缓存)
  • MIGRATE_ISOLATE:禁止跨组分配,用于热插拔

这种设计使得未来可能实现真正的在线碎片整理——将可移动页面搬走形成连续空间。

三、Slab 分配器:内核对象高效复用

伙伴系统以页(4KB)为粒度分配,而内核中大量对象(inode、dentry、task_struct 等)远小于一页。频繁的页级分配会产生严重内部碎片。Slab 分配器在伙伴系统之上构建对象缓存层,实现细粒度内存复用。

3.1 工作原理

Slab 为每种高频对象类型维护一个 Cache,每个 Cache 包含多个 Slab(通常 1-N 页),Slab 内部通过 freelist 链表管理空闲对象。

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;    // per-cpu 快速分配缓存
    struct kmem_cache_node *node[MAX_NUMNODES]; // 节点级半满/空 slab 链表
    unsigned int size;                  // 对象大小(含对齐)
    unsigned int object_size;           // 原始请求大小
    unsigned int offset;                // freelist 指针偏移
    slab_flags_t flags;                 // 对齐/清零等标志
    const char *name;                   // 缓存名称
};

分配路径为:per-cpu cpu_slab → kmem_cache_node 半满链表 → 伙伴系统新分配。快速路径直接操作 per-cpu 可用对象链表,无需加锁。

3.2 SLUB vs SLOB

Linux 提供三种 Slab 实现:

  • SLUB(默认):将元数据嵌入页结构,简化管理,NUMA 友好,适合大内存系统
  • SLAB(传统):独立管理结构,精确的着色和调试支持
  • SLOB:极简实现,用于内存极度受限的嵌入式环境

查看当前系统活跃 Slab 缓存:

$ cat /proc/slabinfo | head -20
slabinfo - version 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
dentry          1123423 1123456    192   21    1 : tunables    0    0    0 : slabdata   53498   53498
inode_cache      987654  987700    648    8    2 : tunables    0    0    0 : slabdata  123463  123463
buffer_head      543210  543210    104   38    1 : tunables    0    0    0 : slabdata   14295   14295
task_struct        3456    3456  7736    1    3 : tunables    0    0    0 : slabdata    3456    3456
vm_area_struct     4567    4567    208   19    1 : tunables    0    0    0 : slabdata     240     240

四、虚拟内存与进程地址空间

每个进程拥有独立的虚拟地址空间,由 struct mm_struct 描述,通过 VMA(Virtual Memory Area)树/映射管理不同用途的内存区域。

struct mm_struct {
    struct maple_tree mt;               // Maple Tree(5.16+ 替代红黑树)管理 VMA
    unsigned long mmap_base;            // mmap 起始地址
    unsigned long task_size;            // 用户地址空间大小
    pgd_t *pgd;                         // 页全局目录(顶级页表)
    atomic_t mm_users;                  // 用户数量
    atomic_t mm_count;                  // 引用计数
    unsigned long total_vm;             // 总映射页数
    unsigned long locked_vm;            // 锁定页数(不可交换)
    unsigned long data_vm;              // 数据段页数
    unsigned long exec_vm;              // 代码段页数
    unsigned long stack_vm;             // 栈页数
};

4.1 地址空间布局(x86_64)

x86_64 Linux 进程地址空间典型布局:

0x0000_0000_0000_0000 - 0x0000_7fff_ffff_ffff  (128TB) 用户空间
  └─ 文本段 .text
  └─ 数据段 .data / .bss
  └─ 堆(向上增长,brk/mmap)
  └─ mmap 区域(动态库、共享内存、匿名映射)
  └─ 栈(向下增长,固定大小或自动增长)

0xffff_8000_0000_0000 - 0xffff_bfff_ffff_ffff  (-128TB) 内核空间
  └─ 直接映射区(physmap)所有物理内存
  └─ vmalloc 区
  └─ 内核代码/数据
  └─ fixmap / vsyscall

5.16+ 内核中,VMA 管理从红黑树改为Maple Tree(基数树变体),目的是批量操作友好并减少锁争用。/proc/<pid>/maps 可读出进程的 VMA 布局。

4.2 mmap 与 brk

进程通过两种系统调用向内核申请内存:

  • brk/mmap:调整.program break 位置(仅适用于小堆,连续分配)
  • mmap:在任意地址建立映射(大分配、共享内存、文件映射)

C 库的 malloc 实现(glibc ptmalloc)策略:小于 128KB 用 brk,大于等于 128KB 用 mmap(避免堆碎片导致无法归还)。jemalloc 和 tcmalloc 使用更细粒度的尺寸分级和 per-thread arena 策略。

五、页表与地址翻译机制

Linux 使用四级(x86_64 早期)或五级(Linux 5.11+ )页表完成虚拟地址到物理地址的翻译。硬件 MMU 中的 Page Table Walker 自动遍历页表,TLU 缓存近期翻译结果。

5.1 五级页表结构

x86_64 五级页表每级 9 bit,共 48 bit 虚拟地址空间(256TB),PKE(Protection Key for Userspace)支持内存域保护:

虚拟地址(48-bit)拆分:
[47:39] PGD index  (9 bits, 512 entries)
[38:30] P4D index  (9 bits) - 五级页表新增
[29:21] PUD index  (9 bits)
[20:12] PMD index  (9 bits)
[11:0]  Page offset (12 bits, 4KB 页)
[11:0]  for 2MB/1GB huge page 相应调整偏移位

每一级页表项(PTE/PMD/PUD 等)包含目标物理地址(页帧号 PFN)和权限位(R/W/U/S、NX、Accessed、Dirty)。

5.2 TLB 机制

TLB(Translation Lookaside Buffer)是 MMU 内的地址转换缓存,现代 CPU 通常分 L1 ITLB/DTLB(约 64-128 项)和 L2 STLB(约 1536-2048 项)。

TLB 相关优化:

  • 大页(Huge Pages / THP):单个 TLB 项覆盖更大地址范围,提高 TLB 命中率
  • PCID(Process-Context Identifier):避免每次上下文切换刷新 TLB
  • INVPCID:选择性无效 TLB 项
  • mmap/read 重排序:减少同页重复触发 page walk

5.3 Huge Pages 与透明大页

Linux 支持 2MB 和 1GB 大页。透明大页(THP)功能由 khugepaged 内核线程自动合并相邻的常规页为大页,但可能引入延迟抖动。

# 查看大页配置
$ cat /proc/meminfo | grep -i huge
HugePages_Total:     512
HugePages_Free:      256
Hugepagesize:       2048 kB
HugetlbTotal:       1048576 kB

# 查看 THP 状态
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never    # 方括号内为当前值

# 为关键应用固定大页
$ echo 256 > /proc/sys/vm/nr_hugepages

数据库(PostgreSQL/Redis)和 DPDK等场景建议禁用 THP(never),改用显式预分配大页,避免运行时合并延迟。

六、内存回收与交换

当系统物理内存不足时,内核按以下顺序激活内存回收机制:直接回收 → kswapd 后台回收 → 压缩 → OOM Killer。

6.1 页面回收算法

Linux 使用 LRU(Least Recently Used)算法维护活跃/不活跃链表来挑选可回收页。内核 5.14+ 的 MGLRU(Multi-Gen LRU)改进了扫描效率。

页面类型与回收优先级:

  1. 干净页缓存(未修改的文件页):直接丢弃,代价最低
  2. 脏页缓存(已修改文件页):先写回磁盘再释放
  3. 不活跃匿名页:写入 swap 后释放
  4. 活跃页缓存 / 匿名页:降级为不活跃,待下一轮回收

6.2 Swap 机制

匿名内存(堆、栈、mmap 匿名映射)无法丢弃或回写文件,必须通过 Swap 换出。swappiness 参数控制内核在"丢弃页缓存"和"交换匿名页"之间的偏好:

# 降低 swappiness,倾向丢弃缓存而非换出
$ echo 10 > /proc/sys/vm/swappiness

# 完全禁用 swap(对低延迟服务推荐)
$ swapoff -a
# 或在 cgroup v2 中:
$ echo 0 > /sys/fs/cgroup/memory.swap.max

swappiness=0 的含义是"除非无文件页可回收,否则不 swap",而非彻底禁用——除非配合 cgroup 或 swapoff。

6.3 Zswap / Zram

  • Zswap:压缩缓存层,匿名页先压缩存入内存池,满后再写回 swap,兼顾快速换回和小内存占用
  • Zram:内存中的压缩块设备,全在内存中,读写不落盘,适合无磁盘 swap 的场景

启用 Zram:

$ modprobe zram num_devices=1
$ echo 4G > /sys/block/zram0/disksize
$ mkswap /dev/zram0
$ swapon /dev/zram0 -p 32767

七、OOM Killer:最后的防线

当所有回收手段均失败、分配请求仍无法满足时,OOM Killer 通过启发式评分选择进程终止,释放其占用的内存。

7.1 oom_score 计算

内核为每个进程维护一个 oom_score(0-1000+),分数越高越可能被 kill。计算方式大致为:

oom_score ≈ (进程占用物理内存比例 × 1000) + 调整量

调整量由 oom_score_adj(-1000 到 1000)控制:

  • -1000:不可被 OOM kill(内核线程、关键系统进程)
  • -100 到 +100:相对调整
# 查看进程 oom 评分
$ cat /proc/<pid>/oom_score
$ cat /proc/<pid>/oom_score_adj

# 保护关键服务不被 kill
$ echo -1000 > /proc/<pid>/oom_score_adj

# cgroup v2 中通过 memory.max 限制,超限触发 cgroup OOM
$ echo 2G > /sys/fs/cgroup/<cgroup>/memory.max

7.2 cgroup 内存控制与 .oom

cgroup v2 的 memory.max 设置硬限制,超出时对该 cgroup 触发局部 OOM,不影响全系统。配合 PSI(Pressure Stall Information)可提前感知内存压力。

$ cat /proc/pressure/some
some avg10=2.34 avg60=1.23 avg300=0.45 total=987654321
some avg10=0.56 avg60=0.89 avg300=1.12 total=123456789

# some = 任意任务因内存等待
# full = 所有任务同时因内存等待

八、vmalloc 与地址空间管理

kmalloc 分配物理连续的内存(适合 DMA 等硬件需求),vmalloc 分配虚拟连续但物理可不连续的内存(适合仅需内核逻辑访问的大块内存)。vmalloc 的代价是需要额外的页表映射,且非物理连续导致 DMA 不可用。

  • kmalloc:物理连续,最大约 4MB(受 MAX_ORDER 限制),GFP 标志控制
  • vmalloc:虚拟连续,接近 3GB(VMALLOC_ZONE)可用空间,线性映射
  • kmap/kmap_atomic:将高端内存临时映射到内核地址空间,用于 32 位系统

九、内存管理性能调优实战

9.1 生产服务器通用参数

# /etc/sysctl.d/99-memory.conf

# 降低 swap 倾向
vm.swappiness = 10

# 提升脏页刷新阈值(适合大内存服务器)
vm.dirty_ratio = 40
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 3000
vm.dirty_writeback_centisecs = 500

# 提升脏页刷新阈值(高 I/O 场景推荐)
vm.dirty_bytes = 268435456     # 256MB 才开始回写
vm.dirty_background_bytes = 67108864  # 64MB 后台回写

# 保留最小可用内存
vm.min_free_kbytes = 262144    # 256MB,保证紧急分配

# 提升网络/文件缓存的水位
vm.vfs_cache_pressure = 50

# 大内存系统放宽 overcommit
vm.overcommit_memory = 0      # heuristic overcommit
vm.overcommit_ratio = 80

# NUMA 平衡延迟(高负载时适当放宽)
kernel.numa_balancing = 1

9.2 数据库场景(PostgreSQL/MySQL/Redis)

# 禁用透明大页(数据库推荐)
# GRUB: transparent_hugepage=never
$ echo never > /sys/kernel/mm/transparent_hugepage/enabled
$ echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 减少 swap(建议物理内存远大于数据集)
vm.swappiness = 1

# 增大共享内存
kernel.shmmax = 68719476736   # 64GB
kernel.shmall = 16777216

# 锁定大页使用(Redis 可配置)
vm.nr_hugepages = 512

9.3 容器场景(K8s/Docker)

# /etc/sysctl.d/99-k8s-memory.conf

# 容器的内存限制由 cgroup 管理
# 建议关闭跨 NUMA 节点内存交错,避免跨节点访问延迟
vm.zone_reclaim_mode = 0

# K8s 中 memory.limit_in_bytes / memory.max 由 kubelet 设置
# 同时为 Burstable Pod 预留 buffer.memory.max = limit - request

9.4 DPDK/NFV 场景

 内核启动参数
default_hugepagesz=1G hugepagesz=1G hugepages=8 default_hugepagesz=2M hugepagesz=2M hugepages=1024

# 隔离 CPU 与内存
isolcpus=2-7,10-15

# iommu 直通
intel_iommu=on

十、观测与故障排查工具

生产环境中需要一套完整的观测手段来诊断内存相关问题。

10.1 基础工具链

# vmstat - 系统内存/si/so/每秒上下文切换
$ vmstat 1
procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free   buff  cache   si   so    bi    bo
 2  0      0 523456 345678 1256789  0    0    12    45

# free -h - 查看内存占用总览
$ free -h
              total        used        free      shared  buff/cache   available
Mem:           62Gi       23Gi       5.0Gi       1.2Gi       33Gi        36Gi
Swap:         8.0Gi          0B       8.0Gi

# sar -r - 详细内存指标(SAR 包)
$ sar -r 1

# top/htop - 进程级 RES/SHR 查看

10.2 深度诊断工具

# /proc/buddyinfo - 伙伴系统各阶空闲块
$ cat /proc/buddyinfo
Node 0, zone   Normal   32   15   8   4   2   1   0   0   1   0   1

# /proc/pagetypeinfo - 按迁移类型分布(碎片分析)
$ cat /proc/pagetypeinfo

# /proc/vmallocinfo - vmalloc 分配列表
$ cat /proc/vmallocinfo | awk '{sum+=$2} END {print sum " bytes in use"}'

# /proc/zoneinfo - 每个 Zone 的详细统计(free/free_pages/low/high watermark)
$ cat /proc/zoneinfo

# /proc/<pid>/smaps - 进程每段内存详细统计(PSS/RSS/Shared)
$ grep -E "^(Rss|Pss|Private|Shared):" /proc/1/smaps | sort | uniq -c | sort -nr

# slabtop - 实时监控 Slab 分配
$ slabtop -s c   # 按大小排序

# bpftrace 跟踪慢速内存分配
bpftrace -e 'km:mm_page_alloc { @[kstack] = count(); }'

# perf 观察 page fault
$ perf stat -e page-faults,minor-faults,major-faults -p <pid>

10.3 常见内存泄漏排查

当观察到可用内存持续下降时,按以下步骤排查:

  1. SAR 趋势:sar -r 检查 memfree 斜率,判断线性/指数增长
  2. Slab 异常增长:slabtop 观察 dentry/inode_cache 是否失控(小文件服务器常见)
  3. PSS 增量:检查进程 smaps 的 PSS 是否在增长(用户态内存泄漏)
  4. WCHAN/kswapd:确认是否频繁内存回收
  5. cgroup OOM 日志:dmesg | grep -i "oom\|out of memory" 被 kill 的具体原因
  6. bpftrace/leak_bt:使用 BCC 的 leak_bt 跟踪内核未释放分配

十一、内存子系统发展趋势

Linux 内存管理仍在持续演进,几个值得关注的方向:

  • CXL(Compute Express Link)内存池化:扩展 tiered memory 支持,NUMA 节点可指向外部内存设备,需要内核的 Soft-DLB 平衡策略
  • Memory Folios:用 folio(复合页组)替代 struct page,减少大页操作开销,5.16+ 内核正在逐步迁移
  • MGLRU(Multi-Gen LRU):5.18+ 改进的 LRU 算法,按世代区分页面活跃度,避免传统突发流量刷掉热页的问题
  • PAN(Privileged Access Never)/PAN 强化(ARM64):硬件隔离内核/用户访问,防内核越界读写
  • Usenet 零拷贝:slow path bypass,绕过常规分配路径,针对高频大块分配场景

总结

Linux 内存子系统是一组精密协作的层次结构:NUMA 节点组织物理内存,伙伴系统管理连续页框,Slab 复用内核小对象,页表与 TLB 完成虚拟到物理的映射,LRU 算法管理回收,OOM Killer 作为最后防线。理解这些机制的交互关系,才能在生产环境中做出正确的调优决策——无论是降低 swappiness、预分配大页、配置 cgroup 限额,还是诊断内存泄漏,都需要回到这些底层逻辑。

对于性能敏感的关键服务,建议固定大页、合理配置 cgroup 限额、禁用透明大页、监控 PSI 指标,将内存管理从"被动响应"转为"主动预防"。

点赞(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; }