我最早真实感受到“连续分配存储管理”这几个字的重量,是在写一个模拟内存分配的小项目时:明明内存总量够大,程序却不断报“分配失败”,我盯着控制台上那块越碎越小的空闲区列表,第一次体会到什么叫存储碎片比容量不足更折腾人。这篇文章想把这段经历背后的东西讲透,包括覆盖、交换这两个经典的内存扩充手段,以及它们和碎片之间那些容易被忽略的连带关系。如果你正在学操作系统、做嵌入式相关开发,或者准备面试,这篇文章会帮你把这些概念串成一条线,而不是让它们各自躺在教科书的不同章节里。
1. 连续分配到底在解决什么问题
1.1 内存管理首先得回答“程序放哪”
任何程序要运行,第一步都是把指令和数据放进内存。连续分配存储管理指的是,操作系统为每个进程分配一段地址连续的内存区域,进程在这块区域内按顺序存放自己的代码和数据。
为什么非要“连续”?因为在早期的硬件和操作系统设计里,连续分配让地址转换极其简单。CPU发出的逻辑地址,加上一个基址寄存器里的起始地址,就能得到物理地址,这个过程只要一个加法操作,不需要额外的查表、硬件遍历、多级索引。对当年那些内存以KB为单位的系统来说,能用最少的硬件代价完成内存访问,比节省一点点空间重要得多。
你可以把内存想象成一条很宽的马路,连续分配就是给每辆车划分一段连续的行驶区段,车只能在属于自己的那一段里往前走。好处是管理简单、隔离清晰;坏处是如果车很小却分到很长的路段,或者车辆来来去去把整条路切得七零八落,道路实际利用率就会直线下降。这两个坏处对应的就是内部碎片和外部碎片,后面会展开聊。
1.2 从固定分区到动态分区:两种思路的取舍
连续分配具体落地时,主要分两个流派:固定分区和动态分区。
固定分区的思路是在系统启动时,把内存划分为若干个大小固定、互不重叠的分区。每个分区可以装入一个进程。按分区大小是否相同,还分等长分区和不等长分区。贴合实际的是不等长分区,比如系统内存在启动时就规划好一个4KB分区、两个8KB分区、一个16KB分区,进程来了就找能装下自己的那个分区进入。
固定分区最大的优势是管理逻辑极简:一个分区表就能记录所有分区的使用情况。但它有一个天生的浪费:如果一个进程只需要6KB内存,而系统最小的空闲分区是8KB,那这6KB进程会被放入8KB分区,剩下的2KB就彻底用不上了。这部分无法给任何其他进程使用的空间,就是内部碎片。
动态分区则不再预设大小,进程需要多少内存,就从当前空闲内存中切一块刚好够用的给出去。这个问题立刻变成了“怎么找一块合适的空闲块”,于是诞生了首次适应、最佳适应、最坏适应三种经典分配算法。
动态分区没有内部碎片,但会引入新的问题:进程不断分配、释放,内存中会留下许多大小不一、散布各处的空闲区,这些空闲区总和可能很大,但单独一块都装不下新进程,这就是外部碎片。为了防止外部碎片影响分配,系统可能需要做“紧凑”,把进程都往一端靠拢,把碎片聚合成大块,代价是搬运所有进程的内存内容,非常耗时。
固定分区和动态分区的核心差异,可以看这张表:
| 对比项 | 固定分区 | 动态分区 |
|---|---|---|
| 分区大小 | 启动时固定 | 按需动态划分 |
| 内部碎片 | 明显 | 基本没有 |
| 外部碎片 | 不存在 | 严重时可用率骤降 |
| 分配算法 | 查表即可 | 需要搜索空闲块 |
| 复杂度 | 低 | 较高 |
从固定分区到动态分区,本质上是拿“管理复杂度”换“空间利用率”。但动态分区做到极致,也无法根治外部碎片,因为只要要求进程占用的物理地址连续,空闲块被切割成零碎小块就只是一个时间问题。
2. 覆盖和交换:内存不够时的两种“变通术”
连续分配解决“程序放哪”的问题,但程序需要的空间可能比实际可用内存还大。覆盖和交换就是在这一背景下出现的两种内存扩充技术,思路完全不同,非常容易被混为一谈。
2.1 覆盖技术:把程序拆开,让模块共享同一块内存
覆盖(Overlay)解决的是“单个程序太大,装不进内存”的问题。它的核心思想是:程序虽然大,但并非所有模块都需要同时驻留内存。某些相互独立的模块,完全可以先后使用同一块内存区域。
最典型的结构是程序由一个常驻部分和若干覆盖段组成。常驻部分始终留在内存里,负责调度和公共功能;覆盖区是一块固定大小的内存,在程序运行的不同阶段,从外存把不同的覆盖段读进来,覆盖掉之前的内容。
我第一次理解覆盖,是在看一个早期编译器的设计说明。编译器工作时,词法分析、语法分析、代码生成这些阶段并不是同时运行的,它们之间有先后依赖,但彼此并不会同时活跃。于是编译器的设计者把它们设计成互相覆盖的模块,内存里只保留当前阶段需要的代码和数据,一个原本需要很大内存的编译器,就能装进很小的内存空间里运行。
覆盖技术看起来很美,但它有一个不小的代价:程序模块之间的覆盖关系往往需要程序员手动分析并声明。程序员要清楚地知道哪些模块不可能同时执行,才能安全地让它们共享同一块内存。一旦分析错,程序可能在运行到某个分支时覆盖掉正在使用的模块,导致难以排查的崩溃。
为了降低手工负担,系统也提供自动覆盖的机制:程序员只需说明模块间的调用关系,构建工具会据此自动生成覆盖结构。但无论自动还是手动,覆盖技术本身都有一个无法回避的弱点——它增加的是程序的设计复杂度,并不减少程序实际运行时的总工作量,反而因为模块需要反复从外存装载,运行效率会下降。
覆盖的适用场景,本质上是“单个进程的逻辑占用空间超过物理内存,但执行阶段彼此独立”。在个人电脑时代,这种东西已经很少见了,因为虚拟内存和分页机制已经接管了这个任务;但在早期系统、嵌入式环境以及部分特殊设备上,覆盖依然是可行的内存节约手段。
2.2 交换技术:把整个进程切换到外存去
交换(Swapping)则站在完全不同的层面处理内存不足。它不关心进程内部模块之间的关系,而是把整个进程当作一个单位:当内存紧张时,把一个或多个进程从内存搬到外存(通常是专用的交换区,或者系统磁盘上的交换文件),腾出内存给需要的进程;等时机合适,再把这些进程从外存换回内存。
交换的最大价值在于,它突破了“内存只能装下当前正在运行的程序”的限制,允许多个进程轮流使用物理内存。你可以把交换理解成宿舍里的“轮流值班”:床位不够,就让不值班的人先去隔壁休息室待着,等轮到自己了再回来。
交换的代价非常直观,那就是磁盘I/O。把一个进程整体换出再换入,要搬运的是这个进程的全部内存映像,哪怕它大部分代码在等待期间并没有被执行。这个过程非常慢——磁盘速度比内存慢几个数量级——所以交换必须谨慎触发,不能太频繁。
实际操作中,交换的触发条件一般包括:操作系统内存分配失败、有高优先级进程等待内存、批处理系统需要调整进程集合等。换出的对象通常是阻塞的、低优先级的进程,因为它们在不久的将来不需要运行;换入的对象则相反,是已经就绪且能够运行的进程。
关于交换目标区域,我有一个很实际的建议:无论是交换区还是交换文件,都应该放在尽可能快的磁盘上。机械硬盘时代,很多人会把交换区放在磁盘最内侧的区域,因为那里寻道距离更短、传输速度也相对稳定;放到SSD时代,这个问题淡化了很多,但你依然不应该把交换区放在网络盘上。
2.3 覆盖和交换,到底哪里不一样
很多初学者分不清覆盖和交换,因为它们都是为了解决“内存不够用”的问题。区别在于:
覆盖是程序内部做的事情,它以模块为单位,一个进程内部的内存区域可以重复使用;交换是操作系统做的事情,它以进程为单位,整个进程被搬进搬出。
覆盖需要程序员的参与和静态分析,交换是操作系统的自动决策,对应用程序透明。
用一个不太严谨但好记的类比:覆盖是“同一个锅轮流炒不同的菜”,交换是“把不用的锅收进柜子,把要用的锅拿出来”。
实际系统里,交换和覆盖完全可以同时使用,覆盖用来压缩单个进程的驻留需求,交换用来协调多个进程对内存的竞争。后来虚拟内存技术出现后,覆盖基本退场,交换还以“换页/换段”的形式留在系统中,只是粒度更细了。
3. 存储碎片:连续分配的“天然硬伤”
3.1 内部碎片和外部碎片,到底哪里“碎”
固定分区造成的是内部碎片,字面意思是碎片出在“分配出去的内存块内部”。你申请了10KB,系统给你个12KB的分区,那2KB就永远浪费在分区里,谁也看不见、谁也拿不走。
动态分区造成的是外部碎片,碎片出在“已经分配的内存块之间”。一堆已分配进程之间散布着大小不等的空闲区,这些空闲区单独看都不大,但总和可能很可观。搞笑的是,这种碎片恰恰是动态分配“灵活”带来的副作用——因为它根据进程实际需求切大小,切来切去,把内存切成了“豆腐渣”。
内部碎片和外部碎片的防范方向完全不同。内部碎片可以通过把固定分区换成动态分区来消除,或者把分区粒度做得更细;外部碎片则几乎无法靠“分配合适大小”来消除,只能靠紧凑、合并、或者从根本上换用分页机制来规避。
3.2 碎片是怎么一步步累积的:一个小型推演
我用一个具体数字来展示外部碎片的恶化过程。假设内存总量100KB:
- 进程A申请30KB,操作系统分配[0,30)。
- 进程B申请20KB,操作系统分配[30,50)。
- 进程C申请40KB,操作系统分配[50,90)。
- 目前空闲区是[90,100),共10KB。
- 进程B退出,释放[30,50),现在空闲区有[30,50)和[90,100),共30KB。
- 进程D申请25KB,假设用首次适应算法,在[30,50)里切出25KB,剩下[55,50)这不可能,应该是[55,50)? 实际上是[30,55)分配,剩下[55,50)? 不对,[30,50)大小20KB,不够25KB,所以会跳过这块,进入[90,100),也不够,分配失败。
- 但此时一个25KB的进程实际上无法分配,因为两个空闲区都不够大,尽管总空闲空间30KB。
这个例子暴露了连续分配最尴尬的地方:空闲空间总量足够,但“连续”的空闲空间不够。50KB的空闲空间分布在两个孤岛上,新进程必须属于某一个岛才能上岛。
注意,如果用最佳适应算法,事情可能更糟:最佳适应会在所有空闲块中找“最小能满足”的那块,长期运行后,内存中会留下大量非常小的、几乎无用的碎片,这些碎片加起来可能不少,但每一个都小到没有任何进程能用。这就是为什么虽然最佳适应从局部看最“节省”,但在高强度动态场景下,反而常常表现不佳。
3.3 碎片整理的常见手段:合并、紧凑、分配策略优化
清理碎片有三个层面可以下手。
第一个层面是“合并”。动态分区分配器在释放内存时,如果发现相邻区域也是空闲的,就把它们合并成更大的空闲块。这是最基础的操作,必须做,但只能处理“相邻”的空闲区,对不相邻的碎片无能为力。
第二个层面是“紧凑”。紧凑的核心操作是把所有已分配的内存块向内存一端移动,让分散的空闲区汇合成一个大块。这个操作看起来简单,但要移动所有进程的代码和数据,搬运量极大。只有在支持动态重定位(也就是有重定位寄存器辅助)的系统中,紧凑才是可实现的,否则移动进程后程序就跑不起来了。紧凑通常是“好钢用在刀刃上”,在分配失败但总空闲空间足够时才会触发。
第三个层面是“分配策略优化”。首次适应倾向于在内存低地址端留下少量碎片,但整体分配速度快;最佳适应倾向于产生大量微小碎片,大块内存保留得较好;最坏适应每次都选最大的空闲块来切,试图让剩余部分仍然可用,但它会让大块迅速被切碎。我在实际做模拟时发现,首次适应往往是最稳定的日常选择,不是因为性能最好,而是因为它在速度、内存利用率和实现复杂度之间取得了最舒服的平衡。
这三个层面都只是“缓解”碎片,并没有真正“解决”碎片。只要系统要求进程物理地址连续,碎片就是一种必然的熵增。解决碎片问题的终极方案,是放弃“连续分配”这个前提,采用分页或分段机制,让一个进程的地址空间可以映射到不连续的物理页框上——这就进入了另一个世界。顺着这个逻辑,你就能理解为什么后来分页会成为主流。
4. 实操记录:用Python写一个内存分配模拟器,直观看到碎片
光讲理论没有感觉,我建议你也自己写一个最简单的内存分配模拟器。我当初用Python花了大概一个晚上写完,跑完一圈后,对碎片的感受完全不一样了。
4.1 模拟固定分区与内部碎片
固定分区很简单,维护一个分区大小列表和一个占用标记列表就行。下面这段代码演示了不等长固定分区下的分配逻辑:
partitions = [64, 64, 128, 256, 512] # 每个分区的KB大小 occupied = [False] * len(partitions) def fixed_allocate(size_kb): for i, p in enumerate(partitions): if not occupied[i] and p >= size_kb: occupied[i] = True internal_fragment = p - size_kb return i, internal_fragment return None, None跑一个例子:进程请求100KB,系统遍历后找到128KB分区,返回分区编号2,内部碎片是28KB。这个28KB就永远浪费了,这是固定分区的“结构性浪费”。
4.2 模拟动态分区与三种分配算法
动态分区的核心数据结构是空闲区链表。每个空闲区记录起始地址和长度,分配时按算法选择一块进行切割。
free_list = [[0, 1024]] # 每个元素是 [start, size] def first_fit(size): for block in free_list: start, bsize = block if bsize >= size: block[1] -= size if block[1] == 0: free_list.remove(block) return start + bsize return None def best_fit(size): best = None best_idx = -1 for idx, block in enumerate(free_list): start, bsize = block if bsize >= size and (best is None or bsize < best[1]): best = (start, bsize) best_idx = idx if best is None: return None start, bsize = best free_list[best_idx][1] -= size if free_list[best_idx][1] == 0: free_list.pop(best_idx) return start + bsize释放时不能只是把空闲区加回去,必须做相邻合并,否则连续几次分配释放之后,空闲区链表就会充满碎片:
def free_block(start, size): free_list.append([start, size]) free_list.sort() # 合并相邻空闲区 merged = [] for block in free_list: if merged and merged[-1][0] + merged[-1][1] == block[0]: merged[-1][1] += block[1] else: merged.append(block) free_list[:] = merged这个合并步骤非常关键。我在最初写的版本里偷懒没有做合并,跑了几十次随机分配释放之后,发现明明释放了大量内存,空闲空间却越来越碎,新进程经常分配失败。加上合并之后,情况立刻改善很多——这正对应了真实分配器里“合并是基本功”的说法。
4.3 用模拟器观察三种算法的差异
我写了一个随机负载生成器,随机创建进程、随机让进程退出,记录分配失败率。跑完1000次操作以后:
- 首次适应:整体最稳,分配失败率最低,空闲区分布比较均匀。
- 最佳适应:经常留下大量又小又碎的空闲块,失败率最高,而且每次分配都要遍历整条链表,速度也最慢。
- 最坏适应:大块空闲区被迅速消耗,中后期分配失败率明显上升。
这个结果和教科书上写的一致,但你亲手跑出来之后,才会真的相信“最佳适应不是最优选择”这个听起来反直觉的结论。
4.4 模拟交换的代价
交换的模拟更偏向策略而不是数据结构。核心是:换出进程要记录它的完整地址空间,还要标记它占用的内存已释放;换入时要把地址空间重新拷贝回内存,并更新进程的内存映射。
class Process: def __init__(self, pid, size): self.pid = pid self.size = size self.in_memory = True self.addr_space = bytearray(size) def swap_out(proc): proc.in_memory = False release_memory(proc.addr_space) write_to_swap(proc.pid, proc.addr_space) def swap_in(proc): addr_space = read_from_swap(proc.pid) addr = allocate_memory(len(addr_space)) proc.addr_space = addr_space proc.in_memory = True当你跑一遍交换模拟,你会立刻看到交换的代价:每次换入换出都要做一次全量读写。如果进程只是偶尔等待某个阻塞事件,换出再换入的成本很可能比让它占着内存等更贵。所以在真实系统里,交换算法会尽量选择“短时间内不会运行”的进程换出,而不是随便挑一个倒霉的进程。
5. 扩展思考:覆盖、交换与分页之间的关系
5.1 为什么覆盖被淘汰,交换却演化成了分页
覆盖和交换本质上都是“时间换空间”,用更多的磁盘I/O和软件开销,换取有限内存的更大吞吐。覆盖需要程序员深入参与,在三五十年前的软件规模下可行,到了现代应用动辄千万行代码的体量,靠人工静态分析模块覆盖关系根本不现实。
交换把整个进程搬进搬出,代价太大,于是操作系统自然想到:能不能不搬整个进程,只搬进程的一部分?这个思路一路演化,最终变成了请求分页:以页为单位,按需加载、按需淘汰,内存和外存之间的数据流动粒度从整个进程缩小到页(通常4KB)。分页不仅解决了内存不足,还顺带攻克了外部碎片——页面大小固定,物理内存被分成了等长的页框,不再需要连续分配,碎片的概念被彻底改写。
5.2 连续分配在今天还有没有用
很多人可能觉得,连分配都够老了,分页才是王道。事实上,连续分配在今天依然有不可替代的场景。
嵌入式系统中,很多实时性要求高的环境依然采用静态分区甚至固定分区,因为实时系统需要可预测的分配延迟,动态分区和分页的复杂度会引入不可控抖动。
内核本身的启动阶段也普遍使用简单的连续分配:内核代码和数据在早期自举阶段往往被加载到连续的物理内存中,直到内存管理初始化完成、页表建立起来之后,才开始复杂的分配。
还有一类典型的应用是DMA。DMA传输通常要求目标内存物理连续,因为I/O设备根本不理解页表,它就要一段连续的物理地址。所以在驱动程序之间,你会看到很多“分配连续物理内存”的专属接口,这本质上就是连续分配在分页系统里的残留需求。
5.3 从碎片角度看整个内存管理的发展脉络
如果你沿着“碎片”这条线索看整个内存管理,会发现所有方案都是一环扣一环的:
- 固定分区被吐槽内部碎片,于是有了动态分区。
- 动态分区被吐槽外部碎片,于是有了紧凑算法和复杂分配策略。
- 紧凑太贵、碎片治标不治本,于是分页方案彻底放弃连续性,用固定大小的页框抹平碎片。
- 分页解决了碎片,但地址转换开销变大,于是有了TLB加速。
- 虚拟内存需要按需加载,于是结合交换思想,有了缺页中断和页置换算法。
这条线把教科书上那些看似独立的概念全部串起来了。这也是为什么我一直建议学操作系统不要背结论,最好亲手写一遍模拟器:当你自己为外部碎片头疼的时候,你就会发自内心地说出“分页真是个天才的发明”。
6. 常见问题快查与避坑心得
在实际操作、学习、面试中,围绕连续分配、覆盖、交换和碎片,有几个问题出现的频率极高,我把它们整理成一张速查表:
| 问题 | 核心原因 | 解决思路 |
|---|---|---|
| 进程明明没释放,内存却不够 | 内部碎片或已分配记录错误 | 检查固定分区剩余空间,检查分配器是否有重复分配 |
| 空闲空间总和够,进程却分配失败 | 外部碎片导致没有单一连续块 | 触发紧凑,或改用分页 |
| 释放进程后内存还是一直分配失败 | 没有做相邻空闲区合并 | 在释放例程里增加向前/向后合并 |
| 覆盖程序偶尔崩溃 | 覆盖模块之间存在隐式依赖 | 重新分析调用关系,公共数据放常驻区 |
| 交换后系统卡顿严重 | 交换太频繁,或换出对象选择不当 | 提高交换阈值,优先换出阻塞且低优先级进程 |
| 固定分区系统利用率很低 | 分区大小和进程负载不匹配 | 重新规划分区大小,增加不等长分区种类 |
我再分享几条实操心得。
第一,写动态分区分配器时,一定要先把“空闲区链表的数据结构”想清楚,而不是先写算法。链表里的每项除了起始地址和长度,最好还记录前驱、后继指针,这样合并相邻空闲区时能直接通过指针访问邻居,而不需要遍历整个链表。
第二,观察碎片时要可视化,不要只盯数字。我当初在模拟器里加了一个最简单的命令行直方图,每次分配释放后把空闲块画成一行星号,肉眼看到内存被切得东一块西一块,那种直观冲击比任何描述都强烈。如果现实场景下有条件,也用图形化工具观察内存水位,比盯着监控曲线迟钝得多。
第三,面试里如果被问到“覆盖和交换的区别”,不要只背定义,最好用一个具体例子说明。比如“覆盖是编译器自身把词法分析、语法分析复用同一块内存,交换是操作系统把一个等待中的进程整体搬到磁盘上”。带例子的回答会让考官觉得你真的理解,而不是临时背概念。
第四,真项目里遇到内存不足,第一反应不应该是优化碎片,而是先看是否有内存泄漏。我见过不止一个人花大量时间研究碎片和紧凑,最后发现是某处循环里不断申请内存却从不释放。碎片优化是性能工程的一部分,但泄漏才是合格工程师最先排查的问题。
关于连续分配、覆盖交换和存储碎片的内容,到这里基本就聊完了。最后再补充一个我自己的体会:如果你在做一个内存分配相关的模拟或实验,建议把“首次适应 + 释放时合并 + 紧凑触发条件”作为基线配置,再去对比其他策略。这个组合在大多数场景下表现稳健,它能让你在踩坑之前先建立一个正确的直觉,后续调整策略时也有了参照系。