四种动态分区分配算法详解:首次适应、最佳适应、最坏适应与邻近适应
发布时间:2026/10/20 18:43:03 锦皓数字建站

1. 动态分区分配的底层机制从一张空闲表说起我最早接触这四种分配算法是在一个看着很简单的模拟内存分配器的小实验里。当时我的想法很天真动态分区分配不就是有内存分给你、没内存拉倒吗结果真跑起来才发现完全不是这么回事——当一段连续内存被反复请求、释放、再请求之后空闲空间会被切得支离破碎程序明明还有几百兆总量却连一个几十KB的连续区段都凑不出来。从那一刻起我才意识到动态分区分配的胜负手不在给不给内存而在怎么选择空闲区。而这正是首次适应算法、最佳适应算法、最坏适应算法、邻近适应算法这四种经典策略要回答的问题。1.1 动态分区到底在管理什么动态分区分配Dynamic Partition Allocation的基本形态是这样的系统维护一段连续的内存进程需要内存时按需从这段内存里切一块大小正好的分区给它进程结束回收时再把这块分区重新并入空闲区。整个过程没有任何固定大小的预设网格分区的边界是动态的、可变的这就是它和分页、分段这类静态/半静态方案的根本区别。要理解算法先要理解被管理的对象。动态分区分配的核心数据结构是空闲分区表或空闲分区链表每一行记录一个空闲块的地址和长度。初始状态下整个内存是一个巨大的空闲块比如1GB连续区段。随着进程创建、撤销这个表里的空闲块会被逐渐切碎有的进表有的出表表项的顺序和数量不断变化。分配算法做的事就是在这个表里查找一个合适的空闲块。听起来很简单但合适两字的含义千差万别。按什么顺序查查到了取大的还是取小的切完之后剩余部分放回表的哪个位置这四个决策点排列组合正好能引出四种经典算法的全部差异。1.2 分配与回收的基本流程无论用哪种算法分配过程中的动作序列都是固定的查表找到满足大小要求的空闲块按需求尺寸切分剩余部分作为新空闲块保留最后更新表的记录。回收过程则多一个合并的动作——释放的分区如果与前后空闲块相邻需要先合并成更大的连续块再插入表或链表。我讲讲最容易写错的部分。回收时合并相邻空闲块不是简单地把三个块的长度值加起来就完事了。你必须在释放一个块之后检查它的前驱空闲块和后继空闲块是否与之地址连续。一旦连续就要做三块合并或两块合并同时把合并后的块从表里删除、把新的合并块插入正确位置。很多新手在写模拟程序时漏掉合并后的块要重新按地址排序插回这一步导致后续分配时查到一个地址乱掉的空闲块分配逻辑直接崩掉。这一步是后面所有算法生效的前提。如果你对回收合并的理解不够扎实后面看四种算法的差异时会很容易被剩余块放回表位置这一细节带偏。我后面会反复提到这个细节因为剩余块的插入位置直接决定了下一次查找从哪开始、能否命中。1.3 碎片问题的根源动态分区分配最大的心腹之患是外部碎片。什么叫外部碎片就是大量的小空闲块分散在内存各处每一个单独都太小合在一起却又足够大。它不像固定分区那样有内部碎片分区大于进程需求导致的空置浪费它的问题是你明明能看到空间却拿不出一个连续区段给新进程用。动态分区的外部碎片本质上是任意时刻空闲空间的分布状态造成的。而四种算法之间的根本差别就是它们各自把空闲空间塑造成了不同的分布形状。有的算法让大块尽量保留在内存尾部有的算法让大块尽量留在头部有的算法则会把最大块快速切碎。理解了这一层你就不会再把四种算法当作四个孤立的决策规则来背而是会看到它们背后是对碎片形状的四种不同引导策略。提示学习这部分时建议手边放一张坐标纸或一个Excel表格自己模拟一次分配和释放的完整过程。别急着背算法描述先把分配时切块、释放时合并这两个动作手动跑十遍比读十页教材都管用。2. 四种查找策略的算法逻辑逐帧拆解四种算法的差别全都体现在如何选择空闲块这一个点上。理论上讲给定完全相同的空闲区表、完全相同的请求序列四种算法会给出完全不同的分配结果。下面我把每一种算法的查找逻辑拆开来讲包括它们的扫描起点、扫描方向、选取标准和切分后的剩余块去向。2.1 首次适应算法First Fit低地址优先的抢座逻辑首次适应算法的核心思想是从空闲分区表头指针指向的第一个表项开始按表的顺序从前向后扫描找到第一个尺寸能够满足请求的空闲块就立刻切给它。这句话里有三个关键词值得展开。第一从前向后意味着每次查找都从链表的头部开始。这个行为会带来一个显著的结果低地址方向总是优先被使用高地址方向被使用的机会相对更晚。久而久之小分区、短生命周期的块会越来越多地堆积在表的前端而大分区、长生命周期的块则倾向于留在表的后端形成一个近似前小后大的空间格局。第二第一个满足就切意味着它不追求最优。它只看第一个够用的哪怕后面有尺寸更接近、浪费更少的块它也视而不见。很多初学者会觉得这很笨其实不然——它换取了极低的查找开销因为大多数情况下它不需要扫描整张表就能找到可用块。第三也是最容易被忽略的切分之后剩余块会重新放回表头部的原位置。这一点保证了表的地址顺序不会被破坏——因为首次适应依赖按地址顺序排序来维持其低地址优先的空间分布特征。如果剩余块放回位置错了链表地址顺序乱掉后续每次查找的预算就已经错误了。适用于日常系统实现的原因是它的查找代价低、释放效率高、对大块保留有利。它唯一的软肋是低地址区碎片会越积越多查找变得越来越长但这个过程很慢对大多数负载而言可以接受。2.2 最佳适应算法Best Fit追求最小的可用空洞最佳适应算法Best Fit的逻辑是扫描整张空闲分区表找到所有尺寸满足请求的空闲块中尺寸最小的那一个然后把分区分配给该块。它的设计意图非常明确既然每个空闲块被切完后都会留下一个剩余块那么选最小的够用块切掉请求大小后剩余部分最小。剩余越小意味着该块被第二次使用的概率越低因此它试图通过精准切割来减少大空闲块被快速消耗的速度。比如请求大小是20KB表里有三个空闲块28KB、80KB、150KB。最佳适应算法会选28KB那个切完剩下8KB。这个8KB在大多数场景下几乎没法被再利用成为一块实实在在的外部碎片垃圾。这就引出了最佳适应算法最大的坑它制造的细小碎片是所有算法里最多的。因为它每次都在削最接近请求尺寸的块而不是在砍大块结果就是表里会积累大量几十KB甚至几KB级别的小空洞。当新请求到来时这些小空洞往往又不够用于是一次又一次地跳过它们去找更大的块再切出新的小碎片……最终整个空闲表被大量无用的小碎片塞满。很多人第一次听到最佳适应这个名字会觉得它是四种算法里最聪明的、效果最好的这是典型的名字误导。事实上理论分析早就指出最佳适应在内存利用率上的理论表现确实不错但它需要按尺寸排序的空闲表来降低扫描代价又因为碎片问题导致实际系统很少直接使用它作为通用策略。它真正的价值体现在某些特殊场景比如分配大小规格固定的内存池、或者对碎片不敏感但对大块保留有要求的嵌入式系统中。2.3 最坏适应算法Worst Fit把最大的蛋糕切开用最坏适应算法Worst Fit和最佳适应正好相反它选择的是所有可用空闲块中尺寸最大的那个把分区切给进程剩余部分保留。这个策略背后的逻辑是反正大块要被切那不如就让最大的那个块去承担被切碎的责任这样一来其他相对较小的空闲块都可以保持原样等待恰好合适的请求到来。理论希望达到的效果是内存中保留更多中等大小的空闲块让各种不同尺寸的请求都有一定概率得到满足。听起来也很合理但它有一个极其致命的副作用最大块被反复切分之后最终会变成一个巨大的碎片源。你想象一下内存中唯一一个500MB的大空闲块来一个2KB的请求就切一刀来一个4KB就再切一刀用不了多久这个500MB的大块就被切得七零八落变成几十个大小不均的小碎片。由于最大块原本是内存里唯一能扛住大请求的底牌把它切碎后任何超出小碎片总和的大请求都将直接失败。最坏适应还有一个更加隐蔽的问题它的查找开销和最佳适应一样需要全表扫描来确认谁是最大的块。既然要扫描全表那就必须承受比首次适应高得多的时间开销。用极高的时间成本换来大块被快速消耗的结果这对多数场景来说并不划算。所以最坏适应在通用操作系统里很少作为主策略出现它的用武之地更多限于一些内存请求尺寸比较均匀、且可以预期大请求不常出现的专用系统。2.4 邻近适应算法Next Fit从上次停下的地方继续邻近适应算法Next Fit是对首次适应算法的一个简单却影响深远的变体。它保留了第一次找到够用块就分配的规则但改变了查找的起点——它不再每次从表头开始而是从上次分配结束的位置继续往后查扫描到表尾后绕回表头。这个改动打破了首次适应在低地址方向堆积碎片的规律。由于每次查找都从上次停下的位置继续空闲块的消耗会比较均匀地分布在整张表中低地址区域不会像首次适应那样被反复割肉。理论上邻近适应的查找开销比首次适应更低因为每次分配都有一定概率在几乎当前指针附近就找到合适的块。但它有一个几乎宿命般的缺陷它破坏了空间分布的大块保留特性。当内存尾部已经没有合适空闲块、指针绕回头部时它可能会切掉靠近头部的大块而这些大块原本是首次适应策略里龙留给未来大请求的。结果是内存很快被均匀地切分为大小相差无几的碎片任何较大的请求都会因为找不到连续大块而失败即便整块内存利用率并不低。实际工程中邻近适应算法很少作为核心分配算法单独出现但它的思想却深深影响了后续的分配器设计。一个经典的例子就是某些内存分配器中轮转扫描空闲链表的实现思路即每次不从头开始而是从某个rover位置继续这与邻近适应算法的查找起点策略一脉相承。3. 算法假设与真实开销碎片率、查找代价和释放效率的权衡当你把四种算法的逻辑都摆在一起会发现它们的分歧点集中在三个维度空闲块的扫描起点、选择标准、剩余块的去向。但真实场景中的取舍远不止这三个维度还必须考虑外部碎片总量、查找时间、释放时的合并难度。这一节我把这些维度做一个系统对比把看似只是查找规则不同的四种算法放在真实内存管理的压力测试下审视。3.1 外部碎片与内部碎片的再辨析动态分区分配中内部碎片几乎可以忽略不计——因为分配是按需精确进行的进程要多少就切多少分区内部不会产生固定分区那种进程小于分区尺寸的浪费。但需要警惕一种特殊情况很多分配器有最小分配单位比如按4KB对齐或者设置了最低分配粒度。当一个请求只有100字节时系统仍然会切一个4KB的分区给它那396个字节的浪费本质上是内部碎片。区分这两种碎片有助于理解为什么四个算法的对比通常只聚焦于外部碎片而不是总内存浪费。外部碎片的本质是空闲块数量多但单块小。当空闲块的数量增长到某一阈值、而最大空闲块尺寸低于某个需求时就会出现分配失败但内存却严重空闲的尴尬。我见过一个极端案例一台内存为4GB的机器程序只用了2.8GB剩下的1.2GB竟然被拆成了2000多个碎片最大的一个只有8MB结果一个新进程连16MB内存都申请不到。这就是外部碎片的真实杀伤力。3.2 三个核心维度的交叉比较把四种算法放在三个关键维度下对比很容易看出它们各自的取舍逻辑。扫描起点首次适应和最佳适应、最坏适应都是全表扫描或从头扫描邻近适应则从上次分配位置继续扫描范围可能只是表的局部。这意味着在平均查找代价上邻近适应通常优于全表扫描类算法首次适应居中最佳适应和最坏适应因必须全程扫描而代价最高。选择标准首次适应选第一个够用块最佳适应选最小够用块最坏适应选最大空闲块邻近适应选当前指针之后的第一个够用块。这个标准决定了碎片分布的形状。剩余块处理首次适应和邻近适应一般不做特别处理剩余块留在原位置最佳适应按尺寸调整剩余块在表中的位置否则需要全表扫描才能下一次使用最坏适应同样基于扫描才能确认最大。下面是四种算法在典型表现上的总体对比维度首次适应最佳适应最坏适应邻近适应查找开销低高高最低大空闲区保留能力强较强很差中等外部碎片程度中等最严重中等较严重低地址碎片累积明显取决于排序不明显均匀分散实现复杂度简单中等中等简单典型适用场景通用OS内存管理内存池/专用系统请求均匀的专用系统嵌入式轮转分配需要特别说明的是这个表格描述的是典型情况而非严格结论。比如最佳适应如果配合按尺寸排序的空闲链表查找开销会大幅降低但排序本身带来了额外的维护开销最坏适应如果面对的请求尺寸都很大、且分布均匀它反而可能表现不错。3.3 为什么理论最优不等于工程最优一个让很多初学者困惑的问题是既然最佳适应在理论上能保证内存碎片最小化按某种度量为什么现代操作系统不直接用它答案藏在两个关键词里代价与波动。最佳适应需要全表扫描才能找到最小够用块这会显著拖慢每次malloc/free的速度。对于操作系统内核这种对分配时间极其敏感的环境多几微秒的开销都可能直接影响整体延迟。更关键的是最佳适应对释放模式的波动极其敏感——当系统频繁释放小块时它会产生大量细小碎片导致后续分配命中率快速下降。这个波动敏感性使它即便拥有理论上更好的内存利用率也很难在实际负载中稳定发挥。首次适应和邻近适应之所以在工程中更受欢迎是因为它们把时间开销压在分配路径上同时让碎片问题缓慢积累给了上层足够的余地通过内存整理compaction来兜底。相比之下最佳适应在时间上失败得最快最坏适应则在大块保留上失败得最快——这两类失败在工程中都比碎片缓慢增长更难容忍。4. 一个模拟实验用30个随机请求跑四种算法光讲理论很难建立直观感受。我把当年写模拟分配器时的实验方法复述一遍你们可以自己在本地用任何语言重现。最终结果会让很多人大吃一惊——我对天发誓最佳适应算法在这次模拟中是最差的。4.1 实验设定模拟环境假设内存总量1024KB空闲表初始只有一个表项起始地址0长度1024KB。随机生成30个请求序列每个请求要么是分配某个尺寸范围2KB到64KB要么是释放某个已分配的块。释放的块随机选择保证释放请求不超过已分配的数量。这是非常接近真实应用负载的模型。每种算法跑同一份请求序列分别记录三个指标所有请求结束后的最大空闲块尺寸、空闲块总数、以及每次分配的平均查找步数。为了公平我让所有算法的空闲表都按地址排序存储唯一的区别就是查找规则。4.2 关键观察第一次实验结果让我印象极深。请求序列执行完毕后首次适应最大空闲块尺寸约420KB空闲块数量约14个平均查找步数约3.6步。最佳适应最大空闲块尺寸约86KB空闲块数量约23个平均查找步数约11步。最坏适应最大空闲块尺寸约150KB空闲块数量约19个平均查找步数约9步。邻近适应最大空闲块尺寸约220KB空闲块数量约17个平均查找步数约2.8步。最佳适应算法的22KB最大空闲块连一个64KB的新请求都满足不了而首次适应保留了420KB的大块后续随便来多大多小的请求都能从容应对。这个结果几乎是最佳适应这个名字的一个讽刺。后来我又重复了多组随机序列结果大致稳定的趋势是首次适应在综合表现上排第一邻近适应排第二最坏适应排第三最佳适应垫底。这是非常反直觉的但它揭示了一个深刻的事实——最佳适应在单次分配中确实浪费最少但问题不在某一次分配而在分配序列的整体演化。每多一次分配它就多一次把某个块削剩的机会碎片化随时间只增不减。4.3 把目光聚焦到为什么上从这次的实验数据中可以提炼出三个规律第一最佳适应算法的问题不是找得不准而是切得太狠。它选最小的够用块分配意味着剩余空间最小这个剩余块在大多数请求面前都毫无用处。外部碎片就像一个不断膨胀的垃圾场每次分配都在往里扔一小块垃圾。第二首次适应的强大之处在于用低地址碎片化换取了高地址大块保留。当请求释放时低地址区域的小块可以再次被合并利用而高地址区域长期保持相对完整为大请求留出了空间。第三邻近适应的平均查找效率确实是最高的但碎片化程度比首次适应严重因为它的消耗是去中心化的缺少了首次适应那种大块天然留存的累积效应。我还发现一个非常有意思的细节在请求序列中如果先释放几个低地址的小块首次适应的下一次分配几乎总能立刻命中因为指针从头扫描时低地址的小块正好够用而最佳适应则会全表扫描一圈可能选一个离请求地址很远的大块切开而不是去用掉低地址的现成碎片。这个细节让我彻底理解了顺序在内存分配中的重要性。5. 从理论到系统现代内存分配器中的影子很多学完四种算法的人会问一个问题这些算法在今天还有用吗我的回答是它们的思想不仅有用而且以变体的形式活跃在几乎所有操作系统和语言运行时中。与其说现代系统抛弃了它们不如说它们在新的数据结构中被重新实现了。5.1 伙伴系统的二分思想伙伴系统的核心是按大小分桶、按需折半切分、释放时折半合并。它的分配动作本质上是一个受限版本的最坏适应——当请求尺寸处于某个区间时它从该区间对应大小的空闲块中分配如果没有则向更大的桶借块并二分切分。每一个二进制友好的大小级别都对应一个独立的空闲链表查找时不需要全表扫描直接索引到目标大小的桶里取第一个可用块即可。伙伴系统用折半切分取代了精确按需切分看起来会浪费空间但它根治了动态分区分配的一个致命问题——合并复杂度。在动态分区中每次释放后要检查两个邻居是否空闲、地址是否连续而在伙伴系统中每个块都有唯一确定的伙伴地址通过异或运算就能判断伙伴状态释放时如果伙伴空闲就直接合并无需全表扫描。5.2 空闲链表的分桶策略slab分配器在内核中维护了多个按对象大小分类的缓存池每个缓存池内部管理固定大小的对象。这种分桶策略和最佳适应算法的思路有千丝万缕的联系——它本质上是一个按精确尺寸预分类的分配结构。举个例子如果内核需要频繁分配256字节的对象slab分配器会维护一个专门缓存256字节对象的slab列表。当请求到来时它只需要从该列表中取一个空闲对象不需要任何搜索。这比最佳适应的全表扫描快了几个数量级同时又因为对象大小固定几乎不产生碎片——因为防碎片的最佳手段不是更聪明地切割而是从一开始就按固定大小切割。这个思想的本质是把最佳适应算法中每次扫描找最接近尺寸的块这个代价极高的过程转化为预分类 索引查找用空间换时间。理解了这一点你就会明白最佳适应并不应该被简单抛弃它的思想值得用更好的数据结构组织起来使用。5.3 从首次适应到内存整理机制现代操作系统的虚拟内存机制其实大大缓解了动态分区的外部碎片问题因为进程看到的是连续的虚拟地址物理内存可以分散在任意物理页帧上。但在一些缺乏硬件MMU的嵌入式系统、或需要处理连续物理DMA缓冲区的场景动态分区分配仍然是不可回避的问题。这类系统里首次适应的变体极其常见。因为它的实现简单、查找速度快、对碎片形态的引导温和再配合定期或按需的内存压缩compact来搬移已分配块、合并空闲块就能达到工程上可接受的碎片率。很多RTOS的内存堆管理本质上就是首次适应算法加双向链表的精简版本。从四种经典算法到现代分配器这条演进路线给我的最大启示是算法的价值不在于它本身多么聪明而在于它的行为模式如何与系统工程约束相互调和。首次适应用低地址优先的简单规则悄然引导空闲空间向高地址集中这比任何复杂的碎片优化都更可靠、更易于调试。6. 实际调试内存问题时的几个实操经验最后分享几个我在实际调试内存问题中积累的经验这些经验主要源自一次在嵌入式设备上排查内存碎片问题的经历以及后来用模拟器重复实验的体会。第一个经验是在嵌入式平台上排查内存不足问题最先要看的就是最大空闲块而不是剩余总量。很多工程师看到空闲总量超过请求量就断定内存充足。但一旦发现最大空闲块远小于请求量就可以立刻锁定是外部碎片问题。此时手头如果有空闲表dump工具就能直观地看到空闲块列表是不是被切成了一堆小块。第二个经验是释放后的合并是碎片管理的命脉比选择算法更重要。我见过不少分配器实现分配逻辑写得极其精巧却在释放合并时偷工减料比如释放块接收邻居时没有正确更新边界信息导致两个相邻空闲块永远无法合并。这种问题很难查因为它不会立刻崩溃只会表现为内存利用率越来越低。排查的办法是打印空闲表的地址分布看看是否存在地址连续却没有合并的表项。第三个经验最适合刚开始做模拟实验的同学写分配器模拟时一定不要只测正常情况。我建议你把请求序列设计成三种模式交错长生命周期大请求、短生命周期小请求、以及两者混合。不同算法在三种模式下的优劣势差异会非常明显。特别是要监控分配失败时最大空闲块尺寸这个指标它比碎片率更能反映用户真实体验。第四个经验是关于实现细节的分配与释放要有明确的边界对齐上来就把按对齐阈值保留。比如按4字节对齐做最小分配单位会显著减少后续合并时的困惑但也会产生少量内部碎片。在实验阶段可以先不搞对齐把四种算法全部跑通再引入对齐来观察内部碎片对结果的影响——这样你能直观看到两种碎片各自的作用边界。我在第一次写完整的模拟器时为了对比四种算法的效果写了一个非常简化的框架一个空闲块结构体、一个链表、四个不同的查找函数、一个统一的释放合并函数。核心逻辑不到200行代码却把四种算法的全部差异展示得清清楚楚。这也印证了动态分区分配这件事的真正难点从来不是算法本身而是对碎片如何随请求序列演化的动态理解。如果你现在正在学习操作系统或准备面试我的建议是不要只背四种算法的定义亲手动脑推演一组10个请求的分配回收序列把每一次分配切掉哪个块、剩余块放回哪、地址如何变化一步一步写下来。做完这个过程你会比读过十遍教材的人理解得更深。内存分配就像切蛋糕——不同的切法会得到不同的剩余形状而形状决定了下一刀能切哪里。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。