Linux内核的内存管理子系统是整个操作系统的核心基础设施之一。它不仅负责页面的分配与回收,更在NUMA拓扑感知、内存压缩、OOM(Out of Memory)杀手决策等层面展现了精妙的工程设计。本文将从内核源码层面深入剖析内存管理的核心机制,帮助开发者和系统工程师理解、调优并排查内存相关问题。
一、从伙伴系统说起:物理内存的顶层分配器
Linux内核的物理内存管理以Buddy System(伙伴系统)为基础。伙伴系统将所有物理页面组织为11个链表(从order 0到order 10),分别管理2^0到2^10个连续页面(即1页到1024页,对应4KB到4MB)。当内核需要分配内存时,伙伴系统会先找到能满足需求的最小order;如果该order链表为空,则从更大order的链表中取出一块,一分为二,其中一块满足请求,另一块放回对应链表。释放时则尝试合并伙伴(buddy)以维持大块连续内存。
伙伴系统的核心优势在于反碎片——通过将不同order的页面分类管理,有效减少了外部碎片。但仅靠伙伴系统是不够的:对于小至几十字节、大至几KB的中小型对象分配,若直接使用伙伴系统会造成严重的内部碎片。因此,内核在伙伴系统之上构建了 slab/slub/slob 分配器作为中间层。
二、SLUB分配器:默认的小对象分配器
自2.6.23版本起,SLUB(Unqueued Slab Allocator)取代SLAB成为Linux内核默认的小对象分配器。SLUB的设计哲学是极简主义——通过去掉SLAB中的部分队列和复杂色彩(color)机制,大幅降低了元数据开销,同时保持了高性能。
SLUB的核心数据结构为struct kmem_cache,它代表一类特定大小的对象缓存。每个kmem_cache由一个或多个Node(在NUMA系统中每个节点一个)管理,每个Node又包含多个Slab。Slab是由一或多个连续物理页面组成的内存块,被等分为固定大小的对象(object)。当一个Slab中所有对象都被分配时,称为full;部分分配为partial;全部空闲为empty。
SLUB的一大特色是CPU本地缓存(cpu partial slab)。每个CPU维护一个本地partial slab列表,减少多核竞争。当CPU本地缓存耗尽时,才会操作Node级别的partial链表——这种层级设计在高并发场景下显著降低了锁争用。
调试SLUB时,/proc/slabinfo是最直观的入口。你可以通过slabtop命令实时观察各kmem_cache的活跃对象数、每个对象大小、slab数量等信息。若你需要追踪特定kmem_cache的泄漏或碎片,可以在启动参数中加入slab_nomerge关闭slab合并,配合slub_debug=UZP开启对象追踪、红色区域(red zone)和毒化(poisoning)检测。
三、NUMA感知:让内存靠近CPU
NUMA(Non-Uniform Memory Access, 非一致内存访问)架构在服务器领域已成为标配。在NUMA系统中,每个CPU节点访问本地内存的速度远快于远端(node)内存。Linux内核的NUMA感知内存分配策略正是为了最大化本地内存访问比例。
内核的NUMA策略分为以下几类:
1. Local分配策略(默认):优先从请求CPU所在的node分配内存,仅在本地内存不足时回退到其他node。
2. Preferred策略:管理员通过numactl --preferred=node指定优选node,系统尽量从该node分配,但允许回退。
3. Bind策略:严格限定只能从指定node集合中分配,若不足则触发OOM——这对性能要求极为苛刻的应用(如DPDK场景)最为有效。
4. Interleaved策略:在各指定node间轮询分配内存,适合需要全node随机访问的场景,可均衡内存带宽。
NUMA的另一个核心机制是自动NUMA平衡(Automatic NUMA Balancing)。内核通过周期性地扫描进程的访问位(accessed bit),识别出被大量远端访问的页面,将其迁移到访问者所在的本地node。同样,当进程被调度到远端节点时,内核也倾向于将其页面一并迁移。这一切都是自动完成的,/proc/sys/kernel/numa_balancing控制开关。
实战调优技巧:对于内存密集型应用(如Redis、Elasticsearch),推荐使用Bind或Preferred策略将应用绑定到特定node,并确保应用的所有worker线程运行在该node的CPU上,避免跨node访问。同时降低vm.zone_reclaim_mode,避免node内存紧张时激进地触发直接回收(reclaim)而非回退分配。
四、内存回收与压缩:当free内存告急
当系统可用内存低于阈值时,内核会启动页面回收(page reclaim)机制。最轻量的回收方式是页面缓存刷新(page cache writeback)——将脏页写回磁盘以腾出干净页面。若仍不足,内核调用的直接回收(direct reclaim)会阻塞调用者并同步扫描LRU链表。最狠的手段是内存压缩(compaction)——移动页面合并出大块连续内存。
LRU链表是回收策略的核心。Linux维护两个LRU双链表:active和inactive。页面首次被访问时进入inactive尾;被第二次引用时提升到active;随着时间推移,active页面未被引用后逐步降级到inactive头,最终被回收。这种二次机会算法有效区分了冷热页面,避免一次性文件I/O污染缓存。
KSM(Kernel Same-page Merging)是一种面向虚拟化场景的去重机制:内核定期扫描物理页面,发现内容相同的副本后合并为一(标记为COW)。KSM在KVM环境下可显著提升内存利用率——例如10台Ubuntu虚拟机默认可复用大量相同库页,节省大量内存。通过/sys/kernel/mm/ksm/下的参数可以调整KSM的扫描间隔和合并阈值。
五、OOM Killer:最后的防线
当所有回收手段均告失败、系统彻底无内存可用时,内核会触发OOM Killer。OOM Killer的任务是牺牲一个进程以拯救整个系统。
OOM Killer的选择逻辑基于oom_score。每个进程都有一个oom_score,由内核综合考虑进程已占内存、CPU时间、oom_score_adj等因素计算得出。在mm/oom_kill.c的oom_badness()函数中可以看到其评分公式:
points = (total_vm_pages * 1000 / totalram_pages) + 分钟级别的CPU时间加权
分数最高的进程,加上其子进程总内存后,被选为oom-kill的受害者。内核在/proc/<pid>/oom_score中可以查询当前得分。
保护关键进程:你可以通过/proc/<pid>/oom_score_adj手动调整分数修正值(-1000到1000)。将其设为-1000可完全豁免该系统进程。systemd也提供了OOMPolicy=配置项来管理服务进程的OOM策略(如continue、stop、kill)。
cgroup内存控制:更优雅的做法是为关键服务配置memory.min预留内存,或使用memory.high软限制避免触发全局OOM。cgroup v2使得OOM可以只杀死组内进程,而非全局混战。
六、实战排查:诊断内存问题三步法
第一步:快速诊断
执行free -h看总内存,再vmstat 1 5观察si/so(换入换出)是否持续非零。若si持续为正,说明页面回收已超过空闲内存,开始向swap要空间——这是需要立刻介入的信号。
第二步:定位元凶
使用top -o %MEM或更精准的smem -s rss查看进程级内存占用。若某进程RSS持续增长,大概率存在内存泄漏。针对内核态泄漏,slabtop和/proc/meminfo中Slab字段的变化是重要线索。配合perf record -e kmem:kmalloc -ag(开启ftrace)可追踪具体的内存分配路径。
第三步:深入取证
对于用户态泄漏,valgrind --tool=maccheck是金标准,但开销巨大,更适合预发布环境。生产环境推荐memleak(基于eBPF的BCC工具包)——它能以极低开销追踪未经释放的alloc调用栈。针对内核态,开启CONFIG_DEBUG_KMEMLEAK后,/sys/kernel/debug/kmemleak提供类似功能。
结语
Linux内核的内存管理是一个精密的分层系统:伙伴系统管理连续物理页面,SLUB管理小型对象,NUMA策略优化访问路径,LRU和compaction负责回收压缩,OOM Killer作为最后的守护者。理解这一层级,才能在高性能服务调优、大规模数据库运维、容器密度优化等场景中做出正确决策。内存管理不仅是理论的研究,更是性能与稳定性的命脉所在。

发表评论 取消回复