☰
内存页面分配算法全解析:伙伴系统、slab与碎片调优
2026/10/22 13:33:01 网站建设 项目流程

做了多年的操作系统方向开发,内存管理始终是绕不开的硬骨头,尤其是页面分配算法,它几乎是所有内存问题的起点。很多人一上来就啃源码、背数据结构,但真正遇到线上内存告警时还是一头雾水。这篇文章以“内存管理1”为起点,专门讲透页面分配算法这条主线,覆盖空闲页如何组织、连续分配策略的取舍、伙伴系统的运作逻辑,以及slab分配器的接力配合,最后补一段实际调优中排查碎片问题的经验。适合正在学内核的开发者,也适合做性能分析和故障排查的工程师。

1. 页面分配算法解决的并不是“分配内存”这一个动作

1.1 一次内存告警背后的真正问题

有一回某业务的报障说服务内存吃紧,我上去看了下系统状态,物理内存明明还有几十GB空闲,但有一个申请较大连续内存的模块一直失败。当时第一反应是“内存泄漏”,结果查了半天没有任何泄漏迹象。后来才意识到,问题出在物理页的离散化上:内存空闲量充足,但连成大块的空闲物理页不够了。

页面分配算法管理的就是这些物理页——谁占用了哪一页、哪些页连在一起、怎么切分、怎么回收合并。它表面上只是在“分配内存”,实际承担着三类核心任务:

  1. 在紧急分配路径上快速返回可用物理页;
  2. 尽量保持大块连续内存的存在;
  3. 在多核环境下不让全局锁成为性能瓶颈。

这三件事互相打架。追求快,就会倾向于简单的顺序分配;追求减少碎片,就要做更复杂的拆分与合并;追求多核扩展,就得牺牲一部分全局一致性。所有经典的页面分配算法,本质上都是这三个目标的折中方案。

1.2 先理解物理页这个“最小交易单位”

操作系统的虚拟内存以页为单位映射,物理内存的管理单位也一般是固定大小的页框,常见的是4KB。页面分配算法处理的最小单位就是这样一个页框,但实际使用中,驱动、文件系统、用户态大页等经常需要连续的多页。所以算法既要支持单页分配,也要支持一次拿出一段地址连续的页块。

用一个类比来理解会轻松很多:物理内存就像一栋酒店的几百个房间,每个房间都有一个房间号,相邻房间就是连续地址。页面分配算法就是酒店前台,客人办理入住时,要么给你单独一间,要么给你一整排相邻的房间。难点在于,客人们入住的先后顺序不同、住的时间不同、退房时间也不同,前台必须保证随时还能给新来的大团客找到一整排空房间。

如果前台简单地从第一号房开始找,住久了,房间会变成零零碎碎的空位组合,明明空房总数很多,但没有足够的连排房间。这就是外部碎片。页面分配算法的主要工作之一,就是通过特定的分配策略和回收合并机制,延缓甚至逆转这种碎片化过程。

1.3 看待算法的三个评价维度

看一个页面分配算法好不好,不能只看某一次分配快不快。我一般会从三个维度去评价:

  • 分配吞吐率:单位时间内能完成多少次分配请求。页分配请求非常高频,一次malloc、一次mmap、一次page fault都可能触发底层页分配,路径太长用户会直接感受到卡顿。
  • 碎片程度:外部碎片是否严重,具体体现在空闲页是否集中在低阶小块上。
  • 分配延迟的稳定性:最坏情况不能太离谱。如果一次分配遍历了几万个空闲页才找到合适块,最坏延迟会飙升。

后面要写的首次适应、最佳适应、最差适应、伙伴系统,都可以放到这三个维度下比较。你会看到,没有任何一种算法能在三个维度同时满分。

2. 空闲页的组织方式:数据结构决定了算法的天花板

页面分配算法无论策略怎么变,都要先回答一个问题:空闲页以什么形式记录下来。这一步选什么数据结构,基本决定了后续算法的效率和复杂度上限。

2.1 空闲链表:最基础也最容易被低估的形态

最简单的方案是维护一个空闲页链,每个空闲页的页结构里保存next指针,所有空闲页串在一起。分配时从链表头部摘下一个页块;释放时再把页块挂回去。

这个方案有个非常巧妙的点:链表节点不需要额外分配存储空间,因为节点本身放在空闲页的起始位置。这一页既然是空闲的,它的内容就没什么好保留的,完全可以把前几个字节当作next指针。等到它被分配出去,写入数据覆盖这个指针,也完全不影响链表——因为它在链表里已经不存在了。

但这个方案只能应付“单页分配”。如果要找连续的多页,就必须遍历链表,判断相邻页是否相邻。链表是单向的,相邻的空闲页之间没有指针关系,你只能靠地址差去猜测。换句话说,空闲链表适合早期启动阶段的临时内存管理,但不适合作为通用的页面分配主路径。

还有一个隐含问题:链表本身没有“按地址排序”的能力。如果释放时不维护有序性,回收回来的页就很散,大块连续内存归位后不容易被发现。很多自定义内存池遇到高地址碎片时,本质问题就出在这里。

2.2 位图:用压缩状态加速“连续区间”判断

改进的思路是用位图记录每一页的状态:1代表已分配,0代表空闲。位图本身占用的内存极少,4KB物理页对应一个bit,1GB物理内存的位图只有32KB左右。

位图的优势在于,判断某个范围内的页是否全部空闲时,可以一次读入多个字节,配合按位比较指令快速扫描。某些处理器体系结构还专门提供“查找第一个空位/置位”的指令,让空闲页检索更进一步加速。

但位图也有它的问题。其一,维护连续区块时,从某个位置开始向后找n个连续空位,最坏仍然要线性扫描;其二,没有链表的“取头即用”的O(1)分配能力。所以位图通常用于一些比较小而固定的内存区域管理,或者配合其他数据结构使用,而不是全局页分配的唯一答案。

2.3 已分配页的索引:基数树等结构的用武之地

前面说的是“找空闲页”,页面分配还需要“知道谁占用了哪一页”,也就是物理页到使用者的反向映射。比如回收一页内存时,系统要知道该页是否属于某个页缓存,能不能安全释放;或者虚拟地址映射到物理页后,内核要能通过物理页找到对应的page结构。

这类需求常见的选择是基数树(radix tree)等按地址索引的结构。它把物理页号作为键,快速定位到对应的页描述信息。可以把空闲链表比作前台的“空房登记本”,位图是墙上的“房间占用表”,基数树则是按房间号建的索引台账。三者在真实系统里往往同时存在,分管不同的环节。

这一节想强调的核心观点是:数据结构先决定了你“能不能高效找到连续块”,分配策略的好坏只在这个基础之上起作用。很多自研内存池性能不稳,不是策略有问题,而是底层压根没有维护“连续区间索引”,分配时只能靠碰运气。

3. 三种连续分配策略:首次适应、最佳适应与最差适应的真实取舍

在伙伴系统出现之前,动态分区分配时代就已经沉淀出三种典型的连续分配策略。虽然现代内核不直接以它们作为物理页分配的全局策略,但它们的思想大量出现在内存池、用户态分配器、以及某些嵌入式系统的内存管理里。理解它们的优劣,才能理解伙伴系统为什么那么设计。

3.1 首次适应(First Fit):从低地址开始找第一个够大的块

策略非常简单:按地址从低到高遍历空闲块表,遇到第一个大小足够的块,直接分配;如果块比请求大,就切割后把剩余部分保留在原位置。

用伪代码表示大致是这样:

// 空闲块按地址递增排列在链表中 domain = find_first_block(size) { for (block in freelist) { if (block.size >= size) return block; } return NULL; } alloc(size) { block = find_first_block(size); if (block.size > size) { split_block(block, size); // 剩余部分生成新空闲块 } remove_from_freelist(block); return block.addr; }

首次适应最大的优点是分配速度快,因为它不用看完所有空闲块,找到第一个就收手。但它的缺点也明显:低地址区域会被反复切割成小块,形成一堆小碎片;而大块空闲都挤在高地址区域。下次再有大分配请求,还是要从头扫描,越过低地址区,白白消耗时间。这是典型的“越快越慢”场景:因为贪快而产生了碎片,碎片反过来拖慢了以后每一次查找。

缓解办法是让空闲块表按地址维持有序,并在分配过程中尽量回填小碎片。但是回填做得太勤快,又会影响吞吐率。实际项目中,如果对象大小相对固定,首次适应其实很够用。

3.2 最佳适应(Best Fit):找最小够用的块,减少碎片增量

最佳适应的思路正好相反:遍历全部空闲块,挑一个大小最接近请求的块分配。它的理论优势在于,尽量不浪费大的连续区间,剩余空间也是最小的一块,后续大块请求更可能被满足。

代价是每次分配都要扫描整个空闲块表,时间复杂度通常是O(n)。在页分配这种高频路径上,这个开销是不可接受的。我自己在一些中间件内存池里用过类似策略,配合按大小排序的平衡树,查找复杂度能降到O(log n),但每块内存的元信息维护成本也随之增加,小对象场景得不偿失。

还有一个反直觉的点:最佳适应在长期运行下的碎片率,其实并不像理论推导那么美好。因为每一次分配都留下一个很小的残块,这种残块往往完全无法满足下一次请求,相当于“凭空制造死区”。加上撕裂分配后剩余部分重新插入结构中的开销,真实系统里很少直接用纯最佳适应。

3.3 最差适应(Worst Fit):从最大块下手,刻意保留均匀余量

最差适应则是每次都找最大的空闲块来分配,切一刀后剩下的仍然是比较大的块。它的初衷是避免产生小碎片——既然大树被砍掉一部分后还是大树,那剩余部分至少不会变成无法利用的“牙签”。

听上去很美,但问题在于:最大块是稀缺资源。连续分配几次大请求之后,原本可能满足更大连续需求的大型空闲块会被彻底消耗掉。更关键的是,为了找到最大块,依然需要全表扫描或维护大顶堆。实际使用下来,最差适应往往把系统推向一种“大块持续变小、碎片总量稳定但整体连续度下滑”的状态,中小请求看似不愁,真正的大块请求反而不容易满足。

3.4 一张表看清三种策略的差异

策略查找方式分配速度碎片倾向典型适用场景
首次适应顺序找到第一个够大块快低地址碎片堆积嵌入式系统、简单内存池
最佳适应全表扫最小够大块慢产生大量微小残块离线分配、对碎片敏感但不要求速度的场景
最差适应全表找最大块慢大块快速缩减极少单独使用,混合策略中作为补充

这些策略没有任何一个是“标准答案”,它们反映的其实是同一枚硬币的两面:分配越精准,查找越慢;分配越随机,碎片越脏。真正优雅的解法,是把空闲内存按大小预先分类,让“找对应大小的块”变成O(1)查表。这正是伙伴系统要做的事。

4. 伙伴系统:为什么现代内核普遍选择它作为页面分配主路径

4.1 核心思想:按2的幂次切蛋糕

伙伴系统的核心想法很简单:把空闲页按大小分门别类存放,每一类对应2的幂次个连续页。用order来表示级别,order k表示连续页数为2^k块。

假设系统初始有16页空闲内存,即order 4的块。请求分配order 0的1页时,系统从order 4块开始对半拆:

order 4: 16页 拆成两个order 3: 8页 + 8页 其中一个order 3继续拆成两个order 2: 4页 + 4页 其中一个order 2拆成两个order 1: 2页 + 2页 其中一个order 1拆成两个order 0: 1页 + 1页

分配走的时候,只拿走其中1页,剩下的页分别挂在order 0、order 1、order 2、order 3对应的空闲链表里。以后再有open order 2的请求,系统直接查order 2空闲链表,有就直接拿,不用再拆。这就是“分类管理”带来的效率。

4.2 伙伴的判定:地址和长度之间的异或关系

伙伴系统最精妙的地方在于确定“谁和谁是一对”。两块空闲内存互为伙伴,需要满足两个条件:大小相同,并且在地址空间中相邻,拼起来恰好组成一块更大的连续块。

判定伙伴不需要遍历任何表,直接拿地址与块大小做异或即可:已知释放块的起始地址addr,大小size,则伙伴地址 = addr ^ size。异或后地址只在你期望的那一位发生变化,正好落到相邻块上。这个位运算的代价几乎为零,是伙伴系统效率的重要来源。

用16页的内存举例,假设起始地址为0:

  • 块A占页0-15,即order 4,split后伙伴对为(0-7, 8-15)
  • 0-7再split,伙伴对为(0-3, 4-7)
  • 0-3再split,伙伴对为(0-1, 2-3)
  • 0-1再split,伙伴对为(0, 1)

释放页0时,先看页1是否空闲。如果是,合并成order 1块(0-1);再看块(2-3)是否空闲,如果是,合并成order 2块(0-3);依次往上,只要伙伴空闲就继续合并。这个“逐级向上合并”的机制,就是伙伴系统能够抑制外部碎片的根本原因:空闲块不会长期处在零散状态,小块的相邻空闲块总是能很快恢复成更大的块。

4.3 为什么选择2的幂次:位运算优势与内部碎片代价

看到这里你应该能理解,选择2的幂次绝不是一个偶然,而是为了把拆分与合并这种高频操作全部变成移位和异或。如果是1.5倍、3倍这种比例,伙伴判定会复杂得多。

代价当然也有。最常见的吐槽是内部碎片:申请3页时,伙伴系统最小只能分配order 2即4页,白白浪费1页。同理,申请9页会分配16页,浪费7页。对于底层物理页分配来说,这种浪费是可以接受的,因为上层还有slab分配器来消化小对象,不会动不动就用伙伴系统分配几个字节。而且3页、9页这种请求在正常业务中并不高频,内部碎片的实际影响没有想象中大。

伙伴系统还有一个极具工程价值的特性:分配与释放路径的对称性。分配时逐级向下拆半,释放时逐级向上合并,两条路径复用同一套位运算逻辑,代码量小、不容易出错、也便于形式化验证。对内核这种对稳定性要求极高的项目来说,这一点弥足珍贵。

5. slab/slub分配器:页面分配之上的小型对象接力

5.1 频繁创建小对象时,伙伴系统为什么显得笨重

假设内核要频繁创建和销毁文件描述符、进程描述符这类几十字节的小对象。如果每次创建对象都向伙伴系统申请一个4KB页,用完再释放,代价实在太大:分配和释放都需要处理order 0的链表,还伴随页描述结构的初始化。

更关键的是,不同对象类型混在同一个页里,会导致页很难回收和规整。比如一个页里既有文件缓存数据,又有socket对象,想迁移这个页时就很麻烦。

所以业界普遍采用的做法是两级结构:伙伴系统只负责分配物理页块;在页块之上,slab分配器把一页切分成等大小的槽位,专门服务某个对象类型。你可以把伙伴系统理解成“整装仓库批发部”,slab是“零售柜台”,后者从前者批量进货,再零卖给各个内核子系统。

5.2 slab的三种状态与对象生命周期

一个slab cache管理着一组同类对象。每个slab可能是一页或多页连续物理页,内部按照对象大小划分成槽位。空闲槽位之间通过指针串成freelist,而且这个freelist指针放在空闲对象本身的内存里,不占用额外空间,和空闲页链表用页内空间的做法如出一辙。

slab在其生命周期内会经历三种状态:

  • 全空:没有任何已分配对象,可以被回收或复用
  • 部分使用:一些对象被占用,一些空闲
  • 全满:对象全部被占用

分配时,优先从部分使用的slab中取一个空闲对象。这样做有两个好处:一是避免全空的slab被拆散闲置,二是避免全满slab被频繁锁竞争。释放时,如果对象所在slab变为全空,且cache中空闲slab过多,系统会把这些slab归还给伙伴系统。

这里还有一个细节值得留意:freelist指针复用空闲对象内存,意味着同一块内存在不同生命周期担任两个角色——要么是已分配对象的数据区,要么是空闲链表的指针区。这种“结构复用”的思想在底层内存管理里反复出现,理解了它,看很多内存池代码都会有豁然开朗的感觉。

5.3 颜色填充与每CPU缓存:两个容易被忽略的优化

slab分配器有一个叫cache color的优化,在做完基本的大小对齐后,会把不同slab的起始偏移错开,让不同slab中的同名对象落在不同的硬件缓存行上。这样多个对象同时被访问时,不容易因为缓存行冲突而反复互相驱逐。用食材类比,就是把不同盘子里的菜错位摆放,让每个盘子里的第一道菜尽量不在同一个灶眼上加热。

比颜色填充影响更大的是每CPU缓存(per-CPU cache)。页面分配和对象分配如果都只靠一个全局锁,多核竞争会成为瓶颈。每CPU缓存的做法是:每个CPU维护一小批空闲对象,分配时优先从本CPU取;取不到的再去全局slab列表批量搬一批过来,释放时也优先放回本CPU缓存。由于每个CPU只访问自己的缓存,绝大多数分配路径完全没有锁竞争。

这种“批次搬运”模式还有一个额外好处:对象刚释放不久又分配时,很可能还是同一个对象,数据还在CPU缓存里,能明显提升缓存命中率。实际系统里,这项优化带来的收益往往比算法本身选型的影响还要大。

6. 实际调优中遇到的碎片问题与排查思路

6.1 “内存剩很多但分配失败”的一次完整排查

回到开头提到的那个报障。当时服务内存还剩几十GB,但某个模块申请连续大块内存一直失败。按常规思路先查泄漏,没有结果;后来看到系统导出的内存碎片统计信息,才发现空闲页大量集中在order 0、order 1这种小级别,order 4以上乃至order 8的高阶空闲块已经接近于零。也就是说,系统并不是缺内存,而是缺“连在一起的空闲内存”。

这种状态就是典型的外部碎片失控。排查思路可以整理成三步:

  1. 确认不是内存泄漏:观察总空闲量是否持续下降,如果总量稳定但大块连续区域消失,方向就转向碎片化。
  2. 观察各order空闲块分布:看低阶空闲页多不多、高阶空闲页是否为空。高阶连续空闲页跌到0,说明碎片化非常严重。
  3. 分析不可移动页占比:有些页因为驱动器映射等原因是不能迁移的,它们像楔子一样挡住了连续区的形成。不可移动页占比越高,规整越困难。

6.2 内存规整:把“散装区”变成“整装区”

发现碎片化之后,首选的处理手段不是重启,而是触发内存规整。整个思路类似于整理书架:把零散分布的可移动页复制到紧凑的位置,把腾出来的连续区域拼成一个大块。

内存规整依赖页面迁移能力。只有标记为可移动的页才能在物理地址间搬移,分配时优先使用可移动页,反过来也让空出来的区域更紧凑。实际操作中,如果某个长时间运行的模拟系统经常出现高阶分配失败,可以考虑在启动参数中调整内存分配策略,尽量把不可移动页限制在固定区域,给规整和伙伴系统合并留出更大空间。这里要提醒一点:规整是线程异步进行的,会占用额外CPU,频繁触发时也要警惕CPU打满的副作用。

6.3 水位线与直接回收路径:调参背后的代价

除了碎片,页面分配还有一个重要机制叫水位线。系统设置低位、高位两个阈值,空闲页低于低位时唤醒异步回收线程,尽量提前回收内存;如果内存消耗太快,异步回收来不及,分配路径会陷入同步的直接回收流程。那位开发者朋友原本想通过调大预留量避免碎片问题,结果反而导致可用内存变少、直接回收频繁触发,业务出现周期性卡顿。

根据我的经验,水位线调节要遵循一个原则:默认值足够应对大多数场景,除非你明确看到了分配失败的统计计数持续增长,否则不要激进调参。需要调整时,优先关注是否有适当的高阶空闲页储备。可以适当增大系统预留内存,给紧急分配路径留一杯“保底饮水”,但一定要同时观察用户态可用内存是否明显缩水。调整后下一轮高峰再看分配失败计数和高阶空闲分布,不要改一次就万事大吉。

页面分配算法的知识密度很高,但最核心的链路就是这一条:空闲页怎么记录、按什么策略切分、释放时怎么合并、小块对象怎么在上面做接力。沿着这条线把伙伴系统和slab分配器吃透,就已经能够应对绝大多数内存碎片和分配性能问题。下一篇文章我会继续往后讲虚拟内存映射与缺页处理,把物理页分配这条主线和上层地址空间串起来,到时候再一起细聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询