Linux内核内存管理深度实战:从伙伴系统到大页NUMA优化

深入理解Linux内存管理机制,掌握从物理页框分配到大页NUMA调优的完整链路

一、物理内存架构:NUMA、Zone与Node

现代多核服务器多采用NUMA(Non-Uniform Memory Access)架构,处理器访问本地内存节点(Local Node)的延迟远低于跨节点访问(Remote Node)。Linux内核将物理内存划分为NUMA Node,每个Node下再按用途划分为不同的Zone:

Zone 典型范围(x86_64) 用途
ZONE_DMA 0–16MB ISA DMA兼容设备
ZONE_DMA32 16MB–4GB 32位DMA设备
ZONE_NORMAL 16MB–896MB 内核直接映射(线性映射)
ZONE_HIGHMEM 896MB以上(仅32位) 高端内存,动态映射
ZONE_MOVABLE 可配置 可移动页面,支持热插拔和大页

在x86_64架构中,ZONE_HIGHMEM已基本退出历史舞台,因为64位地址空间足够庞大(48位或57位虚拟地址),内核通过mem_map[]全局数组直接管理所有物理页框(struct page)。

每个Zone维护一个free_area数组,用于伙伴系统的阶(order)管理:

struct zone {
    struct free_area    free_area[MAX_ORDER]; // 伙伴系统
    unsigned long       managed_pages;          // 由伙伴系统管理的页数
    unsigned long       spanned_pages;          // 跨度页数
    unsigned long       present_pages;          // 实际存在页数
    int                 node;                   // 所属NUMA节点
    // ...
};

struct free_area {
    struct list_head    free_list[MIGRATE_TYPES];
    unsigned long       nr_free;
};

MIGRATE_TYPES 用于抗碎片管理,将页框按可移动性分组:

  • MIGRATE_UNMOVABLE:内核数据结构(kmalloc、slab等)
  • MIGRATE_MOVABLE:用户空间页面、文件缓存
  • MIGRATE_RECLAIMABLE:可回收的内核缓存(dcache、inode cache)
  • MIGRATE_PCP:per-cpu pageset,高速本地缓存

二、伙伴系统(Buddy System):分配与回收的艺术

2.1 核心原理

伙伴系统按2的幂次组织空闲块(order 0 = 1页,order 10 = 1024页 = 4MB),分配时向上取整,回收时与"伙伴"(Buddy)合并成更大的块。

分配流程:

  1. 从请求的order开始,在对应free_list的空闲链表中查找
  2. 若找到,摘除并返回页面
  3. 若当前order无空闲块,向更高阶拆解——将大块一分为二,一半分配,另一半放入低阶free_list

回收流程:

  1. 判断要回收页面的"Buddy"是否也在同一阶空闲链表中
  2. 若伙伴空闲:合并成高一阶块,继续向上检查合并
  3. 若伙伴已分配:直接放入当前阶free_list

2.2 关键数据结构

struct page {
    unsigned long flags;           // 页标志:PG_locked, PG_dirty, PG_lru等
    atomic_t _refcount;            // 引用计数
    atomic_t _mapcount;            // 映射到用户空间页表项的数量
    struct {                       // union节省空间
        struct list_head lru;      // LRU链表节点
        struct list_head buddy_list; // 伙伴系统链表
    };
    unsigned long private;         // 各子系统私有数据
    void *virtual;                 // 虚拟地址(高端内存时有效)
};

2.3 per-cpu pageset(PCP)

为避免多CPU竞争Zone锁,每个CPU维护一组本地页缓存(PCP List),仅当本地缓存耗尽时才向伙伴系统批量申请。PCP同时维护冷页和热页两个列表:

  • 热页(Hot Page):刚从伙伴系统获取,Cache line可能还在CPU cache中,优先分配给小对象
  • 冷页(Cold Page):经过一段时间,Cache冷掉了,保留用于DMA或大块映射

查看PCP参数:

# cat /proc/zoneinfo | head -50
Node 0, zone   DMA32
  pages free     123456
        min      976
        low      1220
        high     1464
        managed  384000
        # PCP参数(通过sysctl vm.extra_free_kbytes调整)

2.4 Watermark水位线

每个Zone维护三个水位线——min/low/high,通过kswapd守护进程回收:

  • min:如果空闲页低于此值,分配器走slow path,触发直接回收
  • low:kswapd被唤醒,持续回收直到达到high水位
  • high:kswapd停止回收,系统恢复正常分配

监控命令:

# 各Zone水位信息
cat /proc/zoneinfo

# 内存碎片情况
cat /proc/buddyinfo

# pcp缓存数量(cat /proc/vmstat | grep pcp)

三、Slub分配器:内核小对象分配引擎

3.1 三层架构

Slub是Linux默认的小对象分配器(替代Slab和Slob),采用三层结构:

  1. kmem_cache:一类对象的缓存池,包含对象大小、对齐、构造函数等元数据
  2. kmem_cache_cpu:per-cpu高速缓存(fastpath),无锁分配
  3. kmem_cache_node:per-node后备池,当CPU缓存不足时从Node补充

3.2 分配流程(Fast Path)

static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfpflags)
{
    // 1. 优先从cpu_slab->freelist拿空闲对象(无锁)
    void *object = cpu_slab->freelist;
    if (object) {
        cpu_slab->freelist = get_freepointer(s, object);
        return object;
    }
    // 2. 慢路径:从node批次申请或创建新slab
    return slab_alloc_node(s, gfpflags, node);
}

每个slab由一个或多个连续页框组成,对象被紧密排列。空闲对象内部存储链表指针,实现O(1)分配。

3.3 SLUB Debug与性能调优

# 查看当前缓存使用情况
cat /proc/slabinfo | sort -k3 -nr | head -20

# 典型输出:
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
# dentry            1234567   1892345     192       21          2
# inode_cache        987654   1456789     584        7          2
# filp               543210    876543     256       16          2

# 内存浪费分析(大量slab对象 = dentry/inode cache占内存过高)
slabtop --once -sc | head -20

常见调优参数:

# 调整slab回收积极性(默认100,越小越积极回收)
sysctl -w vm.vfs_cache_pressure=50

# 主动清空dentry和inode cache(谨慎使用)
echo 2 > /proc/sys/vm/drop_caches

# 限制slab最大order(避免高阶分配失败)
sysctl -w vm.slab_max_order=0

3.4 SLAB_ACCOUNT与cgroup内存控制

较新内核中,SLAB_ACCOUNT标志使slab记账与cgroup关联,实现按cgroup的内存隔离。若某cgroup达到其memory.limit_in_bytes,会触发其自身的shrink回收,而非全局kswapd。

四、虚拟内存:VMA与页表管理

4.1 进程地址空间

每个进程通过struct mm_struct管理虚拟地址空间:

struct mm_struct {
    struct rb_root mm_rb;           // VMA红黑树根节点
    struct vm_area_struct *mmap;    // VMA链表
    unsigned long start_code, end_code;   // 代码段
    unsigned long start_data, end_data;   // 数据段
    unsigned long start_brk, brk;         // 堆区
    unsigned long start_stack;            // 栈底
    unsigned long arg_start, arg_end;     // 参数
    unsigned long total_vm;               // 总虚拟页面数
    unsigned long locked_vm;              // mlock的页面
    // NUMA相关
    struct mmu_gather *mmlist;            // 批量TLB刷新
};

4.2 VMA(Virtual Memory Area)

VMA代表虚拟地址空间中具有相同权限的连续区域,每个vm_area_struct描述一段VMA:

struct vm_area_struct {
    unsigned long vm_start;     // 虚拟起始地址
    unsigned long vm_end;       // 虚拟结束地址(不包含)
    struct mm_struct *vm_mm;    // 所属mm_struct
    pgprot_t vm_page_prot;      // 访问权限
    unsigned long vm_flags;     // 标志:VM_READ/WRITE/EXEC/SHARED/MERGEABLE
    struct vm_operations_struct *vm_ops;  // 缺页处理函数集
};

VMA通过红黑树和链表双重组织,红黑树用于快速地址定位,链表适合顺序遍历。内核还维护VMA Cache(mmap_cache缓存最近访问的VMA),命中时路径可跳过红黑树查找。

4.3 缺页异常处理流程

当CPU访问尚未建立映射的虚拟地址时,触发Page Fault(do_page_fault()→handle_mm_fault()):

  1. Find VMA:通过红黑树查找包含目标地址的VMA,若未找到 → SEGV(段错误)
  2. 权限校验:检查vm_flags是否允许当前访问(读/写/执行)→ 触发COW或SIGSEGV
  3. 匿名页(MAP_ANONYMOUS):分配物理页建立映射
  4. 文件映射(MAP_FILE):从Page Cache读取,结合fault()方法
  5. Demand Paging:页面调入并建立PTE映射
  6. COW(写时复制):对共享页面的写操作触发COW,分配新页框并复制内容

4.4 多级页表与TLB

x86_64采用4级页表(PGD→PUD→PMD→PTE),地址翻译:

39位虚拟地址 = 9bit(PGD) + 9bit(PUD) + 9bit(PMD) + 9bit(PTE) + 12bit(offset)

TLB(Translation Lookaside Buffer)缓存最近翻译结果:

  • 4K页TLB通常64-128 entries
  • 2M大页TLB通常32-64 entries
  • 2020年后硬件支持5级页表,支持57位虚拟地址(128PB)

查看TLB性能:

perf stat -e dtlb_load_misses.stlb_hit,dTLB-load-misses ./your_app

五、页面回收与LRU算法

5.1 双链LRU

Linux内核维护五种LRU链表(每种可移动性类型各一组):

  1. Active Anonymous:活跃匿名页(用户堆栈、匿名映射)
  2. Inactive Anonymous:不活跃匿名页
  3. Active File:活跃文件页(Page Cache中被频繁访问的)
  4. Inactive File:不活跃文件页
  5. Unevictable:不可回收(mlock、Ramfs等)

核心思想:页面先进入Inactive链表,再次被访问时提升到Active链表。回收时优先扫描Inactive链表的头部,实现近似LRU(Second Chance算法)。

5.2 kswapd与直接回收

kswapd是内存回收守护进程,每个NUMA Node有一个kswapd/线程:

  1. 监测Zone水位线,低于low时唤醒
  2. 通过扫描LRU链表,将Inactive页面回收
  3. 回收到high水位线后sleep

直接回收(Direct Reclaim):当进程本身分配内存时触发同步回收,该进程被阻塞等待kswapd工作,极其影响性能。频繁的直接回收是内存压力过大的信号。

5.3 反向映射(Reverse Map)

回收页面时需要断开所有PTE映射:

  • 匿名页:通过anon_vma链表遍历所有映射该页的VMA
  • 文件页:通过address_space的radix tree查找所有映射PTE

反向映射使回收代价随映射数量线性增长,影响大型共享进程(如数据库fork出的子进程)的回收效率。

5.4 页面回收调优参数

# swappiness:0=优先回收文件缓存,100=匿名页和文件缓存均衡回收,200=积极回收匿名页
# 数据库场景推荐0-10,文件服务器推荐100+
sysctl -w vm.swappiness=10

# min_free_kbytes:保留给核心内核的最小空闲内存(用于紧急分配)
# 计算公式:sqrt(lowmem_kbytes * 16),或手动指定
sysctl -w vm.min_free_kbytes=262144

# dirty_ratio/dirty_background_ratio:脏页占全局内存的比例触发刷盘
sysctl -w vm.dirty_ratio=40
sysctl -w vm.dirty_background_ratio=10

六、大页(Huge Pages / THP)与NUMA优化

6.1 透明大页(THP)

THP使应用无需修改代码即可使用大页(x86_64下2MB),通过khugepaged守护进程合并普通页面:

# 查看THP状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 可选值:always、madvise、never

# 大页统计
grep -i huge /proc/meminfo
# HugePages_Total:     512
# HugePages_Free:      234
# Hugepagesize:       2048 kB

madvise模式(推荐):应用通过madvise(addr, len, MADV_HUGEPAGE)显式提示合并。

6.2 显式大页(HugeTLB)

显式大页需预留,开机时配置:

# 内核启动参数
hugepagesz=2M hugepages=256 hugepagesz=1G hugepages=4

# 或运行时(仅连续页面足够时成功)
echo 256 > /proc/sys/vm/nr_hugepages

# 查看状态
cat /proc/meminfo | grep -i huge

通过hugetlbfs挂载使用:

mount -t hugetlbfs hugetlbfs /mnt/huge

6.3 NUMA内存策略

Linux提供多种NUMA内存分配策略:

策略 说明
MPOL_DEFAULT 默认策略,优先从本地Node分配
MPOL_BIND 必须从指定Node集合分配,失败则触发OOM
MPOL_PREFERRED 优先从指定Node分配,失败可fallback到其他Node
MPOL_INTERLEAVED Node间交错分配,减少热偏移
MPOL_LOCAL 本地优先(同DEFAULT,fallback更弱)

查看进程NUMA策略:

# numastat displays memory allocation per node for each process
numastat -p <pid>

# 查看上次OOM发生在哪个Node
cat /proc/<pid>/numa_maps | grep -i oom

6.4 numactl工具

# 强制进程只在Node0上运行并分配内存
numactl --cpunodebind=0 --membind=0 /path/to/app

# 交错策略绑定多个Node
numactl --interleave=all /path/to/app

# 查看NUMA拓扑
numactl --hardware

七、OOM Killer与内存cgroup

7.1 OOM Killer评分机制

当系统内存严重不足且所有回收手段用尽时,OOM Killer被触发:

// 粗略公式(实际更复杂,含adj调整)
unsigned long oom_badness(struct task_struct *p)
{
    points = total_vm;  // 进程使用的总页数
    
    // 特权进程(init、内核线程、CAP_SYS_ADMIN)大幅降分
    if (has_capability_noaudit(p, CAP_SYS_ADMIN))
        points -= 32;
    
    // oom_score_adj调整(-1000到1000)
    points = points * (oom_score_adj + 1000) / 1000;
    
    return points;
}

保护关键进程:

# 防止关键进程被OOM杀掉(设为-1000,永不OOM)
echo -1000 > /proc/<pid>/oom_score_adj

# 降低某进程被OOM优先杀掉的概率
echo -500 > /proc/<pid>/oom_score_adj

7.2 内存cgroup v2

cgroup v2提供层次化精细内存控制:

# 查看cgroup树
cat /sys/fs/cgroup/cgroup.controllers

# memory.max:硬限制
echo "1G" > /sys/fs/cgroup/mycgroup/memory.max

# memory.high:软限制(超过触发回收)
echo "900M" > /sysfs/cgroup/mycgroup/memory.high

# memory.low:保障低水位保护
echo "500M" > /sys/fs/cgroup/mycgroup/memory.low

# memory.weight:相对权重(1-10000),竞争时按比例分配
echo 500 > /sys/fs/cgroup/mycgroup/memory.weight

# 当前使用量
cat /sys/fs/cgroup/mycgroup/memory.current

7.3 容器中的内存限制陷阱

  • 容器限制4G,JVM -Xmx设3.5G,但可能因堆外内存(线程栈、直接IO、native库)触发容器OOM
  • /proc/meminfo在容器中会显示宿主机总内存(除非使用lxcfs或cgroup v2视图)
  • 应用监控看到的"总内存"远高于cgroup限制 → 应读取memory.current为准

八、实战调优与排障案例

8.1 场景一:数据库mysqld内存暴涨

现象:mysqld进程RSS持续增长,宿主机频繁OOM。

排查链路:

# 1. 确认OOM是谁触发的
dmesg -T | grep -i 'oom\|kill'

# 2. 进程实际内存分布
cat /proc/$(pidof mysqld)/smaps | grep -E '^[0-9a-f]|Rss|Pss' | head -50

# 3. 关注几个大头
#   - [heap]: C++分配(插件、存储引擎)
#   - [anon]: 客户端线程栈(thread_stack默认256K*Max_connections)
#   - /usr/sbin/mysqld: 可执行文件+共享库

# 4. 查看slab占用(可能为mysql的kmalloc导致)
slabtop -sc | head -20

# 5. NUMA均衡分析
numastat -p $(pidof mysqld)

修复方案:

# my.cnf
[mysqld]
innodb_buffer_pool_size = 6G      # 设为物理内存70%
innodb_buffer_pool_instances = 8  # 减少锁争用
max_connections = 200             # 限制连接数
thread_cache_size = 50            # 复用线程
performance_schema = OFF          # 节省内存(开发环境可开)

8.2 场景二:Java应用频繁Full GC,系统load高

排查链路:

# 1. GC日志分析
java -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags -jar app.jar
tail -f /var/log/gc.log | grep 'Full GC'

# 2. 堆外内存检查(NMT)
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

# 3. 查看mmap文件数(JVM可能大量mmap)
ls /proc/<pid>/map_files/ | wc -l

# 4. 检查是否触发直接回收
cat /proc/vmstat | grep -E 'compact_|oom|kswapd|compact_stall'

8.3 场景三:嵌入式系统内存仅512MB

配置建议:

# 关闭THP,避免内存浪费和合并延迟
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 减少内核缓冲区
sysctl -w vm.min_free_kbytes=16384
sysctl -w vm.dirty_ratio=20
sysctl -w vm.dirty_background_ratio=5

# 禁用不必要的服务(减少slab)
systemctl disable systemd-journald  # 或使用volatile journal

# 限制最大PID数,减少线程开销
kernel.sysctl="kernel.pid_max=2048"

九、内存管理排障三板斧

9.1 宏观监控:vmstat和sar

# 每秒输出,关注si/so(swap in/out,非0表示内存紧张)
vmstat 1 10

#              procs -----------memory---------- ---swap-- -----io----
#  r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs
#  2  0      0 123456 65432 789012    0    0    45    12  123  456
#                                                              ^^^^
#                                           si=swapin, so=swapout

# 历史数据
sar -r 1 5    # 内存使用
sar -B 1 5    # 页面统计
sar -W 1 5    # Swap统计

9.2 进程级分析:smaps和pmap

# 进程内存详细分布
cat /proc/<pid>/smaps | awk '/^[0-9a-f]/ {name=$6} /^Rss/ {rss+=$2} /^Pss/ {pss+=$2} END {print "Total RSS:", rss, "kB; Total PSS:", pss, "kB"}'
pmap -x <pid> | sort -k2 -nr | head -20

# 关键指标
# Rss: Resident Set Size,实际驻留内存
# Pss: Proportional Set Size,按比例分摊共享库

9.3 内核级追踪:perf和bpftrace

# 追踪页面分配延迟
perf stat -e 'kmem:kmalloc','kmem:kfree','kmem:mm_page_alloc' -p <pid> sleep 10

# 追踪缺页异常
perf record -e faults -p <pid> sleep 10 && perf report

# bpftrace监控慢分配
bpftrace -e 'kprobe:__alloc_pages_nodemask { @start[tid] = nsecs; }
    kretprobe:__alloc_pages_nodemask /@start[tid]/ {
        $lat = (nsecs - @start[tid]) / 1000;
        if ($lat > 1000) { printf("slow alloc %d us order=%d\n", $lat, args->order); }
        delete(@start[tid]);
    }'

# 监控直接回收(性能杀手)
bpftrace -e 'kprobe:direct_reclaim_begin { @reclaim_start = nsecs; }
    kretprobe:direct_reclaim_end {
        $lat = (nsecs - @reclaim_start) / 1000;
        printf("direct reclaim %d us\n", $lat);
    }'

十、未来演进与展望

Linux内存管理仍在快速迭代:内存 Folio(2021+)是大页管理的新抽象,统一了compound page和普通page的接口;Damon(Data Access MONitor)提供硬件级内存访问热力图,实现精准的主动回收和NUMA迁移;MTE(Memory Tagging Extension)在ARMv8.5+上硬件辅助检测Use-After-Free和Buffer Overflow,提升内存安全。

在RISC-V架构上,Sv39/Sv48/Sv57支持多级页表与1GB大页,Linux社区积极适配以保证同等调优能力。


参考资料:

  • Linux内核源码 mm/ 目录(mm/page_alloc.c, mm/slub.c, mm/vmscan.c)
  • Mel Gorman, *Understanding the Linux Virtual Memory Manager*
  • 《Professional Linux Kernel Architecture》, Mauerer
  • Brendan Gregg, *Systems Performance: Enterprise and the Cloud*
点赞(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; }