1. 连续分配的起点:为什么内存管理偏偏从“一块整地”开始
先抛出我的结论:你要理解连续分配存储管理,必须先把“内存管理要解决的根本矛盾”放在脑子里,而不是盯着算法背步骤。这个根本矛盾就是:进程希望自己的地址空间是连续、完整、从0开始的一大块“整地”,但物理内存里同时住着多个进程,堆上的空闲区域不但被切割得七零八落,还在不断变化。连续分配就是最早、最直观、也最暴力的一种应对思路:既然进程想要整块地,那我就把整个内存划成若干块完整的“地皮”,每次给一个进程一整块,不许拆。
这个方案放在今天看确实显得“笨”,但它的重要性恰恰在“笨”里。我在学习和做操作系统实验时有个很深的体会:分页、分段、虚拟内存这些花活儿,全是在连续分配的缺陷上“打补丁”打出来的。你不把连续分配里“碎片从哪来、覆盖解决什么问题、交换付出什么代价”吃透,后面看LRU、多级页表、TLB全都在空中楼阁上建。
这篇文章我希望用做项目的思路来讲,而不是教科书式的条条罗列。假设我们要给一个模拟操作系统设计内存子系统(这种实验我相信不少人都做过),你拿到需求就三件事:第一,多个进程怎么往物理内存里塞;第二,塞进去之后,新来的进程没地方塞怎么办;第三,内存空间明明够但被切碎了,怎么把碎片重新利用起来。这三件事,正好对应连续分配、覆盖与交换、以及存储碎片处理。按这个逻辑走一遍,你会发现书上的知识点全部串起来了。
适合读这篇内容的人:正在准备操作系统考试或复试的学生、在做内存管理课程设计的开发者、以及想搞明白“内存碎片到底是怎么吃掉你十几个G内存”这类现象的后端工程师。前面基础部分我会尽量讲得通俗一些,后面会给出具体的分配算法对比和我的实操心得。
2. 连续分配的三种“圈地方式”:从大锅饭到小炒,再到点菜
连续分配本身不是一个单一方案,它内部还分了三种实现层次。很多初学者混淆这三个东西,导致做题时看到“内部碎片”“外部碎片”就对不上号。
2.1 单一连续分配:整块内存交给一个进程
这是最原始的方案:整个内存,区分成“系统区”和“用户区”。用户区一次只装一个进程。进程运行完之后,下一个进程再整个装进去。
这个方案的好处是管理成本几乎为零,不需要任何数据结构记录空闲区域。坏处也很明显:单道程序,CPU利用率低得令人发指,遇到进程要等I/O时CPU只能闲着。我在早期做课程设计时,为了省事,模型里就直接用这个方案,反正一次只跑一个进程。但实际产品里,这种方案只有在极其简单的嵌入式裸机环境里才能看见。
这里面有个概念要弄清楚,“单一”不是说物理内存必须完全被一个进程占满,而是“用户区同一时刻只有一个进程持有”。
2.2 固定分区分配:提前把内存切成不同大小的“房间”
当你想让多个进程同时在内存里待着,最简单粗暴的做法是:开机时就把内存划分成若干个大小不等的分区,这些分区的边界一辈子不变。
分区表是核心数据结构,每一条记录分区的起始地址、大小、是否空闲。进程到达时,系统去找一个满足大小要求的空闲分区,分配给它;进程结束时,把该分区标记为空闲。
这里有一个很关键的设计点,分区是固定的,所以一个进程分配到某个分区后,哪怕它的实际大小远小于分区大小,剩下的空间也浪费了。这个浪费,就叫内部碎片。比如系统划了一个64MB的分区,而进程只要30MB,剩下的34MB没人能用,就锁死在那儿了。
固定分区还有个调度层面的问题,你没法提前知道明天跑的进程谁大谁小,分区大小和个数只能按经验拍脑袋。我在模拟项目里试过:设 8MB、16MB、32MB、64MB 各两个分区,结果并发任务里出现了好几个 33MB 的请求,它们全都能挤进 64MB 分区,但 32MB 分区始终空闲。这种供需错配会让内存利用率非常难看。
2.3 动态分区分配:按需切分,碎片开始登场
动态分配不再预先划边界,而是把整个用户区看作一大块“肉”,来一个进程,就在空闲区里切一块差不多大的给它;进程结束,释放的块重新并入空闲区。听起来完美,问题随之出现:满内存跑了一会儿之后,空闲区被切成了很多细小的条条块块,每条单独看都不够新进程用,加起来又很多。这就是外部碎片。
外部碎片的本质是:空闲总容量足够,但每个空闲区域都不够大,导致无法为大请求服务。这是动态分配最核心的痛点,也是后面“交换”和“紧凑”技术登上舞台的主要原因。
我在实验里模拟过这个演进过程:固定分区下内部碎片从分配开始就恒定存在;动态分区下外部碎片随着进程反复装载而不断加剧。理解这两种碎片的区别,是读懂之后所有碎片优化方案的前提。
3. 动态分配的四种选择策略:做题时要懂的对比,写代码时要懂的取舍
动态分区虽然思路统一,但“从哪个空闲区下手”是个策略问题。教科书上通常讲四种:首次适应、循环首次适应、最佳适应、最差适应。这里不做背诵式罗列,而是从实际分配效果的维度逐个拆开。
3.1 首次适应:找最靠前的够用空闲区
从空闲区链表的头部开始查,遇到第一个容量够用的空闲区就分配。因为在低地址空间优先分配,高地址空间保留了大块空闲区,当大进程到来时更容易找到地方。
实际表现上,首次适应倾向于把内存“从前往后”填满,空闲区分布逐渐变得零零碎碎,但胜在查找速度快、实现简单,综合表现反而是四种里最好的。大多数操作系统课程实验中我推荐先用它当默认策略。它的问题是高地址的大块区域容易被小进程“浸润”,导致大分区慢慢被侵蚀成多个小块,但这通常要运行很久才会显现。
3.2 循环首次适应:从上次停下的地方继续
与首次适应的区别是,它用一个指针记录“上次查到哪里”,下次从指针位置继续找。这避免了每次从头部启动的线性开销,也让空闲区分布更均匀,每个区域都有被使用的机会。
但这个均匀是有代价的:高地址的大块空区也会被消耗,不像首次适应那样刻意保留了一块“战略缓冲”。我实测模拟数据,循环首次适应在碎片总数上比首次适应更平均,但大请求失败的场景出现得更快一些。
3.3 最佳适应:找最小够用的空闲区
把所有空闲区按大小排序,找最接近请求大小那一个。字面上很完美,但实际碎片化最严重:每次都在“恰好”的位置下刀,剩下的余量碎得像面条渣,全是没法用的小碎片。
最佳适应策略适合对内存大小有严格限制的嵌入式场景(这时候分区数量少、请求规律),不适合通用操作系统。它的理论最优和实际最差之间的反差,我每次讲给学生听都觉得特别像生活里的“完美主义陷阱”。
3.4 最差适应:找最大的空闲区
与最佳适应反过来,专门挑最大的空闲区切。这样切完剩下的余量仍然比较大,还能容纳别的进程。问题是,最大空闲区会迅速缩小,之后遇到超大进程时,内存里没有一个大块能接住它。
四种策略的对比表如下(这是我实验里统计出来的定性结论,具体数值取决于工作量分布):
| 策略 | 查找开销 | 碎片程度 | 大请求友好度 | 实现难度 |
|---|---|---|---|---|
| 首次适应 | 低 | 中 | 较好 | 低 |
| 循环首次适应 | 低 | 中 | 中 | 低 |
| 最佳适应 | 较高 | 高(大量微小碎片) | 差 | 中 |
| 最差适应 | 较高 | 较低大碎片 | 差(消耗大块过快) | 中 |
我的建议是:通用系统没特殊理由就上首次适应。不追求理论最优,稳定可靠才是生产环境的第一诉求。如果让我在写真实项目时给个最终选择,我也选首次适应。
4. 覆盖技术:程序太大装不下时的“手工内存分页”
连续分配的一个大前提是进程必须整体装入内存。如果进程体积比可用分区还大,怎么办?覆盖技术登场。
4.1 覆盖的核心思想:不常用的代码段互相顶替
覆盖技术解决问题的角度是:“一个进程并不需要在任何时刻都让全部代码和数据待在内存里。”对于一个进程,我们把它的逻辑结构划分为若干“段”,其中常驻段(比如主控制模块、核心数据)始终在内存;而一些互斥执行的大段代码,比如“登录处理”和“报表生成”,它们在正常运行中不会同时跑,就可以放在同一个覆盖区,共享一块内存,什么时候用到,再从外存调入。
这不是虚拟内存,因为程序员必须手工分析模块之间的调用关系,设计好覆盖结构。用我做过的一个模拟项目举例:程序有主程序 M、两个大模块 A 和 B,两者独立。传统加载需要 M+A+B 全部装进内存;用了覆盖后,只需要给 M 分配常驻区,再给 A 和 B 共同分配一个覆盖区,当调用 A 时把 A 调入覆盖区,调用 B 时 B 覆盖 A(或反之)。内存占用从“全部代码总和”降到了“常驻部分+最大覆盖段”。
4.2 覆盖技术的现实地位:今天已经很少用了,但思想无处不在
你会看到覆盖技术几乎没有出现在现代通用操作系统里,原因是:
- 手工划分覆盖结构极其繁琐、极易出错,程序员必须精确画出模块调用树,判断哪些函数永远不会同栈。
- 现代计算机内存容量大幅增长,装不下的场景变少了。
- 虚拟内存(请求分页)用统一的、对程序员透明的机制从根本上解决了进程大于物理内存的问题。
但覆盖的思想并未消失,只是换了形态。移动开发中的资源懒加载、大型游戏按场景分块读入地图数据、插件系统中按需加载模块,本质上都是“让一部分代码只在你需要时才占用空间”。你理解覆盖技术后,理解这类工程实践会快非常多。
4.3 在什么情况下覆盖仍值得考虑
如果是在资源受限的裸机嵌入式设备上开发,没有MMU、没有操作系统帮你做虚拟内存,而你的代码又超出RAM容量,那覆盖依然是手工可用的方案。我曾在一款低成本单片机上见过类似的代码组织:引导加载程序常驻,应用程序按功能拆成两个“段”,通过跳转表切换。虽然大家不叫它“覆盖”,但机制完全一致。
5. 交换技术:内存不够,磁盘来凑的调度策略
覆盖是同一进程内部的“组分时”,交换技术则是进程之间的“内存换出换入”。它解决的已经不是“一个进程装不装得下”,而是“内存里进程太多,新进程没有空地”。
5.1 换出换入的几个关键决策点
交换技术的流程并不复杂:内存中某个进程被选中,整个搬到外存的对换区,腾出的区域给新进程或等待中的进程;等那个被换出的进程再次具备运行条件时,再将其换回内存。但这里有几个决策必须明确:
换出哪个进程?通用策略:优先换出“阻塞的”“优先级低的”“最近未使用的”进程。阻塞进程在等待I/O,继续占用内存毫无意义,换出它最划算。如果你把正在CPU上运行的进程换出去,那纯属自找麻烦。
换回时放哪里?这里就暴露交换技术与碎片的耦合关系。被换出进程当初占用的位置,可能已经被其他进程分割占用,所以换回时必须重新使用动态分区分配算法找一块新位置。换句话说,交换技术加剧了外部碎片的产生频率。
交换的开销有多大?我算过一次账。假设内存与磁盘之间的传输速率为20MB/s(这已经是比较理想的对换设备速度),换出一个10MB的块需要0.5秒,换入再需要0.5秒,一次交换往返就是1秒整。如果换出的是运行中的高并发进程,这一秒钟的阻塞对用户来说完全不可接受。所以交换频率必须被严格控制,不能随便换。
5.2 对换区应为独立的高速区域
我特别想强调一个工程细节:对换区最好是磁盘上独立的、连续的、格式化好的区域,不要直接使用文件系统里的普通文件。文件系统路径上存在目录查找、索引节点更新、文件锁等额外开销。对换区越“裸”,换出换入的延迟就越低。
在模拟项目的实现里,我是把对换区抽象成一个按块号寻址的连续区域,写了一个向量存空闲块。这样换出时只需要找连续空闲块并把进程映像搬运过去,整个过程不经过文件系统。
5.3 交换与覆盖共存的场景
覆盖和交换并不是互斥的,它们解决的层次不同:覆盖解决“进程内代码太多”,交换解决“进程间总需求太多”。在早期的小内存系统中,这两者经常配合使用:每个进程可以先用覆盖技术瘦身到最小,再参与交换调度。你可以理解为:覆盖是给进程“减肥”,交换是给内存“腾房”。
6. 存储碎片的终极对策:从紧凑技术到现代内存池
碎片问题是连续分配的宿命。如何缓解乃至解决外部碎片?设计者们想过几条路,这里逐一分析。
6.1 紧凑技术:移动进程,把所有空闲区拼成大块
外部碎片虽然零碎,但如果把它们全部聚合到一起,总容量往往足够大。紧凑技术就是干这件事:系统选择某个时机,把内存中的所有进程往低地址方向移动,使分散的空闲区合并成一块连续区域。
代价有两点:第一,进程移动时,它的所有内存地址都变了,系统必须动态重定位。这要求硬件支持重定位寄存器,并更新所有相关指针;第二,移动过程需要拷贝大量数据,且移动期间进程都要暂停。我估算过:一个512MB的进程在内存中搬家,即使按每秒1GB的拷贝速度,也需要半秒,这期间该进程和涉及内存搬运的系统调用全部阻塞。
因此,紧凑不能频繁做,通常只在“系统有大量外部碎片且一个大进程等待分配”时触发。
6.2 内存整理策略与交换技术的配合
更现实的处理方案是把碎片问题交给“交换”消化:与其移动内存中的进程(耗时且风险高),不如把某些阻塞进程交换到磁盘,它们在磁盘上等待,原本占用的内存块就被释放。释放出来的块可能仍然分散,但至少增加了总可用量。
现代操作系统的内存管理(如Linux的页回收、内存规整机制)从某种程度上继承了这两种思路。内存规整做的就是“把页框移动到一起,合并出大的连续范围”,和紧凑技术如出一辙。所以每当我看到内核开发者讨论内存碎片问题时,都会想到连续分配存储管理里的这些老古董概念,它们从来就没有消失,只是换了更精细的实现。
6.3 碎片检测的实际工具和规避思路
做系统优化或者写低层应用时,怎么感知碎片问题?我给你两个实用方向:
一是通过接口观察内存分配失败事件。在模拟资源受限环境时,我在malloc的包装层记录分配失败时的空闲区分布,累计一段时间就能画出碎片率曲线。碎片率可以用“最大连续空闲块/总空闲字节数”衡量,数值越低,碎片越严重。
二是提前预防。通用做法是设计内存池,将对象按固定大小分类,同类的对象从对应大小的池中分配。池内部不存在大小不匹配的外部碎片,释放时直接返回到池里。内存池的思路本质上就是把“动态分区”改造成了“多个固定分区”的变体,内部碎片变得可控。
我在两个模拟项目中对比过:让同一批随机大小的进程在普通动态分区和内存池模式下反复加载退出,内存池方案的外部碎片率只有动态分区方案的几分之一,代价是池内分配时可能需要浪费一点空间换取归类简化。碎片问题上没有银弹,只有取舍。
7. 我从连续分配实验里获得的几个“血泪教训”
这部分写点实验和模拟中积累的实际经验,希望能给你省掉一些弯路。
7.1 别把“总空闲空间”当作可分配空间来判断是否可以分配一个进程
最早我在写模拟器的时候,直接判断“空闲区总和 > 请求大小”就允许分配。结果多次出现明明总空闲空间足够,但所有空闲区单独都比请求块小,导致分配失败。后来改用“按最大连续空闲块判断”,立即准确了。这是外部碎片引起的最经典误判,也是它被称作“外部”的原因——碎片在分配区域之外,你能看见它,却用不了它。
7.2 分区分配算法只影响碎片速度,不能消灭碎片
很多初学者以为换一个更聪明的分区算法就能彻底解决碎片。实际上,只要采用动态连续分配,碎片就必然出现且必然随时间恶化,区别只在于快慢和分布形态。真正从源头消灭外部碎片的是不连续分配——分页。理解连续分配的各种优化任然是有限的,这件事接受了,你对系统设计的整体认知会上升一个台阶。
7.3 覆盖不适合想做“透明”优化的程序员
覆盖需要程序员手工划分模块层级和维护调用关系,是个极其依赖人力的设计。现代项目的模块数量动辄上千,你不可能手工为它们安排覆盖区。如果有类似“减少常驻内存占用”的想法,优先考虑按需加载、插件机制,而不是覆盖技术。
7.4 交换技术要防“抖动”
交换的换入换出如果过于频繁,系统的CPU时间全耗在搬移进程上,实际进程运行时间少得可怜。这就是“抖动”的雏形(虽然抖动专业术语更多用于分页系统)。我做实验时设过很小的内存容量、把交换间隔调短,很快发现系统吞吐量骤降到接近零——所有时间都在搬运。所以交换阈值必须设置滞回区间:内存占用超过高水位线才开始换出,降到低水位线以下才停止换出。
8. 连续分配思想在现代系统中的“夹缝生存”
最后想聊聊连续分配的遗产。虽然现代通用操作系统普遍使用分页机制,但连续分配的思想并没有死亡,它以各种形态存在于工程的角落。
8.1 内核启动阶段的伙伴系统与连续物理页分配
Linux内核在启动阶段、以及后续为了满足DMA等硬件的连续性要求,仍然需要在物理内存中分配连续的页块。伙伴系统就是把空闲页按大小分组管理,分配时从最接近需求大小的组里取,释放时合并相邻页块。它的设计目标就是尽量缓解物理地址碎片,同时保持分配效率。伙伴系统的合并逻辑,本质上就是动态分区中“空闲区合并”思想的规范化、二叉化版本。
8.2 大页、连续内存分配器与虚拟化场景
在虚拟化场景里,虚拟机监控器要给客户机分配连续的大块物理内存时,对连续性要求也很高。某些实时系统要求程序关键数据在物理内存中连续存放,以避免TLB未命中带来的延迟抖动。这些场景下,连续分配从不曾被淘汰。
8.3 理解连续分配对你排查问题有什么具体帮助
如果你经常用C/C++写底层服务,同时在分析malloc报错或者进程内存异常增长,你可以往碎片方向想:进程的“常驻内存”在不断上涨,但实际分配出去的对象加起来没那么多,这往往就是因为频繁地分配释放不同大小的对象,使堆里的空闲小段增多,而malloc无法将相邻的空闲小段合并为一个大块。这种问题可以通过tcmalloc、jemalloc这类现代分配器缓解,其核心策略便包含对每个大小类别维护独立缓存、定期整理碎片。当你把连续分配存储管理中的碎片机制吃透以后,很多上层应用的内存怪象对你来说都不再神秘。
我在操作系统项目里把这些概念反复验证之后,最大的收获其实是思维上的:所谓“高级”的内存管理技术,底层都是在与这三个永恒的问题缠斗——可用的空间够不够?分布得是否连续?调度开销划不划算?理解了这个底层张力,你才能在面对任何内存管理问题时,快速定位到核心矛盾,而不是被表面的API和参数带偏。