☰
操作系统内存分配全解析:连续与非连续分配机制及实战排查
2026/10/17 1:54:02 网站建设 项目流程

1. 为什么内存分配值得单独拎出来讲

1.1 从一个真实踩坑场景说起

早些年我在做一个嵌入式数据采集项目时,遇到过一件很典型的事:设备跑了两天,突然就崩了,日志里没有任何异常,堆栈也看不出问题。排查了很久才发现,是内存碎片导致的——系统连续运行过程中,频繁申请和释放不同大小的内存块,最后虽然总空闲内存还有不少,但没有任何一块足够大来满足一次新的分配请求。

这个问题让我重新认真梳理了一遍操作系统的内存分配机制。很多开发者平时写代码,malloc和free用得很顺手,但一旦问到“操作系统到底怎么把物理内存分配给进程的”“为什么有时候内存明明够用却分配失败”,就答不上来了。而这部分知识,恰恰是排查内存相关问题、理解性能瓶颈、甚至做系统调优的基础。

这篇文章围绕连续内存分配和非连续内存分配两条主线展开,把操作系统内存管理的核心逻辑拆开来讲。不管你是正在准备操作系统考试的学生,还是工作中需要处理内存问题的开发者,或者只是对底层机制好奇的技术爱好者,都能从中拿到可以直接用的知识。

1.2 两种分配方式的核心差异

先给一个直观的对比,让你在脑子里有个框架:

对比维度连续内存分配非连续内存分配
物理内存布局进程占用一段连续物理地址进程可分散在多个不连续区域
典型代表固定分区、动态分区分页、分段、段页式
碎片问题外部碎片严重内部碎片(分页)或外部碎片(分段)
地址转换基址+偏移,简单需要页表/段表,复杂
内存利用率较低较高
硬件支持基址寄存器、界限寄存器MMU、TLB

连续分配的逻辑很朴素:给进程分配一块完整的、地址连续的内存区域。就像你去停车场停车,管理员要求你必须停在一个连续的车位上,不能这辆车的车头在A区、车尾在B区。非连续分配则灵活得多,进程可以被拆成若干块,分散放在物理内存的不同位置,通过地址映射机制让进程“以为”自己是连续的。

理解了这张表,后面的内容就有了锚点。

2. 连续内存分配:朴素但问题不少

2.1 固定分区分配的工作方式

固定分区是最早、最简单的一种方案。操作系统在启动时就把内存划分成若干个固定大小的区域,每个区域装一个进程。

具体做法是:系统维护一张分区说明表,记录每个分区的起始地址、大小和分配状态。当有进程需要内存时,系统从表中找一个足够大的空闲分区分配给它。

这种方案的问题很明显。假设内存被分成四个分区:100MB、200MB、300MB、400MB。现在来了一个需要250MB的进程,虽然总空闲空间有1000MB,但没有任何一个分区能装下它,只能等着。这就是内部碎片——分区内部被浪费的空间。

注意:固定分区分配在现代通用操作系统中基本已经淘汰,但在一些实时嵌入式系统中仍然能看到它的影子,因为它的行为可预测,适合对确定性要求极高的场景。

2.2 动态分区分配与四种经典算法

动态分区分配不再预先划分固定区域,而是根据进程的实际需求动态地切割内存。系统维护一个空闲分区链或空闲分区表,记录所有可用的内存块。

当进程请求内存时,分配算法从空闲分区中选一个合适的进行分配。经典算法有四种:

首次适应算法(First Fit):从空闲分区链的头部开始扫描,找到第一个大小满足要求的分区就分配。优点是速度快,缺点是低地址部分容易被反复切割,留下大量小碎片。

最佳适应算法(Best Fit):遍历整个空闲分区链,找到能满足要求且最小的那个分区。听起来很合理,但实际上会产生大量难以利用的微小碎片,因为每次分配都把分区切得刚刚好。

最坏适应算法(Worst Fit):每次都选最大的空闲分区来分配。这样剩下的空闲块仍然比较大,不容易产生小碎片。但缺点是大的空闲分区很快就被消耗光了,后续大请求无法满足。

循环首次适应算法(Next Fit):从上次分配的位置继续往后找,而不是每次都从头开始。这样空闲分区的分布更均匀,但同样会产生碎片。

我用一个具体例子来说明这几种算法的差异。假设空闲分区链为:[100KB, 500KB, 200KB, 300KB, 600KB],现在有一个进程需要分配250KB。

  • 首次适应:选500KB那个,剩下250KB空闲
  • 最佳适应:选300KB那个,剩下50KB空闲
  • 最坏适应:选600KB那个,剩下350KB空闲
  • 循环首次适应:取决于上次分配位置,可能选500KB或300KB或600KB

实测下来,首次适应和最佳适应在实际系统中用得最多,但最佳适应在长期运行后碎片化问题更严重。很多教材推荐首次适应,因为它的综合表现最均衡。

2.3 外部碎片与紧凑技术

动态分区分配最大的敌人是外部碎片——空闲内存总量足够,但被分割成太多不连续的小块,无法满足大请求。

解决外部碎片有两种思路:

第一种是紧凑(Compaction)。操作系统移动进程在内存中的位置,把所有空闲块合并成一个大块。这就像停车场管理员把所有车都挪到一边,腾出一整片连续空位。但紧凑的代价很大:需要移动大量数据,而且移动过程中进程必须暂停,对实时系统来说不可接受。另外,紧凑还需要硬件支持动态重定位,否则进程移动后地址就全乱了。

第二种是换出(Swap)。把某些进程暂时写到磁盘上,腾出内存空间。这涉及到进程的挂起和恢复,开销更大,但可以解决内存总量不足的问题。

实操心得:在嵌入式系统中,如果发现系统运行一段时间后内存分配失败,但总空闲内存看起来还够,大概率就是外部碎片问题。这时候可以考虑在系统空闲时主动做一次内存整理,或者改用内存池方案来规避碎片。

2.4 连续分配的地址转换机制

连续分配中,进程的虚拟地址到物理地址的转换非常简单:物理地址 = 基址寄存器 + 虚拟地址。

系统为每个进程维护一对寄存器:基址寄存器存放进程在物理内存中的起始地址,界限寄存器存放进程的长度。每次访问内存时,硬件先检查虚拟地址是否小于界限寄存器的值,如果越界就触发异常,否则加上基址得到物理地址。

这种机制的好处是快,一次加法和一次比较就完成了地址转换,不需要查表。但缺点是进程必须占用连续物理内存,灵活性差。

3. 非连续内存分配:用映射换灵活

3.1 分页机制的核心原理

分页是现代操作系统最主流的非连续分配方案。它的核心思想是:把物理内存划分成固定大小的页框(Frame),把进程的虚拟地址空间划分成同样大小的页(Page),然后通过页表建立页到页框的映射关系。

进程的虚拟地址被拆成两部分:页号和页内偏移。假设页面大小为4KB,虚拟地址32位,那么低12位是页内偏移,高20位是页号。地址转换时,用页号去查页表,得到对应的物理页框号,再拼接页内偏移,就得到了物理地址。

页表本身也是存在内存中的,每个进程一张。页表项通常包含:物理页框号、有效位、访问位、修改位、保护位等。

分页的好处很明显:进程不需要连续物理内存,物理内存的分配以页框为单位,管理简单。而且分页没有外部碎片,因为所有页框大小相同,任何空闲页框都能被任何页使用。

但分页引入了内部碎片。如果进程最后一页没有用完,剩余空间就浪费了。平均来说,每个进程浪费半页。4KB页面的话,就是2KB左右。对于大页面(比如2MB),内部碎片可能很严重。

3.2 页表的大小问题与多级页表

页表本身也要占内存。32位系统、4KB页面、4字节页表项,一个进程的页表就是2^20 * 4B = 4MB。如果有100个进程,光页表就占了400MB,这显然不可接受。

解决方案是多级页表。把页号再拆分,比如32位地址拆成10位一级页号、10位二级页号、12位偏移。一级页表有1024项,每项指向一个二级页表。二级页表也有1024项,每项指向一个页框。

多级页表的好处是:不需要为未使用的虚拟地址空间分配页表项。大多数进程只用了虚拟地址空间的一小部分,所以大部分二级页表根本不需要存在。这样大大节省了页表占用的内存。

代价是地址转换需要多次访存。两级页表需要访问内存两次(一级页表一次,二级页表一次),再加上访问目标数据一次,总共三次。这太慢了。

所以引入了TLB(Translation Lookaside Buffer),也叫快表。TLB是一个小型的硬件缓存,存放最近使用的页表项。地址转换时先查TLB,命中就直接得到物理页框号,不需要访问内存中的页表。TLB通常只有几十到几百个条目,但命中率可以很高,因为程序访问具有局部性。

注意:TLB命中率对性能影响极大。如果TLB频繁未命中,每次都要走多级页表,性能会急剧下降。这也是为什么大页面(Huge Page)在高性能场景中很受欢迎——一个2MB的大页面覆盖的地址范围相当于512个4KB页面,能大幅减少TLB条目需求。

3.3 分段机制与段页式结合

分段是另一种非连续分配方式。它按照程序的逻辑结构来划分地址空间,比如代码段、数据段、堆栈段等。每个段有独立的段号和段内偏移,段表记录每个段的基址和长度。

分段的好处是符合程序员的视角,便于共享和保护。比如代码段可以设置为只读,多个进程可以共享同一个代码段。但分段会产生外部碎片,因为段的大小不固定。

段页式结合了两者的优点:先按逻辑分段,每个段再分页。地址转换需要查段表和页表,一次访存需要三次内存访问(段表、页表、数据),加上TLB才能实用。

段页式的地址结构是:段号 + 页号 + 页内偏移。系统为每个进程维护一个段表,每个段表项指向该段的页表。这种方案在逻辑上最清晰,但硬件实现最复杂,现代通用操作系统(如Linux)实际上主要用分页,分段只保留了最小限度的支持。

3.4 页面置换算法:内存不够时怎么办

当物理内存不足时,操作系统需要把某些页面换出到磁盘,腾出页框给新的页面。选择换出哪个页面,就是页面置换算法要解决的问题。

最佳置换算法(OPT):换出未来最长时间不会被访问的页面。这是理论上的最优解,但需要预知未来,无法实现,通常作为衡量其他算法的基准。

先进先出(FIFO):换出最早进入内存的页面。实现简单,但可能换出经常使用的页面,性能差。而且FIFO有Belady异常——增加页框数反而导致缺页率上升。

最近最久未使用(LRU):换出最长时间没有被访问的页面。基于程序局部性原理,效果接近OPT。但精确实现LRU需要维护访问顺序,硬件开销大。实际系统中常用近似LRU,比如Clock算法。

Clock算法:把页面组织成环形链表,每个页面有一个访问位。指针扫描时,如果访问位为1就置0并跳过,如果为0就换出。这是LRU的廉价近似,在实际系统中广泛使用。

算法实现复杂度性能是否实用
OPT无法实现最优仅作基准
FIFO低差少用
LRU高好近似实现
Clock中较好广泛使用

实操心得:在Linux系统上,可以通过/proc/sys/vm/swappiness参数调整页面换出的倾向。值越高,系统越倾向于换出匿名页;值越低,越倾向于保留内存中的页面。对于数据库服务器这类对延迟敏感的场景,通常建议把这个值调低。

4. 从虚拟地址到物理地址:完整转换流程

4.1 一次访存的完整路径

把前面的知识串起来,看看一次内存访问到底经历了什么:

  1. CPU发出虚拟地址
  2. MMU检查TLB,如果命中,直接得到物理页框号
  3. 如果TLB未命中,MMU查询页表(多级页表需要多次访存)
  4. 如果页表项无效(页面不在内存),触发缺页异常
  5. 操作系统处理缺页异常,从磁盘加载页面,更新页表
  6. 重新执行导致缺页的指令
  7. 得到物理地址后,访问实际数据

这个过程涉及硬件(MMU、TLB)和软件(操作系统缺页处理)的紧密配合。任何一个环节出问题,都会导致性能下降或程序崩溃。

4.2 缺页异常的处理成本

缺页异常的处理开销很大。一次缺页需要:陷入内核、保存上下文、查找页面在磁盘上的位置、发起磁盘I/O、等待I/O完成、更新页表、恢复上下文。磁盘I/O是主要瓶颈,机械硬盘上一次随机读取大约10ms,SSD大约0.1ms。相比之下,一次内存访问大约100ns。差了六个数量级。

所以,减少缺页异常是内存管理优化的核心目标之一。方法包括:增加物理内存、优化页面置换算法、使用预取技术、调整页面大小等。

4.3 页面大小的权衡

页面大小是一个需要仔细权衡的参数:

  • 页面太小:页表项太多,页表占用内存大,TLB覆盖范围小,缺页频繁
  • 页面太大:内部碎片严重,I/O传输效率可能下降

典型值是4KB。但在某些场景下,大页面更有优势。比如数据库的大缓冲区、虚拟机的内存映射,使用2MB或1GB的大页面可以减少TLB未命中,提升性能。

Linux提供了透明大页(THP)机制,自动把连续的4KB页面合并成2MB大页。可以通过/sys/kernel/mm/transparent_hugepage/enabled来控制其行为。

5. 常见问题与排查技巧实录

5.1 内存分配失败但内存充足

这是最典型的碎片问题。排查思路:

  1. 查看/proc/buddyinfo,了解不同阶的空闲块数量
  2. 如果低阶块很多但高阶块很少,说明碎片严重
  3. 可以尝试触发内存紧凑:echo 1 > /proc/sys/vm/compact_memory
  4. 长期方案是使用内存池或调整分配策略

5.2 系统频繁换页导致卡顿

现象是系统响应变慢,磁盘I/O飙升。排查方法:

  1. 用vmstat查看si和so列,如果持续非零,说明在频繁换页
  2. 用sar -B查看缺页率
  3. 检查swappiness设置是否过高
  4. 考虑增加物理内存或优化应用程序的内存使用

5.3 TLB未命中导致的性能下降

对于高性能计算场景,TLB未命中可能是隐藏的性能杀手。排查方法:

  1. 用perf stat查看dTLB-load-misses和dTLB-store-misses
  2. 如果未命中率很高,考虑使用大页面
  3. 优化数据结构的访问局部性,减少跨页访问

5.4 常见问题速查表

问题现象可能原因排查命令解决方向
分配失败但内存够外部碎片/proc/buddyinfo内存紧凑、内存池
系统卡顿、I/O高频繁换页vmstat、sar -B增加内存、调swappiness
性能突然下降TLB未命中perf stat大页面、优化局部性
进程被OOM杀死内存超限dmesg、/var/log/messages限制内存、优化程序
页表占用内存过大多级页表未生效/proc/meminfo检查页表配置

注意:排查内存问题时,不要只看总空闲内存。free命令输出的available列才是真正可用的内存估计,它考虑了可回收的缓存和缓冲区。

6. 实际项目中的选型建议

6.1 什么场景适合连续分配思路

虽然现代操作系统主要用非连续分配,但连续分配的思想在某些场景下仍然适用:

  • 嵌入式实时系统:行为可预测,适合固定分区或静态分配
  • 内存池:预先分配一大块连续内存,自己管理分配和释放,避免系统级碎片
  • DMA缓冲区:某些硬件要求缓冲区物理地址连续

6.2 非连续分配的调优要点

对于使用分页机制的系统,调优可以从这几个方向入手:

  • 合理设置页面大小,平衡页表开销和内部碎片
  • 启用大页面减少TLB未命中
  • 调整页面置换策略,匹配应用特点
  • 监控缺页率和TLB命中率,及时发现瓶颈

6.3 一个实际的内存池实现思路

在嵌入式项目中,我常用一种简单的内存池方案来规避碎片:

#define POOL_SIZE (1024 * 1024) #define BLOCK_SIZE 256 #define BLOCK_NUM (POOL_SIZE / BLOCK_SIZE) static uint8_t memory_pool[POOL_SIZE]; static uint8_t block_used[BLOCK_NUM]; void *pool_alloc(void) { for (int i = 0; i < BLOCK_NUM; i++) { if (!block_used[i]) { block_used[i] = 1; return &memory_pool[i * BLOCK_SIZE]; } } return NULL; } void pool_free(void *ptr) { int index = ((uint8_t *)ptr - memory_pool) / BLOCK_SIZE; if (index >= 0 && index < BLOCK_NUM) { block_used[index] = 0; } }

这个方案把内存划分成固定大小的块,分配和释放都是O(n)的简单扫描。虽然效率不是最优,但完全避免了碎片问题,行为可预测。对于块大小固定的场景,可以把扫描改成位图或空闲链表,效率会更高。

我在实际使用中发现,内存池的块大小选择很关键。如果块太小,大对象分配不了;如果块太大,小对象浪费严重。通常的做法是维护多个不同块大小的内存池,根据请求大小选择合适的池。

6.4 监控与告警的落地建议

生产环境中,内存问题最好在爆发前发现。建议监控这些指标:

  • 系统级:空闲内存、可用内存、换页速率、缺页率
  • 进程级:RSS、虚拟内存大小、缺页次数
  • 硬件级:TLB未命中率、缓存未命中率

设置合理的告警阈值,比如换页速率持续超过某个值就告警。这样可以在问题影响用户之前介入处理。

最后再分享一个小技巧:如果你在Linux上做内存相关的开发,/proc/[pid]/smaps文件能提供非常详细的进程内存映射信息,包括每个映射区域的RSS、PSS、共享内存等。排查内存泄漏时,对比不同时间点的smaps输出,能快速定位到是哪个区域在增长。这个文件很多人不知道,但用起来非常顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询