资讯动态

操作系统内存管理全景:分页、虚拟内存、缺页与内核分配器解析

发布时间:2026/10/17 8:58:08 来源:尧图企业网站定制
如果你也是从“用户态写代码”渐入到“操作系统内部机制”这条路上来的大概率会有同感进程调度还能靠直觉理解成“排队干活”文件系统也勉强能想象成“柜子里存东西”唯独内存管理这一章名词多、抽象层级高、概念环环相扣经常看到一半就卡住。可它偏偏又是操作系统的地基之一页表、虚拟地址、物理地址、缺页中断、置换算法这些东西不弄清楚后面的并发、性能调优、内核模块开发全都悬在半空。这篇文章我想把自己重新梳理内存管理这条线的思路完整写一遍。适合正在学操作系统课程、准备相关面试、或者写应用时被“内存暴涨”“频繁GC和Swap”折磨过的开发者参考。我不打算按教科书的顺序平铺直叙而是从“内存管理到底在解决什么问题”出发把地址翻译、分页、虚拟内存、内核分配器这些串成一条线最后落到实际排查问题时怎么用这些知识。1. 内存管理到底在管些什么先建立整体图景刚接触内存管理时最容易犯的错是把它当成一长串孤立知识点的集合比如“要背页表结构”“要记LRU算法”。但如果你先退一步从最朴素的问题出发一切都变得有逻辑了。1.1 三个绕不开的现实问题第一个问题是地址空间隔离。多道程序同时跑在一台机器上如果大家都能直接访问物理内存A进程完全可以改掉B进程的数据甚至改掉操作系统的内核代码。这不是安全性问题而是基本的正确性崩溃。所以必须让每个进程活在自己的“虚拟地址空间”里看起来自己独占一整块内存实际访问时由硬件和操作系统共同做翻译。第二个问题是内存容量与碎片。物理内存就那么大几十个进程要分怎么分才不浪费连续分配会导致外部碎片进程反复加载和退出后内存中出现大量零散的小空洞每个洞都不够用加起来却很大。这个问题直接导向了分页。第三个问题是程序工作集大于物理内存。一个进程的代码段、堆、栈、映射的共享库可能比物理内存还大。如果必须全塞进去才能运行那很多程序根本跑不起来。虚拟内存把“可寻址空间”和“实际占用的物理空间”解耦用得上的页面才进内存用不上的留在磁盘里。这三点合起来就是内存管理的核心职责为每个进程提供一个独立、连续、足够大的虚拟地址空间再把虚拟空间按页映射到有限的物理内存中并负责换入换出。1.2 从MMU到操作系统谁在干活地址翻译这件事不是纯软件能扛住的每次内存访问都去查一个软件维护的大表性能会慢到没法用。所以现代CPU里都有一个专门硬件单元叫MMUMemory Management Unit它配合页表来做虚拟地址到物理地址的快速翻译。MMU负责查页表、做权限检查、触发缺页异常。操作系统负责建造页表、维护映射关系、处理缺页、决定换出哪个页面。TLBTranslation Lookaside Buffer是MMU内部的高速缓存缓存最近用过的地址翻译结果。理解这个分工非常重要。后面遇到“为什么这个进程page fault这么多”“为什么TLB miss会导致性能下降”这类问题根源都在这里硬件站在最前线做高频翻译软件只在异常和低频但关键的时刻介入。2. 地址翻译这条主线逻辑地址怎么变成物理地址很多教材讲到这里就直接上分页我建议先补一段“没有分页时世界是什么样的”。因为你只有理解连续分配时代的问题才能真正接受分页设计里的每一个细节而不是死记硬背。2.1 连续分配时代的困境早期的内存分配确实就是“整块给”。每次加载程序时找出一个足够大的连续空闲区把程序整个装进去。这块空闲区怎么找就是几个经典算法首次适应First Fit、最佳适应Best Fit、最差适应Worst Fit。从实际效果看首次适应因为偏向低地址分配速度最快高地址区能留下大块空闲最佳适应理论上能把碎片减到最小但代价是查找成本高而且容易留下大量难以利用的极小残留块最差适应相反每次都从最大的洞割一块出来尽量让剩余空间还能用但会快速把大块空闲切碎。没有一个算法能根除外部碎片。那能不能用一个办法让程序不用连续内存两个方向一个是把程序本身拆开装到不连续的区域这就是分页另一个是让程序“以为”自己连续靠运行时重定位来修正地址这是动态重定位。2.2 动态重定位与基址寄存器稍微具体说一下动态重定位因为它体现了处理器的“懒人智慧”。思路是给每个进程配一对硬件寄存器基址base和界限limit。程序里的地址都当作相对地址CPU访问内存时由硬件自动加上基址寄存器的值再检查是否超出界限。这个机制好处是进程在物理内存中可以整体移动移动时只需修改基址寄存器坏处是一样有外部碎片而且进程必须完全装入内存才能运行。它没有被彻底淘汰而是以降级的形式活在段机制里或者作为某些嵌入式系统的简化方案。从这一步到分页中间只差一个关键想法如果把“重定位”的粒度从整个进程缩小到固定大小的页那就不需要连续了。这是分页最朴素的由来。3. 分页机制内存管理的核心引擎分页的基本单位是页框frame物理内存侧的固定大小块和页page虚拟地址侧的等大小块。常见的页大小是4KB少数场景会用2MB甚至1GB的大页。CPU拿虚拟地址的高位部分当页号去查页表拿低位部分当页内偏移直接拼接出物理地址。3.1 页表为什么不能设计成简单一维数组最简单的页表就是一个数组虚拟页号当索引每一项存物理页框号。听起来很直接但有两个致命问题。第一个问题是每个进程一张页表就算进程只用了一点内存页表也要覆盖整个虚拟地址空间。32位地址空间加4KB页页表就要一百多万项64位下更是天文数字。第二个问题是页表项本身要记录状态位比如有效位、读/写位、访问位、脏位等每项至少4到8字节。一个进程开几张页表光页表就能吃掉大量物理内存。所以现代系统都采用多级页表。核心思想是顶层只建少量条目指向下一级中间页表中间页表再指向叶子页表只有真正用到的虚拟地址范围才会在叶子层建立页表项。没被用到的地址空间在中间层直接标成空指针从而省掉整块下级页表。3.2 多级页表的两级与四级差异以8KB页、8字节页表项的64位系统为例通常需要分四层PML4、PDPTE、PD、PT。每层索引位宽、层级数、页大小不同架构之间有差异。我当初建立手感的方式是给一个实际例子推一遍64位系统4KB页页表项8字节。虚拟地址48位有效常见实现页内偏移12位剩下36位要分成若干级索引。每级页表恰好装下512个页表项也就是需要9位索引。36位正好分成4×9所以是四级页表。于是地址从高到低是9位PML4索引、9位PDPT索引、9位PD索引、9位PT索引、12位偏移。这套推演的价值在于你不光记住了“四级”还知道了为什么是四个九位。转发到面试里别人问“为什么64位地址空间用不了64位地址”时你也能顺着硬件分级和软件设计逻辑解释清楚。多级页表解决的是“页表占用空间太大”但它是用“更多次内存访问”换来的。为了把多次访问重新拉回和单级页表一样快硬件引入了TLB。TLB里缓存的是“虚拟页号转物理页框号”的映射命中时完全跳过页表遍历。3.3 页表项的位设计权限与页状态页表项里真正干活的字段不只是物理页框号还包括位作用关键关联Present/Valid页面是否在内存不在时触发缺页Read/Write可写与否写保护User/Supervisor用户态是否可访问内核/用户隔离Accessed页面最近是否被访问置换算法依赖Dirty页面是否被写过换出时决定要不要写回磁盘NX禁止执行该页不允许执行指令缓解缓冲区溢出类攻击这里特别想提Dirty位和Accessed位的配合逻辑页面换出时Dirty位是0就可以直接丢弃那页物理内存因为磁盘上已有副本Dirty位是1则需要先写回磁盘。而Accessed位会被操作系统周期性扫描清零用来估算最近使用情况LRU类算法全靠它。两个位看似不起眼实际决定了虚拟内存系统的换页开销。4. 虚拟内存与缺页异常换入换出是怎么跑起来的分页解决了“不连续”的问题但还没解决“内存不够装”的问题。真正让物理内存看起来能无限大的是虚拟内存与缺页异常这套机制。4.1 缺页处理流程的完整链路当进程访问一个虚拟地址时MMU查页表发现该页表项无效就会触发一次缺页异常把控制权交给操作系统。缺页处理的完整顺序大概是硬件保存现场切换进内核态记录触发缺页的虚拟地址。内核检查这个地址是否合法比如是不是对NULL解引用、是不是越界访问。非法则发送信号杀掉进程合法则继续。定位页面来源可能是磁盘上的可执行文件映射、SWAP分区中的交换页、由零填充的匿名页、或者来自共享页的写时复制副本。找到空闲物理页框如果内存不足先执行页面置换腾出一个页框。从磁盘读取页面数据到该页框或者按需构造页面内容。更新页表项设置有效位、权限位清除异常原因。恢复进程现场返回到触发缺页的指令重新执行。其中第4和第5步最容易出现性能问题。读磁盘是毫秒甚至几十毫秒量级比内存访问慢几个数量级。所以缺页频率绝对不能高否则整个系统会卡在磁盘I/O上。4.2 页面置换换掉谁才不亏操作系统选“牺牲页”的时候内心目标其实是选一个以后最长时间不会被访问的页这样未来缺页最少。但未来不可知所以算法们都是在用历史信息做预测。常见的页面置换算法我按实际使用价值排个序LRU最近最久未使用理论最优逼近者每次淘汰最长时间没被访问的页。实现上的问题是要维护访问顺序代价偏高。Clock算法二次机会/时钟算法本质是LRU的近似。用环形链表每页有一个使用位。被扫描时使用位为1就清0放行为0就淘汰。实现简单开销低是很多操作系统的实际选择。LFU访问频率最低在长生命周期且访问稳定的场景表现好但对突发访问不敏感容易把刚加载的热点页淘汰出去。工作集模型不是单个算法是一种策略思路。核心观点是进程在一段时间内活跃访问的页面集合是有限的只要给足工作集大小缺页率就会低。多级页表的置换往往结合工作集思想来按区域收集页面。这里穿插一个实际经验学置换算法时别只背“缺页次数最小”的判断题一定要想清楚实现开销。真实内核不会对每次访问都精确记录时间戳不然每条指令的TLB miss都会变成内核负担。Clock算法这种“够用且便宜”的方案能在生产环境存活几十年不是因为它最优而是因为它的开销和效果性价比最高。5. 分页之外分段与段页式的思路提起内存管理很多资料直接跳进分页把分段一笔带过。但分段在真实系统里并没有消失它和分页以“段页式”的方式共存。理解分段最好的入口是从程序员的视角看内存代码、数据、堆、栈天生就是不同属性的段希望能按语义单元来做保护和共享。5.1 分段为什么仍然有价值分段按逻辑单位划分段大小不固定。段的表项不只是“页框号”而是“段基址段界限”外加权限。因为段天然就是“某类属性的内容集合”所以权限控制更自然代码段可执行但不可写数据段可写但不可执行栈段随用随长。分页是纯粹的物理管理页对程序员完全透明分段是逻辑管理段对程序员可见。分段的问题和连续分配一样段大小变化会产生外部碎片也要求段整体在内存里。单靠分段解决不了大程序装不进内存的问题。5.2 段页式实现先分段再分页段页式的做法是每个进程先按逻辑切成段每段有自己的段表项记录“该段页表在哪”再从每个段内部按固定页大小分页建立二级/三级页表结构。访存时硬件先查段表得到页表基址后再按分页流程翻译。好处很明显段的逻辑保护与共享还在页的物理分配效率和换入换出也在。代价是地址翻译多一层查表TLB设计变复杂。这也是为什么很多简单教学模式直接跳过段页式但真实内核却一定要考虑段描述符和权限隔离的原因。我自己的学习建议是不要把分段和分页理解成“二选一”理解成“两个不同维度”的分层。段的粒度大管逻辑语义页的粒度小管物理落地。二者不冲突只是需要一种机制把它们合起来段页式完成。6. 内核态的内存分配伙伴系统与slab分配器用户态程序要内存走的是malloc和系统调用。但内核自己也需要大规模分配内存而且分配者往往是驱动、文件系统、网络栈这种对速度和碎片极敏感的角色。内核不能直接拿用户态的分配逻辑于是有了两套经典机制。6.1 伙伴系统从“大块切分”到“小块合并”伙伴系统维护一组空闲块链表每类大小是2的幂次。分配时如果能找到刚好大小的块就直接给否则从更大的块逐级切半直到满足需求释放时则检查“伙伴块”是否也空闲是就合并成更大块再继续向上合并。为什么叫伙伴因为从同一个大块里切出来的两个相邻块互为伙伴只有伙伴之间才能合并不能拿两个不相邻的小块拼接。这种设计的好处是分配和释放速度快、碎片可控代价是最坏情况下内存利用率最高只有50%左右因为即使只要1KB也可能从2KB的块里分配。6.2 slab分配器对象缓存让重复构造不再昂贵内核里大量分配的对象不是“一堆字节”而是“特定结构体”比如进程描述符、文件对象、网络缓冲区。每次都走伙伴系统再手工初始化整个对象效率极低。slab分配器的思路是针对特定对象类型提前分配一组连续页组织成对象缓存。分配时直接取一个已构造好部分初始化状态的对象释放时把它放回缓存而不销毁底层内存。后续演进出来的slub、slob都是对slab的优化方向简化链表管理、减少元数据开销、更适配多核并发。如果你在某个版本的内核里看/proc/slabinfo那一排排对象名和数量就是slab系分配器在实战中工作的直接证据。内核分配这一块最常见的误解是“内核分配也走malloc”。实际上用户态malloc在底层通过mmap或brk拿到一段内存后再在用户态维护自己的空闲链表而内核自己的内存分配走的是页分配器和对象缓存。调试内核内存问题时这两条链路必须分开看。7. 从概念到实战内存问题的定位思路与调优手法内存管理知识点学完如果不落地到排查问题上始终有种“纸上谈兵”的感觉。下面几个场景是我觉得最能检验是否真正理解内存管理的实战题。7.1 怎么判断进程是真的内存泄漏在某个模拟项目里某服务进程的RSS持续上涨值班第一反应是“泄漏了”。但最好先排除两个更常见的原因虚拟内存增长但物理内存没涨可能只是分配器缓存了空闲块还没归还系统。物理内存确实涨但涨幅平缓且有界可能是缓存和缓冲池在吸收读写压力属于正常现象。真正的泄漏要满足每次操作后内存涨一块涨上去的块不再释放且持续累积。常见定位手段包括连续观察/proc/[pid]/status里的VmRSS、VmSize用工具抓堆快照对比重复对象数量或者直接给进程发信号触发统计看哪个字节数的对象数量不减。这个过程完全靠内存管理概念里的“用户态堆管理”和“内核页分配”两个层级的配合来区分。7.2 频繁swap物理内存不足的连锁反应当系统物理内存不足内核会把一些页面换到交换分区。但如果频繁换入换出系统会出现在可观测层面“CPU很空但卡顿、负载看似不高”的怪现象。这是因为瓶颈在磁盘I/O不是CPU。检查/proc/pressure/memory或vmstat里的si、so字段时如果交换入交换出数值持续很大就该考虑是不是进程的工作集总和超过了物理内存。我曾经在某个数据分析任务里遇到过类似情况任务跑起来后CPU使用率不到20%但系统响应极慢。看vmstat才发现si和so一直在跳动pidstat里也没看出哪个单进程异常。最后是调大物理内存配额、并且把数据预加载改成流式处理后SI和SO才彻底归零。这类问题不掌握“工作集”“缺页峰值”这些内存管理概念很难从CPU表象往“内存交换”方向想。7.3 TLB影响与页表相关调优业务服务如果对延迟极度敏感页表粒度和TLB命中率就成了调优点。默认4KB页下一个活跃服务的工作集如果达到1GB就需要25万个页表项跨了很多内存区域时TLB根本缓存不住每次访问都要穿透多级页表。常见的调优是开启大页内存。比如把关键进程的内存分配从默认4K页改成2MB页TLB覆盖范围直接扩大512倍命中率显著上升。代价是大页分配和回收不如小页灵活存在内部碎片。很多数据库类服务之所以提供专用的大页配置就是在用可接受的碎片成本换关键的访存延迟。8. 一些容易忽略但很重要的补充机制最后想补几个看起来零碎、实际在真实系统里天天出现的内存管理机制它们经常是面试和实战里的“隐藏分”。8.1 写时复制fork为何如此快写时复制Copy-on-Write的设计动机很朴素fork创建子进程时如果立刻把父进程全部物理内存复制一份既慢又浪费。绝大多数情况下子进程马上会调用exec去换一个新程序那些复制过来的页根本用不上。所以实现上只复制一张页表让父子进程的页表项指向同一批物理页并把权限设为只读。双方只读时相安无事任何一方写入了硬件触发写保护异常内核才真正复制一份物理页然后修改这一方的页表项。这个机制对“有时间效率的fork”“父子进程纠葛时的共享数据”“逃逸分析”都有直接关联。几个系统调用的延迟差异如此巨大COW功不可没。8.2 mmap的虚拟内存映射mmap把文件或共享内存映射到进程地址空间底层建立的是文件页到进程地址空间页表的映射关系。访问时触发的缺页会把文档对应内容读进物理内存修改后再写回磁盘。这天然实现了“按需加载”和“页面置换”对不想把整个大文件全部读进内存的场景极其友好。我自己处理过不少“读大文件不能一次全load”的需求比如几十GB的日志扫描。直接用mmap映射后只按需访问进程内存几乎没有峰值压力配合顺序读和内核预读速度也很可观。这里最底层起作用的仍然是缺页中断与页表映射那一套机制。8.3 内存屏障与并发访问严格说内存屏障不完全属于“内存管理”正章但它出现在并发内存访问的上下文中。现代CPU和编译器的乱序优化会让指令执行顺序不等于代码书写顺序多核场景下一个核的写入在另一个核看来可能乱序。内存屏障就是显式告诉CPU/编译器这里不许越过边界重排。数据库、驱动、无锁数据结构里acquire/release语义和显式屏障是家常便饭。这块知识容易被归到“并发编程”里但它的核心——缓存一致性、写缓冲、可见性——本质上和内存模型高度相关。建议学内存管理时顺手把内存屏障和缓存行概念也过一遍因为它们解释了很多“为什么这个程序在多核下不稳定”的谜案。我在学习内存管理时最有价值的体会是不要试图一次性把每一条细节全部装进脑子。把“进程视图→虚拟地址→多级页表→TLB→缺页→置换→物理页分配→内核对象缓存”这条主链先拉通其他所有边角知识都能顺着这条链找到自己的位置。后面你在现实中遇到内存飙升、页交换频繁、TLB命中率低、fork延迟高这些问题时脑子里会有一套完整的“地图”而不是零散的知识点。那时你就会发现当初啃这些概念花费的力气全都值了。

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

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

免费获取报价 →
↑