Linux 内核内存管理深度实战

Linux 内核内存管理深度实战:从虚拟地址到物理页面的全链路解析

内存管理是 Linux 内核最复杂、最核心的子系统之一。无论是页面回收导致的服务延迟毛刺,还是 THP 引发的数据库性能崩溃,亦或是容器 OOM 带来的线上事故 —— 这些问题的根源都在于对内存子系统缺乏深度理解。本文从硬件 TLB 出发,完整拆解虚拟地址转换、物理分配器、反向映射、页面回收、大页机制和 cgroup 内存控制器的全链路原理,并给出生产环境调优的实战方案。


一、虚拟内存与页表遍历:CPU 视角的地址转换

现代处理器通过 MMU(Memory Management Unit)完成虚拟地址到物理地址的转换。x86_64 架构采用四级页表结构,在 57 位虚拟地址空间(5-Level Paging)下扩展为五级:

CR3 → PGD → P4D → PUD → PMD → PTE → Physical Page (4KB)

每个页表项(PTE)占用 8 字节,包含物理页框号(PFN)、权限位(R/W/X)、User/Supervisor 标志、Accessed/Dirty 位以及 NX(No-Execute)位。以 4KB 页为例,每级页表恰好占一个页面,可容纳 512 项(9 级索引)。

关键代码路径位于 arch/x86/mm/pgtable.c:

// 虚拟地址分解为页表索引
static inline pgd_t *pgd_offset(struct mm_struct *mm, unsigned long addr)
{
    return mm->pgd + pgd_index(addr);
}

// 遍历页表的典型实现
pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{
    return (pte_t *)pmd_virtual(*pmd) + pte_index(address);
}

TLB(Translation Lookaside Buffer) 是 MMU 的缓存,现代 CPU 通常具备 L1 iTLB(128 项)、L1 dTLB(64 项)和 L2 STLB(1536-2048 项)。TLB miss 会触发硬件 Page Walk,在最坏的四级页表场景下需要 5 次内存访问 —— 这也是为什么大页(2MB/1GB)能显著提升 TLB 命中率。

当进程切换时,Linux 通过加载新进程的 mm->pgd 物理地址到 CR3 寄存器来切换地址空间。从内核 5.10 开始,PCID(Process-Context Identifier)支持允许 TLB 条目在进程间保留,避免每次上下文切换都冲刷 TLB,显著降低了多进程场景下的切换开销。


二、物理内存分配器:Buddy System 与 SLUB

Buddy System

Linux 将物理内存组织为 mem_map[] 数组,每个元素对应一个 struct page。物理内存按 Zone 划分:ZONE_DMA(16MB)、ZONE_DMA32(4GB)、ZONE_NORMAL、ZONE_HIGHMEM(仅 32 位系统)。

Buddy System 维护 11 个 free_area 链表(order 0-10),分别管理 4KB、8KB、...、4MB 大小的连续物理块:

struct zone {
    free_area_t    free_area[MAX_ORDER]; // order N: 2^N pages
};

// 分配 2^order 个连续页面
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)

分配过程从合适的 order 链表取出一个块,若该 order 链表为空,则从更高 order 链表取块并"分裂"(buddy split)。释放时,检查 buddy 块是否空闲,若空闲则合并(coalesce)为更大的块。

SLUB 分配器

SLUB(Unqueued Slab Allocator)是 Linux 默认的小对象分配器,替代了早期的 SLAB 和 SLOB。每个 cache 对应一种内核对象类型(如 task_struct、inode、dentry):

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab; // Per-CPU 快速路径
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点
    unsigned int object_size;
    unsigned int offset;
    struct kmem_cache_order_objects oo; // 最佳 order
};

SLUB 的核心优化是 Per-CPU 的 freelist:每个 CPU 维护一个本地空闲对象链表,分配和释放时无需加锁,仅在本地链表为空时才从 partial 链表补充。这对于高频分配/释放场景(如网络收发包中的 sk_buff)至关重要。

生产环境诊断:

# 查看 slab 内存占用分布
cat /proc/slabinfo | head -30

# slabtop -o 实时查看 slab 占用
slabtop -o

# 如果 dentry 或 inode_cache 增长异常:
# 说明可能存在文件描述符泄漏或目录遍历

三、反向映射与页面回收

Reverse Mapping (rmap)

当物理页面需要被回收时,内核需要找到所有映射该页面的 PTE 并将其标记为 not-present。反向映射正是解决这一问题的机制,分为两类:

  1. 匿名页(Anonymous Page):通过 anon_vma 链表。每个 mm_struct 中的匿名页面都有一个 anon_vma 结构,形成树状结构以便 fork 后的 COW 共享。回收时从 anon_vma->rb_root 遍历所有关联的 VMA。

  2. 文件页(File-backed Page):通过 address_space 的 radix tree / XArray。文件映射的页面按文件偏移量组织在文件的 address_space 中,回收时直接定位到文件对应的 page cache 节点。

// 反向映射的核心入口
int try_to_unmap(struct page *page, enum tmu_flags flags)
{
    if (PageAnon(page))
        return try_to_unmap_anon(page, flags);
    else
        return try_to_unmap_file(page, flags);
}

LRU 页面回收

Linux 使用 LRU(Least Recently Used)算法管理页面回收。从内核 5.0 开始采用 Active/Inactive 双链表设计:

  • Active List:最近被访问过的页面
  • Inactive List:长期未访问的候选回收页面

页面在两个链表间移动:第二次访问时从 Inactive 提升到 Active;当 Active 链表过大时,尾部的页面被降级到 Inactive。回收时优先扫描 Inactive 链表尾部。

页面回收由 kswapd 后台线程驱动,当空闲内存低于 wmark_low 时唤醒。从内核 5.0 引入的 PSI(Pressure Stall Information)接口可以更精确地监控页面回收带来的调度延迟:

# 查看 CPU/IO/内存压力
cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=123456789

# 设置内存压力阈值(cgroup v2)
echo "some 50000 100000" > /sys/fs/cgroup/myapp/memory.pressure

OOM Killer

当所有回收手段用尽仍无法释放足够内存时,OOM Killer 被触发。Linux 使用 oom_score 评估每个进程的"可杀性":

// mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    // 基于 RSS、swap 占用、CPU 时间、oom_score_adj 综合计算
    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
    points *= 1000;
    points /= totalpages;
    // 应用 oom_score_adj 调整
    points += p->signal->oom_score_adj;
    return points > 0 ? points : 1;
}

生产建议: 对关键数据库和缓存服务设置 oom_score_adj=-1000 防止被 OOM;对批处理任务设置正值加速被杀。


四、大页与透明大页

HugePages(静态大页)

静态大页需要在系统启动时通过 hugepages=1024 内核参数预分配,或在运行时通过 sysctl vm.nr_hugepages=1024 在 NUMA 节点分配。2MB 大页的 TLB 覆盖面积为 2MB × TLB entries,相比 4KB 小页的覆盖能力提升 512 倍。

# 查看当前大页配置
cat /proc/meminfo | grep Huge

# 预留 1024 个 2MB 大页
echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# 巨页文件系统挂载
mount -t hugetlbfs hugetlbfs /mnt/huge

使用静态大页需要在程序中显式映射(mmap(MAP_HUGETLB)),PostgreSQL 和 DPDK 等高性能应用广泛使用这种方式。

透明大页(THP)

透明大页(Transparent Huge Pages)是内核自动的透明合并机制:当检测到连续的 4KB 小页满足合并条件时,khugepdd 守护进程自动合并为 2MB 大页。

# THP 三种模式:always / madvise / never
cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

THP 的生产陷阱: 对于数据库工作负载(PostgreSQL、MongoDB、Redis),THP 经常导致性能下降。原因是: 1. THP 失败时的 compaction 操作造成严重延迟尖峰 2. 数据库的随机访问模式使得大页的 TLB 优势无法体现 3. Defragmentation 的开销抵消了 TLB 收益

建议: 数据库和内存缓存系统建议设置 transparent_hugepage=never,网络代理和 HPC 应用建议设置为 always 或显式 madvise(MADV_HUGEPAGE)。


五、KSM:内核同页合并

KSM(Kernel Same-page Merging)是内核的去重机制,最初为 KVM 虚拟机密度优化设计。KSM 定期扫描内存区域,将内容相同的物理页面合并为同一只读页面,多余的页面被释放。

# 启用 KSM
echo 1 > /sys/kernel/mm/ksm/run

# 合并一个 VMA 区域(用户态调用 madvise)
madvise(addr, length, MADV_MERGEABLE);

核心参数调优:

# 每次扫描的页数(默认 100)
echo 1000 > /sys/kernel/mm/ksm/pages_to_scan

# 两次扫描间的毫秒数(默认 200)
echo 20 > /sys/kernel/mm/ksm/sleep_millisecs

KSM 的收益与代价需要权衡:高密度虚拟机部署可节省 50%+ 内存,但 CPU 开销显著。建议仅在同构虚拟机或重复内容较多的场景启用。


六、cgroup v2 内存控制器

cgroup v2 提供了精细的内存控制能力:

# 设置内存硬限制
echo "2G" > /sys/fs/cgroup/myapp/memory.max

# 设置内存软限制(高压力下生效)
echo "1.5G" > /sys/fs/cgroup/myapp/memory.high

# 设置 swap 限制
echo "512M" > /sys/fs/cgroup/myapp/memory.swap.max

# 查看 OOM 事件计数
cat /sys/fs/cgroup/myapp/memory.events

关键参数说明:

参数 作用 生产建议
memory.max 硬限制,触发 cgroup OOM 设置为容器规格的 1.1-1.2 倍
memory.high 软限制,触发回收和限流 设置为容器规格的 80-90%
memory.low 保护阈值,避免被其他 cgroup 挤占 设置为关键业务最小保障
memory.min 绝对保护,不被回收 仅用于极关键页面
memory.swap.max swap 限制 有状态服务建议 0(禁用 swap)

PSI 与 QoS 联动: 在现代 Kubernetes 部署中,建议将 PSI memory pressure 与 HPA 联动:当 memory.some 压力超过阈值时提前扩容,而不是等 OOM 发生后才响应。


七、实战:三个典型内存问题的排查与修复

场景一:数据库延迟毛刺

现象: Redis 平均延迟 0.5ms,P99 达 200ms,每秒数百次毛刺。

排查: 通过 /proc/pressure/memory 发现 some avg10 周期性飙升至 80%+。进一步通过 perf 跟踪发现是 khugepaged 触发的页面 compaction 占据了 IO 带宽。

# 解决方案:禁用透明大页
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 验证:设置后毛刺消失,P99 降至 2ms

场景二:容器频繁 OOM

现象: Java 容器每天 OOM 3-5 次,heap 仅使用 60%。

排查: 通过 memory.stat 发现 page cache 占用 1.5GB,远超过 JVM heap 之外剩余空间。JVM 的 NIO Direct ByteBuffer 和 memory-mapped file 绕过 cgroup 内存统计。

# 方案一:降低 cgroup limit,为 page cache 留出空间
echo "3G" > /sys/fs/cgroup/java/memory.max  # 原 4G

# 方案二:限制 JVM MaxDirectMemorySize
java -XX:MaxDirectMemorySize=256m -XX:MaxDirectMemorySize=512m ...

# 方案三:使用 jemalloc 替代 glibc malloc,更好控制 RSS

场景三:slab 内存泄漏

现象: K8s 节点可用内存持续下降,重启 pod 后短暂恢复但很快复现。

排查:

# 检查 slab 分布变化
watch -n1 "cat /proc/slabinfo | awk '{print \$1, \$2*\$3/1024\"KB\"}' | sort -k2 -nr | head"

# 发现 dentry 和 inode_cache 持续 200MB/分钟增长
# 定位到应用在不断创建临时文件但未清理

# 查看具体缓存对象
slabtop -o | head

# 临时清理(测试用)
echo 2 > /proc/sys/vm/drop_caches  # 清除 slab 和 page cache

八、总结

Linux 内存管理是一个环环相扣的系统工程:TLB 命中率决定地址转换开销,Buddy System 保证物理连续性,SLUB 优化小对象高频分配,反向映射使页面回收可行,LRU 双链表兼顾命中率与回收效率,大页机制放大 TLB 覆盖范围,KSM 释放冗余内存,cgroup 提供精细的 QoS 分层。

生产环境中的内存问题往往不是单一模块的故障,而是多个环节级联反应的结果。建议构建以下监控体系:

  • page-level:pgscan_kswapd, pgsteal_kswapd, pswpin/pswpout 追踪回收与 swap
  • slab-level:定期快照 /proc/slabinfo 监控异常增长
  • cgroup-level:memory.current, memory.events.oom, memory.pressure
  • application-level:GC pause, RSS, PSS, USS 多维交叉分析

理解这些底层机制,才能在面对复杂内存问题时快速定位根因,而非救火式地重启服务。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部