资讯动态

操作系统内存分配全解析:连续与非连续分配机制及实战排查

发布时间:2026/10/17 1:54:03 来源:尧图企业网站定制
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项每项指向一个页框。多级页表的好处是不需要为未使用的虚拟地址空间分配页表项。大多数进程只用了虚拟地址空间的一小部分所以大部分二级页表根本不需要存在。这样大大节省了页表占用的内存。代价是地址转换需要多次访存。两级页表需要访问内存两次一级页表一次二级页表一次再加上访问目标数据一次总共三次。这太慢了。所以引入了TLBTranslation 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是主要瓶颈机械硬盘上一次随机读取大约10msSSD大约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输出能快速定位到是哪个区域在增长。这个文件很多人不知道但用起来非常顺手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑