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 一次访存的完整路径
把前面的知识串起来,看看一次内存访问到底经历了什么:
- CPU发出虚拟地址
- MMU检查TLB,如果命中,直接得到物理页框号
- 如果TLB未命中,MMU查询页表(多级页表需要多次访存)
- 如果页表项无效(页面不在内存),触发缺页异常
- 操作系统处理缺页异常,从磁盘加载页面,更新页表
- 重新执行导致缺页的指令
- 得到物理地址后,访问实际数据
这个过程涉及硬件(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 内存分配失败但内存充足
这是最典型的碎片问题。排查思路:
- 查看
/proc/buddyinfo,了解不同阶的空闲块数量 - 如果低阶块很多但高阶块很少,说明碎片严重
- 可以尝试触发内存紧凑:
echo 1 > /proc/sys/vm/compact_memory - 长期方案是使用内存池或调整分配策略
5.2 系统频繁换页导致卡顿
现象是系统响应变慢,磁盘I/O飙升。排查方法:
- 用
vmstat查看si和so列,如果持续非零,说明在频繁换页 - 用
sar -B查看缺页率 - 检查
swappiness设置是否过高 - 考虑增加物理内存或优化应用程序的内存使用
5.3 TLB未命中导致的性能下降
对于高性能计算场景,TLB未命中可能是隐藏的性能杀手。排查方法:
- 用
perf stat查看dTLB-load-misses和dTLB-store-misses - 如果未命中率很高,考虑使用大页面
- 优化数据结构的访问局部性,减少跨页访问
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输出,能快速定位到是哪个区域在增长。这个文件很多人不知道,但用起来非常顺手。