引言:为什么内存管理是内核最复杂的子系统

Linux 内核的内存管理子系统是整个操作系统中最复杂、最核心的组件之一。它负责管理物理内存的分配与回收、虚拟地址空间的映射与隔离、页面置换与回收、以及内存压力的动态响应。理解内存管理机制,不仅是深入 Linux 内核的关键一步,更是排查系统性能瓶颈、内存泄漏、OOM(Out of Memory)等生产环境问题的必备技能。

本文将从物理内存管理入手,逐步深入 Buddy 系统、Slub 分配器、虚拟内存区域、页表机制、NUMA 架构优化、KSM 与 zswap 压缩技术、OOM Killer 算法、cgroup 内存限制,最终通过 perf 和 ftrace 实战展示内存相关问题的定位与调优方法。

一、物理内存管理架构

1.1 节点-区域-页面(Node-Zone-Page)三级模型

Linux 采用 NUMA(Non-Uniform Memory Access) 感知的三级内存管理模型:

  • NUMA Node(节点):每个 CPU socket 对应一个内存节点,通过 struct pglist_data 描述
  • Zone(内存区域):每个节点内按地址范围划分为 DMA、DMA32、Normal、Movable 等区域
  • Page(物理页帧):每 4KB(或大页)对应一个 struct page 结构

通过 /proc/zoneinfo 可以查看当前系统的内存区域分布和统计信息:

$ cat /proc/zoneinfo | head -40
Node 0, zone      DMA
  pages free     3968
        min      128
        low      160
        high     192
        scanned  0
        spanned  4095
        managed  3976
  Node 0, zone    DMA32
  pages free     716796
        min      12899
        ...
  Node 0, zone   Normal
  pages free     15624056
        min      1270000
        ...

其中 min/low/high 是内核的三大水印阈值,决定了页面回收的触发时机:当空闲页低于 min 时直接进入直接回收(direct reclaim),低于 low 时唤醒 kswapd 异步回收,high 表示回收目标水位。

1.2 mem_map 与 struct page

系统启动时,内核会为每个物理页帧创建一个 struct page 结构,全部存储在 mem_map 数组中。这是内核追踪物理内存状态的核心数据结构:

struct page {
    unsigned long flags;        /* 页面状态标志:PG_locked, PG_dirty, PG_lru... */
    atomic_t _refcount;         /* 引用计数 */
    atomic_t _mapcount;         /* 映射到多少个进程的页表 */
    struct { /* 联合体,根据页面用途不同含义不同 */
        struct list_head lru;       /* LRU 链表节点 */
        struct address_space *mapping; /* 页面的地址空间映射 */
        pgoff_t index;              /* 在映射中的偏移 */
    };
    unsigned long private;      /* 私有数据 */
    void *virtual;              /* 内核虚拟地址(高端内存时有效) */
};

二、Buddy 分配器:物理页的管理基石

2.1 伙伴系统的核心原理

Buddy 分配器管理以页(4KB)为最小单位。它将空闲页面按大小分组为 11 个链表(order 0~10),分别管理 1、2、4、...、1024 个连续页面的块。分配时优先从对应 order 的链表取,若不够则从更大 order 的链表拆出一半;释放时检查"伙伴"是否空闲,若是则向上合并为更大块。

这种设计天然保证了分配的连续性,是 DMA 设备和内核大块内存需求的基础。

2.2 /proc/buddyinfo 分析

$ cat /proc/buddyinfo
Node 0, zone      DMA      1   1   0   1   2   1   1   0   1   1   3
Node 0, zone    DMA32  2847 1201  475  286  123   51   17    6    3    1    0
Node 0, zone   Normal 82705 59456 64857 81624 ...

每列对应 order 0~10 的空闲块数量。如果高阶 order(8~10)数量持续为 0,说明系统存在外部碎片化问题。

2.3 页面迁移与碎片整理

Linux 2.6.24 引入了页面迁移机制,将可移动页面分组到 Movable zone,不可移动页面分到不可移动 zone。CONFIG_COMPACTION 配置启用内存规整(memory compaction),通过在后台移动页面来合并出连续大块。

手动触发:

# 触发内存碎片整理
echo 1 > /proc/sys/vm/compact_memory

# 查看碎片化指数
cat /sys/kernel/debug/extfrag/extfrag_index

三、Slub 分配器:内核对象的内存管家

3.1 Slab/Slub/SLOB 三代演进

Slab 分配器建立在 Buddy 之上,用于管理小于页的内核对象分配。三代演变:

  • Slab:最早的实现,使用复杂但效果有限
  • SLOB:面向嵌入式设备的精简实现,适合内存极小的系统
  • Slub:当前默认实现,简化了缓存管理,提升了 SMP 扩展性

3.2 Slub 工作原理

Slub 为每个常见内核结构(task_struct、inode、dentry 等)建立专用缓存(kmem_cache)。每个缓存管理多个 SLAB(由一个或多个连续页组成),每个 SLAB 被划分为等大小的对象槽位。

关键数据结构:

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;  /* 每CPU快速分配缓存 */
    struct kmem_cache_node *node[MAX_NUMNODES]; /* 每节点半满/全满SLAB */
    unsigned int size;          /* 对象实际大小 */
    unsigned int object_size;   /* 对象最小对齐大小 */
    unsigned long flags;        /* SLAB flags (如 SLAB_ACCOUNT) */
    unsigned int offset;        /* 空闲链表指针偏移 */
    unsigned int oo;            /* max order 和 min count */
    const char *name;           /* 缓存名称 */
};

struct kmem_cache_cpu {
    void **freelist;            /* 空闲对象链表头 */
    unsigned long tid;          /* 全局唯一事务ID,防竞态 */
    struct page *page;          /* 当前正在使用的SLAB页 */
    struct page *partial;       /* 每CPU部分空闲SLAB链表 */
};

分配优先级:CPU freelist(最快)→ CPU partial slab → Node partial slab → 新的页面(最慢)

3.3 /proc/slabinfo 与 slabtop 监控

$ slabtop -o 2>/dev/null | head -20
 Active / Objects (% used)    Overhead / Slab / Active Obj Size  Cache Size  Name
  128K / 128K (100%)          12K / 4K / 4     1K         128K        dentry_cache
   96K /  96K (100%)           9K / 8K / 6     512B       48K         kmalloc-512
   48K /  48K (100%)           4K / 16K / 12   256B       12K         kmalloc-256
  ...

重点关注:100% 使用率但 num_active < num_objects 的缓存(可能有空闲 slab 无法释放),以及对象数量异常增长的缓存(内存泄漏)。

3.4 kmalloc 的 SLUB 分级

kmalloc 通过预定义的大小分级(16/32/64/96/128/.../8K)将分配请求路由到对应的 kmem_cache。超出 8KB 的请求则直接由 Buddy 系统分配。/proc/kmallocinfo(需开启 CONFIG_DEBUG_FS)可查看各尺寸缓存的分配细节。

四、虚拟内存与多级页表

4.1 虚拟地址空间布局

x86_64 架构下,Linux 使用 48 位虚拟地址(57 位可选启用):

用户空间(128TB):
0x000_0000_0000 (4KB)        → 保留 NULL
0x000_0000_1000 ~ 0x7FFF_FFFF_F000 → 代码/堆/共享库/内存映射
0x8000_0000_0000 (128TB)     → 未映射空洞(canonical hole)

内核空间(128TB):
0xFFFF_8000_0000_0000         → 直接映射区(physmap)
0xFFFF_8880_0000_0000         → 内核代码/data(直接映射起点)
0xFFFF_C000_0000_0000         → vmemmap(struct page数组)
0xFFFF_E9FF_FFFF_F000         → vmalloc/ioremap 区域
0xFFFF_FF00_0000_0000         → KASAN/固定映射
0xFFFF_FFFF_FFFF_F000         → 顶部保留区

4.2 四级/五级页表机制

页表负责虚拟地址到物理地址的转换。x86_64 传统使用 4 级页表(PML4 → PDPT → PD → PT),57 位地址启用 5 级(PML5)。

CR3 寄存器存储顶级页表物理地址,硬件 MMU 通过查表完成转换,并借助 TLB(Translation Lookaside Buffer) 缓存最近转换。

虚拟地址 48 位(4 级):
| PML4(9) | PDPT(9) | PD(9) | PT(9) | Offset(12) |
   [39:38]   [30:29]  [21:20] [12:11]   [11:0]

TLB miss 性能开销极大(20~100 个 CPU 循环),因此大页(2MB/1GB)的使用至关重要。

4.3 Transparent Huge Pages(THP)

THP 自动将连续普通页合并为大页(2MB),减少 TLB miss,提升内存密集型负载性能。但对数据库等需要精细内存管理的应用可能带来碎片化和分配延迟问题。

$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

# 查看系统大页使用
$ grep -i huge /proc/meminfo
AnonHugePages:   204800 kB
HugePages_Total:    1024
HugePages_Free:     512
Hugepagesize:       2048 kB

建议数据库场景关闭 THP:echo never > /sys/kernel/mm/transparent_hugepage/enabled

五、页面回收与交换

5.1 LRU 链表与 kswapd

Linux 使用 LRU(Least Recently Used) 算法追踪页面活跃度,维护两组 LRU 链表:Active 和 Inactive。页面首次访问加入 inactive,再次访问提升到 active。回收时优先从 inactive 链表尾部取页面。

两类页面分别管理:

  • Anonymous Pages:匿名页(堆内存、栈、mmap 私有匿名映射)
  • File Pages:文件页(缓存文件数据,通过 page cache 管理)

kswapd 是后台页面回收守护进程。当空闲页低于 low 水位时被唤醒,回收直到 high 水位为止。当低于 min 时,进程直接在分配路径中执行 direct reclaim(性能杀手)。

5.2 swap 机制与 swappiness

swappiness 控制匿名页相对于文件页的回收权重(0~200),默认 60:

echo 10 > /proc/sys/vm/swappiness   # 倾向保留匿名页,减少swap
echo 0  > /proc/sys/vm/swappiness   # 几乎不swap OOM前才用

对生产服务器通常建议 10 或更低,配合充足物理内存使用。

5.3 zswap 与 zRAM:内存压缩方案

  • zswap:在页面写入 swap 前用 zstd/lzo 压缩,若压缩后小于 95% 则缓存于内存的动态分配池中,减少磁盘 IO
  • zRAM:创建一个基于内存的压缩块设备作为 swap 空间,完全避免磁盘 IO
# 启用 zRAM
modprobe zram num_devices=1
echo lz4 > /sys/block/zram0/comp_algorithm
echo 8G > /sys/block/zram0/disksize
mkswap /dev/zram0 && swapon /dev/zram0 -p 100

# 查看压缩比/节省内存
cat /sys/block/zram0/mm_stat
# 输出: orig_data_size compr_data_size mem_used_total ...
#       16xxxxxxxx  8xxxxxxxx      9xxxxxxxx  (压缩比约50%)

六、NUMA 架构与内存策略

6.1 NUMA 拓扑感知

多路服务器中,每个 CPU socket 配有本地内存,通过 QPI/UPI 总线访问远程内存,延迟高 1.5~3 倍。

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11
node 0 size: 131023 MB
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23
node 1 size: 131072 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

距离矩阵 10 表示本地访问,21 表示跨节点(2.1 倍延迟)。

6.2 自动 NUMA 平衡

Linux 4.7+ 引入 AutoNUMA balancing:内核周期性清除进程页面的 Access 位,当发现页面被其他 NUMA 节点的 CPU 频繁访问时,触发页面迁移到访问节点。

控制开关:

echo 1 > /proc/sys/kernel/numa_balancing    # 启用自动平衡
echo 0 > /proc/sys/kernel/numa_balancing    # 禁用(对延迟敏感服务可关闭)

6.3 numactl 手动绑定策略

  • --interleave=all:交替分配到所有节点,适合大内存分配但不绑定 CPU 的场景
  • --membind=node_list:强制在指定节点分配内存
  • --cpunodebind=node:绑定 CPU + 本地内存分配

七、OOM Killer:最后的防线

7.1 何时触发 OOM

当系统无法满足任何内存分配请求(包括 direct reclaim 和 compaction 后仍失败),且无法再回收页面时,OOM Killer 被触发。

查看当前 OOM 风险:

$ cat /proc/meminfo | grep -i commit
Committed_AS:   52428800 kB
CommitLimit:    67145728 kB
# Committed_AS 接近 CommitLimit 时,overcommit 风险极高

7.2 oom_badness() 评分机制

OOM Killer 通过 oom_badness() 为每个进程打分,分数最高的先被杀。评分公式:

score = (total_vm_pages * 1000 / total_ram_pages) * adj_factor

其中 oom_score_adj(-1000~1000)为用户可调整的系数,-1000 表示永不 OOM 杀死。

查看进程 OOM 评分:

$ for pid in $(ls /proc | grep -E '^[0-9]+$'); do
    if [ -f /proc/$pid/oom_score ]; then
        echo "$(cat /proc/$pid/oom_score) $(cat /proc/$pid/comm 2>/dev/null)($pid)"
    fi
done | sort -rn | head -10

7.3 cgroup 级别的 OOM 控制

当进程属于 cgroup 且 memory.oom_control 设为 1 时,只有该 cgroup 超出内存限制时才会触发 cgroup OOM,不影响系统全局。

echo 1 > /sys/fs/cgroup/memory/mygroup/memory.oom_control

八、cgroup 内存限制:精细化管控

8.1 memory 子系统核心参数

cgroup v1 内存控制器核心参数:

  • memory.limit_in_bytes
  • memory.soft_limit_in_bytes:软限制,内存压力下优先回收
  • memory.swappiness:此 cgroup 独立的 swappiness 值
  • memory.kmem.limit_in_bytes

cgroup v2 内存控制器更简洁:

  • memory.max:硬限制
  • memory.high:内存压力阈值,触发回收但不会 OOM
  • memory.low:保证保留的最小内存
  • memory.min:绝对保留量,不会被回收

8.2 查看 cgroup 内存使用统计

$ cat /sys/fs/cgroup/memory/docker//memory.stat
cache                   1638400
rss                     32768000
rss_huge                0
mapped_file             819200
swap                    0
pgpgin                  123456
pgpgout                 12345
pgfault                 6543210
pgmajfault              12
inactive_anon           102400
active_anon             32665600
inactive_file           51200
active_file             1587200
unevictable             0

其中 pgmajfault(缺页中断次数过高)通常是内存紧张的信号。

九、内存问题调试实战

9.1 使用 perf 分析内存热点

# 统计 L2/L3 缓存缺失
perf stat -e cache-misses,cache-references -p <PID>

# 采样页面缺页
perf record -e page-faults -p <PID> -g -- sleep 10

# 追踪 Buddy 分配(需 debugfs)
perf trace -e km:mm_page_alloc -p <PID>

# 分析 slab 分配/释放
perf probe --add 'kmem_cache_alloc name'
perf probe --add 'kmem_cache_free'
perf stat -e probe:kmem_cache_alloc,probe:kmem_cache_free -p <PID>

9.2 ftrace 追踪内存回收路径

 events/vmscan/vmscan_kswapd_sleep/enable
echo 1 > events/vmscan/vmscan_direct_reclaim_begin/enable
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmem_cache_alloc/enable
echo 1 > tracing_on

...运行负载...

cat trace | head -100
echo 0 > tracing_on

9.3 排查 Slab 内存泄漏

当一个 kmem_cache 中对象数量只增不减,高度怀疑内存泄漏。使用 slabinfo 工具追踪:

# 安装 slabinfo-tool
slabtop -s c   # 按缓存大小排序

# 通过 /sys/kernel/debug/slab/<name>/alloc_traces 追踪分配路径
echo 1 > /sys/kernel/debug/slab/dentry/alloc_traces
cat /sys/kernel/debug/slab/dentry/alloc_traces

9.4 vmstat 实时监控

$ vmstat 1 10
procs -----------memory----------   -----swap--   -----io---- -system-- --------cpu-----
 r  b   swpd   free   buff  cache     si   so    bi    bo   in   cs us sy id wa st
 2  0      0 842340 120348 2367824   0    0     4     8  452 1234 15  2 82  1  0
 1  0      0 840292 120348 2369988   0    0     0    16  567 1345 18  3 79  0  0

关键指标:b(不可中断阻塞进程数)、si/so(swap in/out)、wa(IO等待)。>5 的 wa 或持续增长的 si/so 通常意味着内存压力。

9.5 sysctl 核心调优参数

# 直接回收的最小碎片阶(越大越激进但也越浪费内存)
vm.min_free_kbytes = 262144          # 256MB,根据系统内存调整

# 脏页刷写阈值(默认10表示10%空闲内存时开始写回)
vm.dirty_background_ratio = 5
vm.dirty_ratio = 15

# overcommit 策略(0=启发式,1=总是允许,2=严格拒绝)
vm.overcommit_memory = 1        # 数据库/HPC场景常用

# 大页预留
vm.nr_hugepages = 1024

# zone_reclaim_mode(NUMA 节点本地回收优先,数据库建议开启)
vm.zone_reclaim_mode = 1

# 内存压缩/碎片整理
vm.compaction_proactiveness = 50    # 5.18+ 主动压缩程度
vm.extfrag_threshold = 500          # 碎片指数阈值(0~1000)

十、内核演进与前沿趋势

10.1 Folio(2021 合入 5.16)

Folio 是 page 的"容器"抽象,将复合页(compound page)与 base page 统一到一个接口,大幅简化 hugetlb 和大页文件支持的代码复杂度。struct folio 嵌入 page 并使用 LSB 标志区分,让 msmap、DMA 等子系统对透明大页"无感"。

10.2 DAMON(Data Access MONitor,2021 合入 5.15)

DAMON 通过采样追踪进程的实际内存访问模式,实现精确、低开销的冷热页面判定,替代了传统 LRU 的近似算法。已被用于:

  • DAMON_RECLAIM:主动回收未使用页面
  • DAMON_LRU_SORT:为 LRU 提供智能页面热度信息
# 使用 damo 工具监控 python 进程访问模式
damo record -t 10000 -p $(pidof python3)
damo report heats --heatmap heatmap.png

10.3 CXL(Compute Express Link)内存扩展

CXL 1.1/2.0 支持通过 PCIe 总线连接外部内存设备,Linux 正在集成"CXL Memory Tiering":将 CXL 内存作为慢速 tier,热页面在 DRAM,冷页面自动下沉到 CXL 内存,实现大容量与高性能的折中。

10.4 Memory Sealable Mappings(Linux 6.3+)

进程保护只读匿名映射的 API MSEAL,防止后续代码(可能被劫持)修改已初始化的映射权限,增强了进程安全性。

总结

Linux 内存管理是一个精密的分层系统:Buddy 管理物理页,Slub 管理内核对象,页表负责地址转换,LRU 驱动页面回收,OOM Killer 兜底。理解每个子系统的边界和交互机制,是优化内存性能、排查生产故障的基础。

关键记忆点:

  • Buddy 用于页级分配,Slub 用于内核对象
  • min/low/high 三水位直接决定回收时机
  • direct reclaim 是性能杀手,应增加内存或调整水位
  • zRAM/zswap 在内存紧张时提供压缩缓冲
  • NUMA 本地访问延迟仅为跨节点的 1/2~1/3
  • OOM Killer 按 oom_score 选择牺牲品,可调节 oom_score_adj 保护关键进程
  • cgroup memory controller 提供容器级隔离
  • DAMON + Folio 是内存管理最活跃的前沿方向

生产环境运维口诀:看 zoneinfo 知水位,盯 slabtop 察泄漏,调 swappiness 控 swap,绑 NUMA 减延迟,配 oom_score 保核心。

点赞(0) 打赏

评论列表 共有 0 条评论

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

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部