很多学操作系统的人,翻到“连续内存分配”这一章时容易犯一个错:觉得它太简单。毕竟名字里写着“连续”两个字,无非是把内存一块块切开分给进程,跟切蛋糕差不多。但真到了写实验、调 bug、甚至面试被追问的时候才发现,这一章的坑比想象中多得多——外部碎片怎么处理、分配算法到底选哪个、地址重定位的时机为什么重要、空闲链表该用什么结构维护,每一条都能往深了挖。
这一章解决的核心问题很明确:多个进程同时驻留在内存里,怎么把物理内存空间安全、高效地划分给它们。它是一切后续内存管理方案(分页、分段、虚拟内存)的起点,也是理解操作系统如何“管硬件资源”的极佳样本。不管你是正在上这门课的学生,还是准备面试的开发岗候选人,或者只是想搞清楚自己写的程序在内存里到底怎么安家的读者,这一章都值得认真对待。我当年学的时候,老师讲完固定分区和动态分区,我自以为懂了;等自己动手实现了一个模拟分配器之后,才发现理解停留在表面。这篇文章就把我在理论学习和实验操作中沉淀下来的东西,掰开揉碎讲给你听。
1. 这一章的核心是什么:内存管理的第一块基石
1.1 连续内存分配在整个内存管理里的位置
在操作系统课程里,内存管理章节通常会按“连续分配 → 分页 → 分段 → 虚拟内存”的顺序展开。这个顺序不是随便排的,它其实是问题驱动式的演进逻辑:先尝试最简单的方案,发现缺陷,再用更复杂的机制去弥补。
连续内存分配的基本假设是:一个进程加载进内存时,占据的是一整段地址连续的物理空间。这个假设非常符合直觉,也符合早期程序员的认知——毕竟程序在磁盘上就是一个线性文件,加载时按顺序塞进内存天经地义。但实际上这个朴素想法很快会撞上两道墙:空间利用率和灵活度。程序有大有小,内存有快有慢,进程会退出会变化,空闲空间从一开始的“一块完整大蛋糕”就会慢慢裂成一地碎渣。于是就有了固定分区、动态分区、伙伴系统这些不同的管理策略。
把这个位置搞清楚很重要,因为它决定了你看后续内容的视角。分页本质上是把“连续”这个约束彻底放开了,用固定大小的页去嵌套任意大小的逻辑地址空间;但它并没有消灭连续分配讨论过的所有问题,反而把外部碎片转移成了内部碎片。如果连续分配这一章的理解不透彻,后面学分页里的页表、TLB、多级页表时,很多“为什么这么设计”的问题就找不到根。
1.2 连续分配的底层目标:三条硬约束
我习惯把连续内存分配的目标拆成三条,这三条同时成立,难度一下就上来了:
- 空间上不重叠。任何两个常驻进程占用的物理内存区域不能有交集,否则程序数据会互相覆盖,系统直接崩。
- 时间上可复用。进程退出后,它占用的空间要能被回收,分给下一个进程,而不是拖着“空洞”不放。
- 效率上少浪费。分配器自身的数据结构要小,分配和回收操作要快,碎片导致的浪费要尽量低。
这三条约束放在一起,就能解释教科书上很多看似“多余”的设计。比如为什么回收空闲区时要考虑合并相邻空闲块?因为如果不合并,空闲列表会越来越碎,新来一个大的进程可能就找不到足够大的连续区域。为什么地址重定位要在运行时做?因为编译时根本不知道进程会加载到内存的哪个物理地址上,所有指针和跳转目标都要在装载后重新计算。
我在做实验时给自己记了一句话:*连续分配器的核心矛盾是“不知道下一个进程有多大”,所以只能在碎片化和分配延迟之间做权衡。*想通这一点之后,再看各种分配算法就会觉得它们其实都有自己的“性格”,没有绝对的最优解。
2. 基础模型:分区划分与地址变换
2.1 固定分区与动态分区:第一个分岔口
固定分区是最早的思路:系统启动时就把物理内存切成若干块,块大小可以相同也可以不同,每块只允许装一个进程。它的优点是实现极其简单,几乎没有数据结构负担;缺点是极度僵硬。如果分区大小是 8MB,来了一个 3MB 的进程,就得浪费 5MB;来了一个 10MB 的进程,即使总剩余空间足够,也可能没有哪个分区能装下它。
这里有两个关键术语要区分清楚,很多人在这里栽跟头:
- 内部碎片:进程没把分配给它的一块区域用完,剩余的部分在分区内部躺着浪费。
- 外部碎片:所有分区之间的小空闲块加起来足够大,但每一块都不够单独装下一个进程。
固定分区同时带来这两种碎片,尤其内部碎片几乎是必然的,因为它强制“进程大小必须落在分区大小的档位里”。这就好比买衣服只有 S、M、L 三个码,任何人的身材都不可能正好对应某一件衣服,即使勉强穿了,袖子总是长一截。
动态分区则丢掉了“预切块”的思路,改为在进程真正到来时,从当前的空闲区里切一块正好够用的空间给它。每次分配按需切割,空闲区大小和位置都在变化。好处是灵活,避免了固定分区的内部碎片;坏处是空闲区和已分配区犬牙交错,很快就产生大量外部碎片。动态分区正是现代操作系统课程里讲授的重点,也是我们后面要详细拆解的部分。
2.2 重定位:动态分区绕不开的“地基”
动态分区意味着进程装载时的起始地址不是固定的,甚至同一个进程第二次运行,加载位置都可能不同。那问题来了:程序里写好的地址怎么办?代码里的跳转指令、数据引用的地址,在程序编译时都被翻译成了相对于程序起始点(0 地址)的逻辑地址。装载到内存后,这些逻辑地址必须变成真实的物理地址。
重定位的常见做法有至少三种层次:
- 编译时静态重定位:编译或链接时就知道程序会被加载到某个固定地址,直接生成物理地址。这种方案要求每次加载地址都一样,实用价值很低。
- 装载时静态重定位:程序加载到内存中的某个位置后,由装载器把程序里的所有逻辑地址加上一个“偏移量”,一次性重写。实现简单,但程序一旦在内存中移动位置就没法用了。
- 运行时重定位:这个才是现代系统的主流思路。CPU 硬件里有一个基址寄存器,进程运行时,CPU 把指令产生的逻辑地址与基址寄存器里的值相加,得到物理地址。进程被换到不同的内存区域,只要改一下基址寄存器即可,程序自身完全不用动。
运行时重定位有个极为重要的推论:进程可以在执行过程中被整体搬移。这恰好是解决外部碎片的手段之一——“内存紧凑”(compaction)之所以可行,靠的就是运行时基于寄存器的地址变换。你只要把进程从 A 地搬到 B 地,更新基址寄存器,程序就能继续稳定跑。如果没有运行时重定位机制,搬一次家就意味着要修改进程内部所有地址数据,工程上是不可接受的。
注意:理解逻辑地址、物理地址和重定位三者的关系,是本章后半部分(分页、分段、虚拟内存)的基础。这一步跳过了,后面学页表时很容易把“逻辑页号→物理帧号”的映射搞混。
3. 动态分区分配算法的深度权衡
3.1 First-Fit、Best-Fit、Worst-Fit 各自的“脾气”
动态分区中,核心模块是空闲区管理,常见分配算法有三种,我直接用实战视角来对比它们。
首次适配(First-Fit)的做法是:从空闲链表头开始扫描,找到第一个大小满足要求的空闲区就分配。它最大的优点是快——尤其链表表头附近有空闲区时,平均查找次数少;同时它倾向于把大空闲区保留在链表后半段,这对未来可能到来的大进程更友好。但缺点也很明显:链表前部会被频繁切割,产生大量小碎片,所以回收时需要勤快一种做向前向后合并,否则性能下降得厉害。
最佳适配(Best-Fit)的做法是:扫描所有空闲区,选择大小最接近需求的那一个,把浪费降到最小。听起来这应该是最优策略。但实际操作中我踩过一个典型问题:*按“最适合”切割后,剩下的残余区往往非常小,几乎无法被任何后续进程使用,就成了新的外部碎片。*也就是说,最佳适配会产生大量“针眼”式的小空洞,空闲链表很快就长满了垃圾节点。要命的是它每次都需要完整扫描链表,开销大。那些小碎片还要反复扫描,形成恶性循环。
最差适配(Worst-Fit)恰好相反:每次选最大的空闲区来分配,宁愿切完后留下一块较大的剩余区,方便后续进程使用。这想法也很合理,但事实证明,大空闲区被一次又一次切割后,终有一天也会变成中等碎块,整体碎片化速度并不慢,而且也需要全链表扫描。在很多模拟实验中,三种算法的综合表现排序通常是:首次适配在速度和简单性上胜出,最佳适配在利用率上有理论优势但碎片太多,最差适配则大多是“看起来好,实际一般”。
这里想多说一句:教科书喜欢给这三种算法下简单结论,考试也爱考“谁最容易产生内存碎片”。但做工程时,我更推荐用模拟数据说话。你可以写一个简单的事件驱动模拟器,喂入同一组进程创建/撤销序列,对比三种算法下的内存利用率和分配失败率。我做过类似实验,结果在不同负载下排序会翻转,所以真正的结论是:没有绝对最优,只有与业务负载匹配的适配策略。
3.2 空闲区表的实现选择:链表、位图、还是边界标签
分配算法跑在空闲区数据结构之上,数据结构的选择直接影响分配和回收的复杂度。
最直观的方案是空闲链表。链表的每个节点记录一块空闲区的起始地址和长度。分配时遍历找合适的节点,切割后更新节点;回收时插入新节点,并检查相邻空闲区是否可以被合并。链表实现简单,逻辑清晰,但有两个问题:一是碎片节点的遍历开销,二是链表本身要占用内存,通常存放在分配给每个进程的控制块或全局表里。
位图是另一条路线。把内存按固定大小的块(比如 4KB)分成若干单元,再用一个位图,每个 bit 为 1 表示该块已分配,为 0 表示空闲。分配时找连续的 0 串,回收时把对应 bit 翻回 0。位图的优点是:内存占用固定且小,查找连续 0 串可以一次性用位运算扫描,速度可观。缺点是:找一个恰好满足需求大小的连续 0 串,位图中没有天然的空闲区“长度”信息,需要额外遍历统计;同时分配粒度受限于块大小,会引入内部碎片。
还有一种更为工程化的做法是边界标签(boundary tags)。它在每个分区(不管空闲还是已分配)的头部和尾部都记录分区大小和分配状态。每个分区的尾部信息让“向前合并”变得很便宜——要回收一块区域时,你只需要看它前后邻居的尾部/头部标记,如果邻居也是空闲的,就可以立刻扩大这块区域。这个方案在动态分区模拟器里非常常用,也是我在实验里推荐自己动手实现一遍的方案,因为处理“前后邻接合并”的逻辑会把链表版的回收细节逼出来。
3.3 回收与合并:动态分区最容易出 bug 的环节
分配写起来简单,回收才是考验。假设进程释放了一块区域 P,回收程序需要判断:
- 它前面那块区域是否空闲?空闲则合并,注意新区域起始地址要改成前一块的起始地址。
- 它后面那块区域是否空闲?空闲则合并,新区域长度要加上后一块的长度。
- 两者都空闲,则三块合并成一块。
这个判断在实现时顺序有讲究。我最早写代码时先合并后面的再找前面的,结果在处理链表的 prev/next 指针时经常指错,调试了很久。后来换了教科书推荐的做法:先看前邻居,再看后邻居,两种情况分别处理,每次只改变局部指针,逻辑清晰了很多。
另外有个细节:回收时合并空闲区,表面上是为了消除碎片,深层目的其实是让链表上每个节点的面积尽可能大,降低分配时的扫描长度。如果不合并,进程反复申请释放几次,链表上就可能挂着几十个未合并的小节点,分配的扫描时间会显著恶化。这个现象我在模拟实验中是亲眼见过的,从几微秒涨到几十毫秒,只因为一次忘记合并。
实操心得:实现回收函数时,最好在链表的头尾各放一个“哨兵节点”,哨兵永远标记为已分配。这样可以避免在边界处写特殊判断逻辑,所有合并操作都能用同一套代码处理,少很多边界 bug。
4. 碎片问题:连续分配的真正敌人
4.1 内部碎片和外部碎片的本质区别
内部碎片和外部碎片虽然都叫碎片,本质完全不同。内部碎片是“分配资源的粒度问题”,比如给进程分配了 8KB,它只要 5KB,那 3KB 就内部浪费了。这种浪费发生在已分配区域内,只要进程还活着,这块空间就永远无法被其他进程使用。外部碎片则是“空闲空间分散问题”,总共的空闲空间足够,但没有一块连续区域能满足新的分配请求。
两者的治理思路也因此不同。内部碎片靠“更精确的内存粒度”来缓解,比如把分配单位从 KB 级降到字节级,或者用分页机制把物理内存切成小帧来消除大块内部碎片;外部碎片靠“整合和搬移”来处理,比如内存紧凑、或者换成非连续分配方案。
我记得老师讲过一句特别经典的话:*分页机制其实就是把外部碎片“内部化”了。*页是固定大小的,物理内存的每个帧也是固定大小的,分配时只要凑够帧数就行,不再需要物理上连续;但进程的虚拟空间一般不会恰好是页面大小的整数倍,最后一个页里总会有浪费——这就是内部碎片。所以说分页没有彻底消灭碎片,只是换了形态。能理解到这一层,操作系统内存管理的知识就串起来了。
4.2 紧凑(Compaction):搬运进程的艺术
紧凑是针对外部碎片最直接的解决方案:把已分配区域里的进程都往内存的一端移动,让所有空闲区汇合成一个大的连续区。代价是什么呢?首先是时间。移动大块内存数据需要拷贝,进程越大越多,拷贝的开销越明显。其次是停顿。进程在移动过程中,引用它的指令、缓存、TLB 都可能失效,系统通常需要停止该进程的运行,这在实时系统里几乎不可接受。
但紧凑也不是一无是处。在一些嵌入式系统里,没有 MMU(内存管理单元),也没有分页机制,紧凑是治理碎片化的少数可选项。加上前面说的运行时重定位机制,紧凑在技术上不复杂——改基址寄存器就行——问题完全集中在性能上。还有一个工程细节:紧凑的顺序和方向有讲究。一般选择从两端向中间搬移,而不是从头到尾,这样可以减少搬移总次数;如果知道进程的运行优先级,也可以把不活跃的进程优先搬移,减少停顿影响。
4.3 伙伴系统的思想:把碎片“有序化”
伙伴系统(buddy system)介于连续分配和非连续分配之间,是很多实验和真实系统里会用到的方案。它的核心思路是:把内存看成大小为 2 的幂次方的块,最小块和最大块都有设定。分配时,把请求大小向上取整到 2 的幂次;如果对应大小的块不够,就把更大的块一分两半,直到分出满足大小的块为止。回收时,如果某块和它的“伙伴”(同一次分裂得到的另一半)都是空闲的,就立即合并回去,形成更大的块。
伙伴系统的优点是合并非常方便:因为块大小都是 2 的幂次,伙伴的地址可以通过位运算直接算出来(当前地址异或块大小)。缺点是内部碎片:请求 7KB 会拿到 8KB 的块,浪费 1KB;如果请求 9KB,则需要 16KB,浪费 7KB,这个浪费率在临界点附近可能高达近 50%。所以伙伴系统更适合大小比较规整的分配请求,Linux 内核早期的物理页分配就广泛采用类似思想。
我在实验里实现伙伴系统时,用了一个数组存放不同大小块的空闲链表,分配和回收都变成数组下标的转移。代码量不大,但好处很明显:复杂度和碎片管理都比动态分区清晰得多。如果你学到这里,推荐把动态分区算法和伙伴系统各实现一遍,对比它们的空间浪费和分配时间,这样对“为什么实际系统很少用纯动态分区”会有直觉上的认识。
5. 常见问题与实验排查实录
5.1 我在模拟器里踩过的坑
自己动手实现一个连续内存分配模拟器时,我遇到了不少问题,挑几个最有代表性的分享。
坑一:忘记考虑进程自身的控制块开销。早期版本里,我只在“进程需要的内存大小”上分配空间,忽略了我自己要在链表里为每个分区维护头节点信息。结果模拟运行一段时间后,内存消耗看起来比预计大不少,一查才发现控制块本身也是内存占用的。真实操作系统里也有类似问题,内核数据结构要占用一部分系统内存,不能全部拿来给用户进程用。
坑二:链表节点指针悬挂。回收合并时,我把当前节点从链表移除,但另一个地方还保留着指向它的指针,后续分配时访问了被释放的内存。这种 bug 在 C 语言里表现为随机崩溃,排查时非常痛苦。后来我用边界标签方案来存储分区元数据,分区节点本身是内存里的一段头部信息,不再依赖外部链表指针,问题大幅减少。
坑三:模拟实验的数据要“看起来真实”。我一开始用随机生成的进程创建请求,发现三种分配算法的差距很小,几乎分不出优劣。后来把请求序列改成带有“大进程间歇性出现”的业务模式,差距就明显了:首次适配在应对间歇大进程时表现更稳,而最佳适配经常因为碎片积累而分配失败。这说明模拟负载的设计比算法实现更影响实验结论。
坑四:回收顺序导致碎片重现。我在实验里加入了“进程全部退出后再加载大批进程”的场景。第一次没做紧凑,直接分配,结果虽然总空闲空间足够,却因为碎片化导致分配失败。这直观说明了:碎片问题不是“空间不够”,而是“空间不连续”。后来加了紧凑模块,分配成功率立刻回升,但时间开销上升明显。这个 trade-off 做一次实验体会就很深。
5.2 高频考点与面试题的答题框架
学完这一章,免不了面对考试和面试。我把经常碰到的问题整理成一张速查表:
| 问题 | 答题要抓的核心 |
|---|---|
| 固定分区和动态分区哪个内部碎片更多? | 固定分区的内部碎片多,动态分区主要造成外部碎片 |
| 最佳适配为什么会产生最多的小碎片? | 因为它切割出的剩余区尺寸最小,几乎无法继续利用 |
| 内存紧凑为什么必须与运行时重定位配合? | 进程搬移后,代码内地址不变,只有基址寄存器更新才可行 |
| 伙伴系统的内部碎片为什么在高位请求时严重? | 请求量靠近但超过 2 的幂次边界时,向上取整导致接近 50% 浪费 |
| 空闲链表与位图各自的适用场景? | 链表适合大分区、较少分配请求;位图适合固定小块、需要快速定位 |
面试经常追问的是第一个和第二个问题。答题时不要背结论,可以结合一个具体例子推演:比如内存总长 100,进程请求 50、30、10,按最佳适配会怎么切,回收后又变成什么形态。能用例子讲清楚逻辑,远比背结论有说服力。
还有一类题问“连续分配有什么致命缺点,为什么现代通用 OS 不用它作为唯一的分配方案”,答三条就够了:一是外部碎片难以根治,二是进程大小受限于内存连续空间,三是没有支撑虚拟内存所需的地址隔离能力。分页和虚拟内存的引入,本质上就是在绕过连续物理空间的限制。
5.3 连续分配在真实系统里的“隐形存在”
很多人学完这章会问:现在的通用操作系统不是都用分页吗?那学代码还有用吗?答案是:分配器的思想在底层无处不在。Linux 内核里的物理页分配器、某些嵌入式固件的内存管理、还有不少用户态的自定义内存池,或多或少的都沿用了伙伴系统或者首次适配的思想。你在 C/C++ 里调 malloc,到 glibc 的 ptmalloc 实现里还能看到空闲链表和 bin 的设计影子;在数据库和网络服务器里,内存池的分配策略更是和连续分配算法一脉相承。
所以我的建议是:别把这章当成纯理论,花一个周末写一个小型模拟器——支持固定分区和动态分区、实现 First-Fit 和 Best-Fit、记录碎片数量和分配失败次数。代码量估计在几百行到一千行之间,却能让你对“为什么分页能取代连续分配”、为什么真实的分配器要那么复杂,形成别人给不了的第一手感知。
最后再分享一个我在做实验时体会最深的小细节:分配器的正确性容易验证,但性能特征非常依赖工作负载。操作系统是通用软件,面对的是千奇百怪的进程行为,所以才需要不断在策略间权衡。当你真正为一个具体的负载调优过内存分配器之后,再回头看这一章,你会觉得那些看起来简单的算法,其实每一个都是在“快速”“少浪费”“抗碎片”三个目标之间做艰难取舍的产物。这种取舍感,就是学操作系统最珍贵的收获。