1. 从一次内存分配翻车说起:为什么连续分配没那么简单
刚接触操作系统内存管理那会儿,我以为"连续内存分配"就是把一整块内存切给进程用,简单直接。直到有一次在模拟器里跑一个多进程场景,系统频繁报"内存不足",可实际空闲内存明明还有一大截。排查了半天才发现,问题出在外部碎片上——空闲内存被切得七零八落,单个进程要的连续空间凑不出来,系统只能干瞪眼。
这件事让我意识到,连续内存分配和非连续内存分配不是两个孤立的知识点,而是一条演进路线上的两个阶段。前者简单但脆弱,后者复杂但灵活。理解它们之间的取舍,比死记概念重要得多。
这篇文章会从实际场景出发,把连续分配的四种放置策略、碎片问题的来龙去脉、分页与分段的底层逻辑、以及多级页表为什么必须存在,一层层拆开讲清楚。适合正在学操作系统、准备面试、或者写模拟器时被内存管理卡住的人。读完之后,你至少能做到:看到一道内存分配题,知道该用哪种策略;写代码模拟时,知道页表项该放什么字段;面试被问到"为什么需要多级页表",能从头推导出来。
2. 连续内存分配:四种放置策略的实战取舍
2.1 首次适应、最佳适应、最坏适应、邻近适应到底怎么选
连续内存分配的核心思路很朴素:每个进程占一块物理地址连续的内存区域,操作系统维护一张空闲分区表或空闲分区链。当进程请求内存时,从空闲区域里找一块够大的切出去。
问题在于,空闲区域往往不止一块,选哪块?这就引出了四种经典策略:
| 策略 | 选择逻辑 | 优点 | 缺点 |
|---|---|---|---|
| 首次适应 | 从低地址开始,选第一个够大的 | 实现简单,高地址大空闲块保留 | 低地址碎片多,查找开销随碎片增加 |
| 最佳适应 | 选能满足要求的最小空闲块 | 理论上浪费最小 | 产生大量微小碎片,几乎无法再利用 |
| 最坏适应 | 选最大的空闲块切分 | 剩余块仍可能可用 | 大块很快被消耗光,后续大请求无解 |
| 邻近适应 | 从上次结束位置继续找 | 分配更均匀 | 高地址大块也被破坏 |
我实测过这四种策略在同一个请求序列下的表现。假设初始空闲块为 100KB、500KB、200KB、300KB、600KB,请求序列是 212KB、417KB、112KB、426KB。
首次适应:212KB 放进 500KB(剩 288KB),417KB 放进 600KB(剩 183KB),112KB 放进 288KB(剩 176KB),426KB 找不到够大的块,失败。
最佳适应:212KB 放进 300KB(剩 88KB),417KB 放进 500KB(剩 83KB),112KB 放进 200KB(剩 88KB),426KB 放进 600KB(剩 174KB),全部成功。
这个对比很直观:最佳适应在这个序列下反而表现最好,因为它保留了 600KB 的大块给后面的大请求。但换个序列,最佳适应就可能因为切出太多小碎片而崩盘。
注意:没有哪种策略永远最优。实际系统里,首次适应和邻近适应用得最多,因为实现开销低,且碎片分布相对可控。最佳适应听起来美好,但碎片回收时的合并逻辑如果写得不好,反而会加速内存碎片化。
2.2 碎片问题:内部碎片和外部碎片的本质区别
碎片分两种,很多人容易搞混。
外部碎片:空闲内存总量够,但不连续,凑不出一块满足请求的连续区域。这是连续分配方案的固有缺陷。比如空闲块是 50KB、30KB、40KB,进程要 100KB,总量 120KB 够,但没有一块连续的 100KB。
内部碎片:分配给进程的内存块比它实际需要的大,多出来的部分浪费在分区内部。比如进程要 95KB,系统按 2KB 对齐分配了 96KB,那 1KB 就是内部碎片。分页机制里这种碎片很常见,因为页大小固定。
解决外部碎片有两条路:一是紧凑,把已分配的内存块挪到一起,腾出连续空闲区。但紧凑需要动态重定位支持,而且移动内存块的开销很大,不是所有场景都能接受。二是非连续分配,干脆允许进程的内存分散在多个不连续的物理块里,从根上绕开连续性的约束。
我个人的经验是:如果系统对实时性要求高,紧凑操作要慎用,因为移动内存时进程必须暂停。非连续分配虽然增加了地址转换的开销,但换来了内存利用率的提升,现代通用操作系统基本都走这条路。
2.3 动态分区分配的具体实现:空闲分区链怎么维护
连续分配的实际实现通常用空闲分区链。每个空闲块有一个节点头,记录块大小和前后指针。分配时遍历链表找合适块,回收时把相邻空闲块合并。
合并逻辑是这里的关键。假设回收的块地址为addr,大小为size,需要检查:
- 前一个块是否空闲且结束地址等于
addr - 后一个块是否空闲且起始地址等于
addr + size
如果前一个块空闲,合并后新块起始地址变为前块的起始地址,大小累加。如果后一个块也空闲,继续合并。这个逻辑写起来不复杂,但边界条件容易出错,尤其是链表头和链表尾的处理。
// 空闲块结构示意 typedef struct FreeBlock { size_t start_addr; // 起始地址 size_t size; // 块大小 struct FreeBlock* prev; struct FreeBlock* next; } FreeBlock; // 回收时的合并逻辑(简化版) void free_block(FreeBlock* block) { // 检查前向合并 if (block->prev && block->prev->start_addr + block->prev->size == block->start_addr) { block->prev->size += block->size; // 从链表移除 block,保留 prev block = block->prev; } // 检查后向合并 if (block->next && block->start_addr + block->size == block->next->start_addr) { block->size += block->next->size; // 从链表移除 next } }提示:合并时一定要先检查前向再检查后向,因为前向合并会改变当前块的起始地址,影响后向合并的判断条件。这个顺序搞反了,合并就会漏掉相邻块。
3. 非连续分配的分页机制:地址转换的每一步
3.1 页表项里到底存了什么:从逻辑地址到物理地址的完整映射
分页的核心思想是把逻辑地址空间切成固定大小的页,物理内存切成同样大小的页框,页可以离散地装入任意空闲页框。地址转换靠页表完成,每个进程一张页表,页号到页框号的映射。
逻辑地址结构是:高位是页号P,低位是页内偏移W。假设页大小为 4KB,逻辑地址 32 位,那么低 12 位是偏移,高 20 位是页号。转换过程:
- 从逻辑地址中提取页号
P和偏移W - 查页表,找到第
P项,读出页框号F - 物理地址 =
F × 页大小 + W
页表项通常不止存页框号,还包含一些控制位:
| 字段 | 作用 |
|---|---|
| 页框号 | 物理块编号 |
| 有效位 | 该页是否在内存中 |
| 访问位 | 是否被访问过,供置换算法参考 |
| 修改位 | 是否被写过,决定换出时是否需要写回 |
| 保护位 | 读/写/执行权限 |
我写模拟器时踩过一个坑:页表项的有效位没初始化,导致访问未加载的页时没有触发缺页异常,程序直接读到了垃圾数据。后来养成习惯,页表创建时所有有效位清零,加载页时才置 1。
3.2 页大小怎么定:4KB不是随便选的
页大小是分页系统里最关键的参数之一,它直接影响内部碎片、页表大小和地址转换开销。
页太小:页表项数量暴增,页表占用内存大,地址转换时查表次数多。页太大:内部碎片增加,最后一页平均浪费半个页大小。
假设页大小为S,进程平均大小为P,那么内部碎片期望值约为S/2。如果S = 4KB,平均每个进程浪费 2KB。如果S = 64KB,平均浪费 32KB,对于大量小进程来说不可接受。
页表大小 = 页表项数 × 每项大小 =(逻辑地址空间 / 页大小) × 每项字节数。32 位地址空间、4KB 页、每项 4 字节,页表大小 =2^20 × 4B = 4MB。每个进程 4MB 页表,100 个进程就是 400MB,光页表就吃掉大量内存。
这就是为什么 4KB 成为事实标准:它在页表大小和内部碎片之间取了一个平衡点。但 64 位系统下,即使 4KB 页,页表也会大到无法接受,所以必须引入多级页表。
3.3 快表TLB:为什么地址转换不能每次都查内存
分页带来一个严重问题:每次访存需要两次内存访问,一次查页表,一次取数据。如果页表本身也在内存里,性能直接减半。
解决方案是快表,也叫TLB,是一块高速缓存,存放最近使用的页表项。地址转换时先查 TLB,命中则直接得到页框号,不命中才查内存中的页表,并把结果填入 TLB。
TLB 命中率对性能影响极大。假设 TLB 查找耗时 10ns,内存访问 100ns:
- 命中时:10ns + 100ns = 110ns
- 不命中时:10ns + 100ns(查页表)+ 100ns(取数据)= 210ns
如果命中率 98%,平均访问时间 =0.98 × 110 + 0.02 × 210 = 112ns。如果命中率降到 90%,平均时间 =0.9 × 110 + 0.1 × 210 = 120ns。命中率每降一点,性能就掉一截。
TLB 通常用相联存储器实现,支持并行查找。它按内容寻址,不是按地址,所以查找速度快。但容量有限,一般几十到几百项,所以置换算法很重要,常用 LRU。
注意:进程切换时 TLB 必须刷新或标记,否则新进程可能用到旧进程的页表项,导致地址转换错误。有些架构用 ASID(地址空间标识符)来区分不同进程的 TLB 项,避免每次切换都全量刷新。
4. 分段与段页式:更贴近程序逻辑的划分方式
4.1 分段为什么按逻辑单元切:代码段、数据段、栈段的实际意义
分页是物理层面的划分,对用户透明,一个函数可能被切到两页里。分段是逻辑层面的划分,按程序的自然结构切:主程序段、子程序段、数据段、栈段。每个段有段名和段号,段内地址连续,段间不要求连续。
逻辑地址结构是:段号S+ 段内偏移W。段表每项记录段基址和段长。地址转换时先查段表,检查偏移是否越界,然后基址加偏移得到物理地址。
分段的好处很直观:
- 逻辑清晰:代码段只读、数据段可读写、栈段可增长,权限管理自然
- 共享方便:多个进程可以共享同一个代码段,只要段表指向同一物理区域
- 保护灵活:段级保护,越界检查在段表项里完成
但分段也有外部碎片问题,因为段大小不固定,和连续分配一样会面临空闲区碎片化。
4.2 段页式结合:先分段再分页的地址转换流程
段页式是两者的结合:先把程序按逻辑分段,每个段再分页。逻辑地址变成三段:段号S+ 页号P+ 页内偏移W。
地址转换需要三次访存:
- 查段表,找到该段的页表起始地址
- 查页表,找到页框号
- 访问物理内存取数据
加上 TLB 后,如果 TLB 命中,可以跳过前两步。但 TLB 需要同时缓存段号和页号信息,实现更复杂。
段页式的优点是兼顾了逻辑清晰和内存利用率,缺点是地址转换开销大、硬件支持复杂。实际系统里,纯分段用得少,纯分页和段页式更常见。x86 架构早期用段页式,后来 64 位模式下段机制基本被弱化,以分页为主。
4.3 分段和分页的对比:什么时候用哪种
| 维度 | 分页 | 分段 |
|---|---|---|
| 划分依据 | 物理块大小固定 | 逻辑单元大小可变 |
| 对用户 | 透明 | 可见 |
| 碎片类型 | 内部碎片 | 外部碎片 |
| 地址空间 | 一维 | 二维(段号+偏移) |
| 共享保护 | 页级,较粗 | 段级,较细 |
| 典型用途 | 通用内存管理 | 逻辑模块划分 |
我个人的判断标准:如果系统追求内存利用率和管理简单,选分页;如果需要精细的逻辑保护和共享,选分段;两者都要,就上段页式,但要做好性能开销的心理准备。
5. 多级页表:64位系统下页表爆炸的解法
5.1 一级页表在64位下为什么不可行
32 位系统下,4KB 页、4 字节页表项,页表大小 4MB,还能接受。64 位系统下,逻辑地址空间是2^64,即使只考虑实际使用的 48 位地址空间,页表项数 =2^48 / 2^12 = 2^36,每项 8 字节,页表大小 =2^36 × 8B = 512GB。每个进程 512GB 页表,完全不现实。
而且进程实际使用的地址空间往往很稀疏,大部分页表项是空的。一级页表强制为整个地址空间分配连续页表,浪费极其严重。
5.2 二级页表怎么省内存:外层页表按需分配
二级页表把页号再切一刀:外层页号 + 内层页号。外层页表指向内层页表,内层页表指向页框。
以 32 位地址、4KB 页为例,页号 20 位,切成 10 位外层 + 10 位内层。外层页表有2^10 = 1024项,每项指向一个内层页表。内层页表也有 1024 项。
关键点:内层页表可以按需创建。如果进程只用了低地址的几 MB 空间,只有对应的几个内层页表需要分配,外层页表里其余项置空。这样页表占用从 4MB 降到几十 KB。
地址转换变成:外层页号查外层页表 → 得到内层页表地址 → 内层页号查内层页表 → 得到页框号 → 加偏移。多了一次访存,但 TLB 命中时可以抵消。
5.3 多级页表的访存次数和TLB的补偿作用
二级页表不命中 TLB 时需要三次访存:外层页表、内层页表、数据。三级页表四次,四级页表五次。访存次数随级数线性增长。
TLB 在这里的作用更加关键。如果 TLB 命中率高,多级页表的额外开销几乎被完全掩盖。实测数据:TLB 命中率 99% 时,二级页表和一级页表的平均访存时间差距不到 5%。
提示:多级页表的级数不是越多越好。级数增加,页表占用减少,但访存次数增加。实际系统里,32 位通常用二级,64 位用四级(如 x86-64 的 PML4 → PDPT → PD → PT)。选择级数时,要在页表内存开销和地址转换开销之间找平衡。
5.4 反置页表:另一种思路
反置页表不走"每个进程一张页表"的路子,而是整个系统一张页表,每个物理页框对应一项,记录哪个进程的哪个虚拟页占用了它。
页表项数 = 物理页框数,而不是虚拟页数。物理内存 4GB、页 4KB,页框数 =2^20,页表项 100 万左右,每项 8 字节,总共 8MB。相比每个进程 512GB 的页表,节省巨大。
但反置页表的缺点是查找慢。给定虚拟页号,需要搜索整张表找到对应项,不能直接索引。通常用哈希表加速,但哈希冲突又带来新问题。而且进程共享页时,一项只能记录一个进程,需要额外结构处理。
反置页表在实际系统里用得不多,但它的思路值得了解:用物理页框数而不是虚拟页数来决定页表规模,在虚拟地址空间远大于物理内存时很有优势。
6. 写内存分配模拟器时踩过的坑
6.1 碎片统计口径不一致导致的数据对不上
第一次写模拟器时,我统计外部碎片用的是"空闲块中无法满足当前请求的块大小之和",但回收时合并逻辑有 bug,导致空闲块链表里存在相邻但未合并的块。结果碎片统计偏大,和预期对不上。
排查过程:打印每次分配和回收后的空闲链表,逐块对比地址和大小。发现两个相邻块[0x1000, 0x2000)和[0x2000, 0x3000)没有合并,原因是回收时只检查了前向合并,后向合并的条件写错了——用了block->start_addr + block->size == block->next->start_addr,但block->size在合并前向块后已经变了,导致后向判断失效。
修复方案:先保存原始块的起始地址和大小,再做前向合并,最后用保存的值做后向判断。或者调整顺序,先做后向再做前向。
6.2 页表项有效位没初始化引发的随机崩溃
页表模拟里,我用数组表示页表,但创建时没有清零有效位。程序运行时,访问某些页时有效位恰好是随机值 1,系统以为页在内存里,直接去读页框号,结果页框号也是垃圾值,访问了非法物理地址,程序崩溃。
这个 bug 的隐蔽性在于:它不总是触发,取决于内存分配时数组的初始内容。有时候跑十次才崩一次,很难复现。
修复方案:页表创建时用memset清零,或者显式遍历所有项置有效位为 0。加载页时再置 1。这个习惯后来一直保留,任何位图结构创建时先清零。
6.3 TLB模拟中命中率统计的偏差
TLB 模拟时,我用一个固定大小的数组加 LRU 置换。统计命中率时发现数值偏高,和理论计算对不上。检查后发现,我在 TLB 查找时,如果命中,没有更新 LRU 顺序,导致某些项长期不被置换,人为提高了命中率。
修复:每次命中后,把该项移到 LRU 链表头部。不命中时,从尾部淘汰,新项插入头部。这样命中率统计才准确。
提示:TLB 模拟里,LRU 的更新时机很关键。命中不更新,等于用了 FIFO 策略,命中率会失真。写模拟器时,置换策略的每个细节都要和理论定义对齐,否则统计数据没有参考价值。
7. 面试和实际工作中怎么用这些知识
7.1 常见面试题的推导思路
面试问"为什么需要多级页表",不要直接背答案。从一级页表的大小算起:64 位系统、4KB 页、8 字节页表项,一级页表 512GB,不可行。然后引出稀疏地址空间的特点,说明大部分页表项是空的,按需分配内层页表可以大幅节省。最后提 TLB 补偿访存开销。这个推导链条完整,面试官能看出你是真理解。
问"分页和分段有什么区别",从划分依据、碎片类型、地址空间维度、共享保护粒度四个角度对比,每个角度给一个具体例子。比如共享保护:分页共享以页为单位,一个函数跨两页时共享麻烦;分段共享以段为单位,代码段整体共享,更自然。
问"页面置换算法",FIFO、LRU、Clock、OPT 要能说出各自优缺点和适用场景。FIFO 实现简单但可能置换常用页;LRU 理论好但硬件实现开销大;Clock 是 LRU 的近似,实际系统常用;OPT 是理论最优,无法实现,只做基准。
7.2 实际系统调优中的内存管理视角
实际工作中,理解内存分配机制对性能调优很有帮助。比如发现程序频繁缺页,可以检查是不是工作集太大,超出了物理内存;发现 TLB 命中率低,可以调整数据结构,让热点数据集中在少数页里;发现内存碎片严重,可以考虑用内存池预分配,减少动态分配次数。
大页(Huge Page)是另一个实用技巧。把页大小从 4KB 提到 2MB 或 1GB,页表项数大幅减少,TLB 覆盖范围扩大,命中率提升。数据库和虚拟化场景里,大页能带来明显的性能收益。但大页的内部碎片也更大,适合内存占用大、对碎片不敏感的应用。
我个人的经验:内存管理知识不是背出来的,是在排查问题和写模拟器的过程中慢慢内化的。每次遇到缺页异常、内存碎片、地址转换错误,回头翻这些原理,都会有新的理解。建议你也动手写一个简单的分页模拟器,把页表、TLB、缺页处理都实现一遍,比看十遍书都管用。