
1. 从一次内存申请说起分配器到底在解决什么问题我在排查一个嵌入式设备的问题时遇到过这么个现象设备运行了大概三天之后业务进程突然申请一块很小的内存都失败但通过free查看系统明明还有几十兆的空闲内存。后来定位到是内核内存碎片化严重连续物理页无法满足分配需求导致的。那次排查之后我对 Linux 内存分配器的理解才算真正从知道名字到了能分析问题的层面。如果你也想真正搞懂 Linux 内存管理内存分配器是绕不开的核心环节。它本质上是内核和硬件之间的内存资源调度中心——用户态进程申请内存、内核模块分配缓冲区、文件系统缓存刷写最终都会汇聚到分配器这一层。而 Linux 内核里的分配器并不是一个单一的实现而是一条完整的链路从伙伴系统Buddy System到 SLUB 分配器再到各种特定用途的缓存池每一层都有各自的职责和取舍。这篇文章我会从整体架构、核心数据结构、算法原理、实操排查几个维度展开。适合这三类读者一是正在学习 Linux 内核的开发者二是在做嵌入式或服务端性能调优的工程师三是准备内核方向面试的求职者。读完你至少能回答这几个问题一次kmalloc调用在内核里经历了什么为什么free显示内存充足但分配仍然失败伙伴系统和 SLUB 是什么关系2. 内存分配器的整体分层与核心设计思路2.1 从硬件到用户态的完整链路理解内存分配器先得建立一层分层映射的视角。硬件层面CPU 通过 MMU内存管理单元访问物理内存内核把物理内存按页通常 4KB管理再往上内核用伙伴系统和 SLUB 分配器管理这些页和更小的对象最上层用户态进程通过malloc或mmap申请内存这背后是 glibc 的分配器最终通过系统调用向内核申请。这四层之间是什么关系我习惯用一个类比来理解相当于一个大型仓库的四级管理硬件芯片是仓库建筑本身物理内存页就是仓库里的货架伙伴系统相当于仓库调度员负责按大块区域划拨空间SLUB 分配器相当于货架上的标准周转箱用来存放零散小件glibc 的分配器则是门店的收银台负责把商品按客户需求包装交付。用户看到的是收银台这一层但真正的库存调度在上层完成。这种分层设计不是随意定的。如果所有内存申请都直接按页操控那么一个 64 字节的结构体也要占用一整页浪费率超过 98%。如果所有申请都从同一套机制里分配又会有锁竞争和碎片问题。所以 Linux 用分层 缓存的思路把不同尺寸、不同频率的分配请求分流到不同机制中。2.2 为什么需要两个分配器伙伴系统与 SLUB 的分工我经常被问到的一个问题是既然有了伙伴系统为什么还要 SLUB答案是它们的服务对象完全不同。伙伴系统管理的是页这个基本单位最小分配粒度是一个物理页4KB而且只支持 2 的幂次方大小的页块。这对内核里大量小于一页的分配请求比如一个task_struct结构体、一个dentry对象、一个 socket 缓冲区来说太粗糙了——如果每次都分配整页浪费极其严重。SLUB 分配器的出现就是为了解决小对象的高效管理。它的思路是向伙伴系统申请若干整页然后在页内切成等大小的对象池维护一个空闲链表分配时从链表头部取一个对象释放时再放回去。这样做有两个好处一是消除内部碎片二是通过每 CPU 缓存减少锁竞争。两者的关系可以概括为一句话伙伴系统管大盘子SLUB 管零钱。一个管理物理页的分配与回收一个在已分配的页内做细粒度的对象复用。上层kmalloc、kmem_cache_alloc等接口就是 SLUB 的对外服务窗口。2.3 关键权衡速度、碎片和可扩展性分配器的设计有三大目标分配快、碎片少、并发好。但这三个目标之间存在天然矛盾。速度快意味着要尽量减少锁操作、减少遍历次数于是引入 per-CPU 缓存但 per-CPU 缓存会导致内存在不同 CPU 之间私有化加剧整体碎片。碎片少意味着需要定期做内存规整compaction或回收reclaim但这又需要额外的 CPU 开销。并发好意味着把锁粒度做细但锁越细维护成本越高。Linux 的解决方案是分级妥协伙伴系统用 10 个页阶order 0~10管理不同大小的连续块用迁移类型MIGRATE_MOVABLE 等把可移动和不可移动的页分开降低碎片影响SLUB 则通过 per-CPU partial 链表减少锁竞争。理解了这些权衡再去看内核源码里的宏和参数会清楚很多——比如/proc/sys/vm/zone_reclaim_mode、/sys/kernel/slab/*/remote_node_defrag_ratio这些参数本质上都是在调节快、碎、并发的平衡点。3. 核心数据结构源码级解析3.1 三大基石pglist_data、zone、pageLinux 内核里内存管理的核心数据结构我推荐从三个结构体入手struct pglist_data、struct zone和struct page。搞懂这三个再去读分配器的调用路径就有了地图。struct pglist_data是每个 NUMA 节点一个代表一块物理内存区域。它包含节点 ID、节点内各个 zone 的数组、以及一些统计字段。在 UMA 架构下通常只有一个节点但这个数据结构仍然存在只是很多字段被跳过。struct zone代表一段连续的物理内存区间分为 ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_MOVABLE、ZONE_DEVICE 等类型。每个 zone 里保存了页分配器所需的全部状态free_area数组伙伴系统的核心、vm_stat各类统计计数、水位线watermark、等待队列wait_table等。struct page是物理内存页的元数据结构每个物理页对应一个。它用 union 把不同用途的字段复叠在一起作为页缓存时用lru链表节点作为匿名页时用mapping指向地址空间作为 slab 对象时用slab_cache指向所属缓存。这个 union 的设计是内存管理里非常精妙但也非常难读的部分读的时候要时刻记住这个页当前处于什么状态。3.2 伙伴系统的组织单元free_area 与 free_list伙伴系统最核心的数据结构是每个 zone 中的free_area数组。这个数组有 MAX_ORDER 1 个元素x86 上 MAX_ORDER 默认是 11即 0~10 阶每个元素对应一个链表链上挂的是该阶的空闲页块。比如free_area[0]的链表上挂的是单个页框order 0free_area[3]上挂的是 8 个连续页框组成的页块order 3。分配时从目标阶开始找如果对应链表为空就向上找更高阶的块然后不断二分切割释放时则检查相邻块是否同样空闲若空闲则合并成一个更高阶的块。这就是伙伴二字的来源——只有互为伙伴的两个块才能合并。这里有一个容易混淆的点free_area 数组的每个链表在较新的内核里还按迁移类型MIGRATE_UNMOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_MOVABLE、MIGRATE_CMA 等拆分成多个链表。原因前面提过就是为了让不可移动的页尽量聚集在独立区域避免它们挡住可移动页的迁移和内存规整。3.3 SLUB 的核心kmem_cache、slab、freelistSLUB 分配器的入口是struct kmem_cache它描述了一类对象缓存对象大小、对齐方式、构造函数、所属 NUMA 节点等。系统启动时会创建一组通用 cachekmalloc-8、kmalloc-16、kmalloc-32 一直到 kmalloc-8k 等覆盖常见的尺寸范围内核模块也可以用kmem_cache_create创建专用缓存比如task_struct_cachep、inode_cachep。每个kmem_cache内部维护了多个slab在较新内核里叫slab_page每个 slab 是一块连续页框。slab 里的对象通过 frelist 链表串起来分配时取头部对象释放时放回。为了减少跨 CPU 访问SLUB 为每个 CPU 维护了一个kmem_cache_cpu结构里面缓存了当前正在使用的 slab 和空闲对象指针分配时优先从这里取只有当前 slab 用尽时才去节点共享链表kmem_cache_node里找新 slab。SLUB 还有一个细节是 per-CPU partial 链表。每个 CPU 释放对象时如果当前 slab 里的对象全部空闲了不立即还给伙伴系统而是挂到本 CPU 的 partial 链表上等这个链表积累到一定程度再批量归还。这样既避免频繁和伙伴系统交互又减少锁竞争。这也是为什么 SLUB 在并发场景下性能表现比其他分配器更好的关键原因之一。4. 伙伴系统核心机制分配、释放与防碎片策略4.1 分配路径从 get_page_from_freelist 看起伙伴系统的分配入口是alloc_pages它最终会走到get_page_from_freelist。这个函数做的事情可以概括为按节点和 zone 的优先级顺序检查水位线尝试从对应 zone 的 free_area 里取页块如果失败则触发内存回收或者尝试从其他节点借用。水位线的检查是理解分配行为的关键。每个 zone 有三个水位线WMARK_MIN、WMARK_LOW、WMARK_HIGH。当 zone 的空闲页低于WMARK_LOW时内核会启动 kswapd 后台回收低于WMARK_MIN时分配路径会直接同步回收内存。水位线的值不是固定的它取决于min_free_kbytes参数以及 zone 的大小。真正取页块的动作在rmqueue里它会从对应阶的 free_list 上摘下一个块如果这个块比请求的阶大就要循环做拆分操作把大块一分为二一半返回给上层另一半挂到低一阶的 free_list 上。这个过程叫作buddy 切分反向操作则是buddy 合并。4.2 释放路径伙伴合并的判断条件释放路径相对简单free_pages最终会走到__free_one_page。关键逻辑是把当前页块与它的伙伴buddy合并。判断伙伴的方法是基于物理地址的异或运算——一个 order n 的页块的伙伴地址是把第 n 位取反得到的。合并可以逐级进行如果合并后得到的新块也有空闲伙伴就继续向上合并直到没有可合并的伙伴或到达 MAX_ORDER。这个过程能有效减少外部碎片但要注意的是合并需要持有 zone 的锁所以释放路径在高并发下可能成为瓶颈。实际优化中很多场景会用release_pages批量释放减少锁的持有次数。4.3 防碎片三件套迁移类型、pageblock 与内存规整前面反复提到碎片问题这里展开说明伙伴系统应对碎片的三层手段。第一层是迁移类型migratetype。每次分配页块时调用方要声明这个页是移动的MIGRATE_MOVABLE、不可移动的MIGRATE_UNMOVABLE还是可回收的MIGRATE_RECLAIMABLE。伙伴系统尽量把同一迁移类型的页块放在一起这样可移动页可以随时搬迁腾出连续空间给不可移动的分配。第二层是 pageblock 机制。系统把物理内存划分成固定大小的 pageblock默认与 hugepage 大小相关通常 2MB 或 4MB每个 pageblock 有一个迁移类型属性。当某个 pageblock 中不可移动的页数量超过阈值时它会升级为不可移动区域反之亦然。这个机制是在分配时通过fallback逻辑实现的——如果目标迁移类型没有空闲块会按预设顺序从其他迁移类型借用借多了就可能改变 pageblock 的属性。第三层是内存规整compaction。当分配高阶页块失败时内核可以执行compact_zone把可移动页从某个区域搬走让分散的空闲页合并成大块。这就是echo 1 /proc/sys/vm/compact_memory这条命令背后做的事情。在 THP透明大页分配路径上内存规整尤为常见因为 THP 需要连续的 2MB 页。5. SLUB 分配器深入解析5.1 为什么最终选择了 SLUBslab 家族的演进SLUB 是 slab 分配器家族中的第三代实现前两代分别是传统的 slab 和 slob。三者用CONFIG_SLAB/CONFIG_SLUB/CONFIG_SLOB编译选项切换。传统 slab 设计很早它为每个对象维护了复杂的状态机partial、full、empty管理开销大在多核系统上锁竞争明显。slob 是为嵌入式微型系统设计的极简分配器用首次适配算法在页内分配代码量小但性能一般。SLUB 的出发点是简化它把 slab 对象的状态压缩到最少去掉复杂队列保留核心的 freelist 机制同时引入 per-CPU 缓存使得多核扩展性大幅提升。我实际感受最明显的是在同样压力下slab 的/proc/slabinfo输出能看到大量 full 队列和复杂状态而 SLUB 的实现更线性、更容易排查。现在主流发行版默认都是 SLUB所以新项目可以无脑选 SLUB除非你有特殊的小内存场景才会考虑 slob。5.2 SLUB 分配路径与释放路径逐步拆解SLUB 的分配入口是slab_alloc关键步骤可以拆成四步第一步从当前 CPU 的kmem_cache_cpu里取 freelist 头指针如果非空直接返回对象并更新头指针。这个路径没有任何锁是 SLUB 最快的路径。第二步如果当前 slab 的 freelist 为空则调用___slab_alloc处理无对象可用的情况。它会先检查当前 slab 是否还有残留残留意味着有其他 CPU 正在使用如果有则尝试从节点 partial 链表抢占一个新 slab如果节点 partial 也没有则走慢路径。第三步慢路径调用new_slab从伙伴系统申请新的页块通常是 order 0即一页初始化 slab 结构并重新建立 freelist。如果申请失败则会触发内存回收。第四步分配成功后更新统计信息返回对象地址。释放路径刚好是反向的把对象放回当前 CPU 的 freelist 头部更新计数。如果当前 slab 的 inuse 计数变为 0即全部对象都空闲了SLUB 会把它挂到 per-CPU partial 链表如果 partial 链表长度超过cpu_partial阈值则批量把部分 slab 归还给节点 partial或者直接释放给伙伴系统。这里的核心设计思想是尽量不打扰伙伴系统。对象释放时更愿意留在当前 CPU 的缓存里因为重新分配的命中率更高。5.3 SLUB 调优与排查从 /proc/slabinfo 到 perf实际工作中排查 SLUB 相关问题时我会先看三个地方。第一个是/proc/slabinfo它列出所有 kmem_cache 的对象数量、活跃对象数、每个 slab 的页数等。如果某个 cache 的 active_objs 远大于总内存预期说明可能有内存泄漏。第二个是/sys/kernel/slab/cache-name/目录里面有很多可调参数比如cpu_partial、min_partial、shrink。cpu_partial控制每个 CPU partial 链表的最大 slab 数调大可以减少对伙伴系统的访问但会增加内存占用调小则反过来。在延迟敏感的场景下适当调大cpu_partial能显著降低分配延迟。第三个是perf的分配路径采样。如果怀疑某段代码频繁触发慢路径分配可以用perf record抓__slab_alloc/new_slab的调用栈定位到具体的申请者。之前我定位一个网络模块的丢包问题就是用这个方法发现某个驱动在数据路径上反复调用kmalloc导致每次分配都要走慢路径加了对象池之后性能立刻上来。6. 实操中的内存问题排查经验6.1 经典问题一内存充足为什么分配失败回到开头提到的那个场景。设备free显示还有几十兆内存但进程申请小块内存失败。排查步骤我总结为四步第一步看cat /proc/buddyinfo。这个文件输出每个 zone 各个 order 的空闲页块数量。如果 order 0 的块很多但 order 3 以上几乎没有说明外部碎片严重——小块内存有剩余大块连续内存稀缺。对于这种情况可以用echo 1 /proc/sys/vm/compact_memory触发内存规整临时缓解。第二步看cat /proc/pagetypeinfo确认碎片是按迁移类型分布的还是集中在某一种类型里。通常你会发现不可移动的页把可移动区域切碎了。第三步看cat /proc/zoneinfo的 watermark 和 nr_free_pages。如果 nr_free_pages 低于 WMARK_MIN即使 free 显示有内存分配也会被拒因为内核预留了一部分紧急内存给 atomic 分配请求。第四步确认是否触发了 cgroup 内存限制。容器场景下host 内存充足但 cgroup 的 memory.limit_in_bytes 达到上限进程同样会分配失败。这个排查顺序基本覆盖了 90% 的内存看似充足却分配失败问题。6.2 经典问题二slab 内存异常增长另一个常见问题是 slab 内存异常增长表现为free里 cached 和 slab 占据大量内存业务内存被挤压。首先我建议用slabtop看看哪个 cache 占用最高。如果是 dentry 或 inode 缓存可以调整/proc/sys/vm/vfs_cache_pressure调大可以让内核更积极地回收目录项缓存。如果是某些驱动创建的专用缓存一直在增长那就很可能是泄漏——注意每个 cache 的 active_objs 变化如果只增不减基本可以确认。还需要注意 SLUB 的 per-CPU partial 会把释放的 slab 留在各 CPU 上造成内存明明释放了却不归还的假象。这时候可以用echo 1 /sys/kernel/slab/cache-name/shrink强制回收空闲 slab观察free的 slab 列是否下降辅助判断是否只是缓存行为而非泄漏。6.3 内核内存问题的常用工具与排查命令工具方面我日常用到的有这些工具/文件作用典型用法/proc/buddyinfo查看各阶空闲页块分布判断碎片化程度/proc/pagetypeinfo按迁移类型查看页分布定位不可移动页聚集情况/proc/zoneinfo查看 zone 水位线与统计数据判断是否触发 reclaim/proc/slabinfo查看所有 slab cache 状态定位 slab 内存占用slabtop交互式查看 slab 占用快速找出高占用 cachefree -m整体内存视图初步判断缓存占比perf record采样分配路径定位慢路径分配的调用栈echo 1 /proc/sys/vm/compact_memory手动触发内存规整缓解碎片问题echo f /proc/sysrq-trigger触发 OOM 杀进程应急时释放内存此外开启内核的CONFIG_DEBUG_KMEMLEAK可以用来检测内核内存泄漏对长时间运行的内核模块特别有效。缺点是性能开销大通常只在测试环境开启。6.4 调优参数的实践心得给新手一个实用的调优起步策略不要凭感觉改/proc/sys/vm/里的参数先加监控收集一个完整业务周期的数据再做调整。我常用的调整优先级是第一优先vm.min_free_kbytes。调大可以提高紧急内存预留降低原子分配失败的几率但也会减少可用内存。一般设置为物理内存的 1%~2% 比较稳妥。第二优先vm.vfs_cache_pressure。默认 100调大到 200 会让内核更积极回收 inode/dentry适合缓存占用过高的服务端场景调小到 50 则适合需要高缓存命中的文件服务器。第三优先vm.zone_reclaim_mode。在 NUMA 架构下默认 0 表示允许跨节点分配能提高内存利用率但可能增加访问延迟。对延迟敏感的应用可以设 1 开启本地回收但要注意可能导致分配失败率上升。第四优先vm.compact_memory和vm.compact_unevictable_allowed这类规整相关参数建议在发生实际碎片问题时再介入不要长期开启否则 CPU 开销明显。顺带一提/sys/kernel/slab/cache/cpu_partial这类 SLUB 参数如果不是专门做内核裁剪或嵌入式优化不建议动。默认值在内核社区已经经过大量迭代测试大多数场景下是最优的。7. 从分配到回收一次完整的内存生命周期7.1 用户态 malloc 到内核分配的完整路径把前面所有内容串起来完整走一遍用户态malloc调用链第一步glibc 的 malloc 根据申请大小判断走 fastbin、smallbin、unsorted bin 还是 mmap。小于 128KB 的走堆分配大于MMAP_THRESHOLD的走 mmap。第二步走堆分配时如果 brk 堆空间不足glibc 调用sbrk或mmap向内核申请新的堆段。这里涉及的是内核的页分配do_brk_flags或mmap_region会创建 VMA并通过alloc_pages拿到物理页。第三步alloc_pages进入伙伴系统。从当前进程的 cpuset 和 mempolicy 确定 NUMA 节点从节点到 zone检查水位线从对应迁移类型的 free_area 取页块必要时拆分。第四步对于小块内核对象比如内核模块里的kmalloc(32)会直接走 SLUB从 kmalloc-32 这个 cache 的 per-CPU freelist 取一个对象。第五步页表建立映射。用户态拿到的是虚拟地址内核通过set_pte把物理页映射到进程地址空间。这里要注意malloc返回后内存并没有真正分配物理页而是缺页异常时按需分配这就是 demand paging。7.2 释放路径与内存回收的触发条件释放路径同样有层次用户进程调用freeglibc 把内存块归还堆或调用munmap内核回收物理页时需要通过 TLB flush 确保映射失效。如果系统内存压力增大内核会触发回收机制路径是内存分配失败 → 唤醒 kswapd → 扫描 LRU 链表 → 回收文件页或匿名页。文件页直接丢弃脏页先回写匿名页可能换出到 swap。这里有个面试常问的知识点kswapd和direct reclaim的区别。前者是后台异步回收在水位低于 WMARK_LOW 时唤醒后者是分配路径上的同步回收在水位低于 WMARK_MIN 时触发。理解了这个区别就理解了为什么有时候进程会突然卡顿——它正在做同步回收。7.3 内存规整与 OOM 的触发流程当水位极低且回收无效时内核进入 OOM 阶段。OOM Killer 的选择逻辑基于 oom_score综合考虑进程占用的内存量、运行时长、nice 值等因素。注意 OOM 并不总是意味着物理内存不足也可能是地址空间限制RLIMIT_AS、cgroup 内存限制或碎片导致连续分配失败。在实际调优中我通常会区分三类内存不足场景物理不足、碎片化不足、限制条件不足。三者的处理方式完全不同——物理不足靠扩容或降内存占用碎片化靠规整或调整迁移类型限制不足靠修改 cgroup 参数或 ulimit。用错方案问题会反复出现。8. 面试高频考点与学习路径建议8.1 理解内存管理子系统的关键数据结构清单面试中如果提到Linux 内存管理子系统中有哪些重要的数据结构我建议按模块回答不要零散罗列。物理内存描述pglist_data、zone、page、free_area、pageblock。地址空间管理mm_struct、vm_area_struct、pgd/p4d/pud/pmd/pte页表层级。分配器相关kmem_cache、slab、kmem_cache_cpu、kmem_cache_node以及伙伴系统的 per-zone 数据。回收与规整lruvec、scan_control、compact_control、zone_stat_item枚举。建议每类选两三个结构体用自己的话讲清楚它是什么、放在哪、解决什么问题、和谁有关系。能画出来各结构体的引用关系基本上数据结构这一关就过了。8.2 从源码角度理解的推荐路径如果你准备深入源码我的建议是按照由外到内、由整体到细节的顺序来读。第一步读mm/page_alloc.c关注__alloc_pages_nodemask、get_page_from_freelist、rmqueue、__free_one_page这几个核心函数。这是伙伴系统的骨架。第二步读mm/slub.c关注slab_alloc、___slab_alloc、new_slab、__slab_free、put_cpu_partial。第三步读mm/vmscan.c关注kswapd、shrink_node、shrink_lruvec、shrink_page_list。第四步读mm/compaction.c关注compact_zone、isolate_migratepages、migrate_pages。第五步读mm/oom_kill.c关注out_of_memory、select_bad_process、oom_badness。每一步都可以配合实际场景去验证比如用一个分配压力测试工具观察/proc/buddyinfo和/proc/vmstat的变化对照源码去理解为什么这个计数会涨。8.3 一页纸总结分配器的核心要点速查主题核心要点伙伴系统按 2 的幂次管理页块free_area 数组组织分配时切分、释放时合并迁移类型按页移动属性分类减少碎片支持内存规整水位线MIN/LOW/HIGH 三级控制后台回收与同步回收的触发SLUB对象池化管理per-CPU freelist慢路径才触碰伙伴系统per-CPU partial释放时先缓存到本地减少锁竞争和伙伴系统交互内存回收kswapd 异步回收与 direct reclaim 同步回收相结合内存规整迁移可移动页合并大块解决高阶分配失败OOM基于 oom_score 选进程区分三种内存不足场景关键参数min_free_kbytes、vfs_cache_pressure、zone_reclaim_mode、compact_memory9. 写在最后调内存别只看 free说实话内存分配器这块内容量非常大一篇文章很难覆盖全部细节但如果能真正理解伙伴系统和 SLUB 的分工、数据结构和分配释放路径日常排查和性能调优会顺手很多。我个人在实际排查中最大的体会是不要一上来就怀疑内存泄漏先看碎片再看水位线最后才排查泄漏。很多内存不足的假象其实只是物理页分布不均或者缓存未回收导致的。用buddyinfo和zoneinfo定位问题比盲目调参数有效得多。最后再分享一个技巧如果你用的是较新的内核5.x 以上可以关注/sys/kernel/debug/page_owner开启CONFIG_PAGE_OWNER之后它能告诉你每一页是谁分配的、什么时候分配的。这个信息在排查内存泄漏和碎片问题时价值远超各种猜测。不过开启后有额外内存开销最好在测试环境验证清楚再上生产。