1. 先弄明白为什么要有分页存储1.1 连续分配方式到底差在哪学操作系统内存管理绕不开一个老问题程序加载进内存时内存空间怎么分早期用连续分配也就是给一个进程一整块连续的内存区域。这种方式听着简单实际用起来让人头大。两个比较典型的场景第一个是外部碎片。进程来了又走内存被切得零零碎碎明明总剩余空间够大却找不出一块连续区域装下一个新进程。就好比一个停车场空着十几个散位却停不下一辆需要连续两个车位的大巴。这种碎片在进程退出、换入换出频繁的系统里特别严重最终的结果是内存利用率虚高但实际能用的进程数很少。第二个是进程地址空间和物理内存的绑定问题。连续分配下程序编译时就得知道自己会被加载到哪个物理地址区间程序里用的地址和真实内存地址脱不开关系。于是出现了重定位、动态重定位、界地址寄存器等一系列补丁式的解决方案每个都是能跑但别扭。业界后来思考的方向很直接别追求连续了允许一个进程散落在内存各处把“能不能装下”的判断粒度变小。分页存储的核心就是干这件事——把物理内存切成固定大小的页框把进程的逻辑地址空间切成同样大小的页映射关系交给页表进程就不需要再找连续空间了。1.2 分页的核心理念把“大”拆成“小”分页的思路不复杂逻辑地址空间按固定大小等分每一份叫一“页”或“页面”物理内存按同样大小等分每一份叫一“页框”或“页帧”也有人叫“物理块”。页面大小通常取2的整数次幂常见的4KB在经典体系中被广泛使用。由于页和页框大小一致任意一个页面都可以放到任意一个空闲页框中映射关系由页表记录。这样进程的逻辑地址空间是连续的物理分布却是离散的外部碎片直接被消除内部碎片最多浪费一页以内的空间——按4KB算平均浪费约2KB每进程远远好于连续分配的不可控碎片。这里有一个容易被忽略的点为什么页面大小必须是2的整数次幂因为只有当页面大小是2^n时才能把逻辑地址的低n位直接当作页内偏移量高若干位当作页号地址拆分完全靠位运算完成硬件实现极其高效。如果页面大小不是2的幂地址转换时就得做乘除运算电路复杂度和时延都会明显上升。所以看一个体系结构的页面大小往往就能推断出地址格式的设计思路。这也是为什么学分页存储要先从地址结构入手而不是上来就背那一堆算法。2. 分页存储的核心概念与数据结构2.1 页面、页框、页表、逻辑地址分页涉及几个最基本的概念先把它们串起来。逻辑地址由两部分组成页号P和页内偏移量W。例如某系统页面大小4KB逻辑地址32位那么低12位是页内偏移量高20位是页号。物理地址也由两部分组成页框号F和页内偏移量W偏移量与逻辑地址中的偏移量完全一致因为页面和页框大小相同映射前后页内偏移不变。地址翻译要做的事其实只有一件把逻辑地址中的页号P通过页表换成物理地址中的页框号F偏移量原封不动透传。页表是每个进程独立的一张表表项记录了页号到页框号的映射关系。页表本身需要存放在内存中操作系统为每个进程维护一个页表基址寄存器指向该进程页表的起始地址。每次进程切换时这个寄存器会被更新为另一个进程的页表基址从而实现进程地址空间的隔离——一个进程的页号就算相同也对应着完全不同的物理页框谁也不能越界访问别人的物理内存。页表项里除了页框号通常还包含一些标志位有效位表示该页是否在内存中如果为0说明页面可能在磁盘上或尚未分配访问时会产生缺页异常读写位控制权限访问位和修改位用于页面置换算法收集信息还有些体系结构加入禁止缓存位、全局位等。这些标志位看着不起眼但在缺页处理、页面置换、内存保护、性能优化里都起着关键作用。2.2 地址翻译过程一次完整的手工推演拿一个例子完整推演一次地址翻译。假设页面大小4KB逻辑地址0x3A2B。4KB 2^12所以页内偏移占低12位页号占剩余高位。0x3A2B换算成二进制是 0011 1010 0010 1011低12位是 1010 0010 1011 0xA2B高4位假定地址位宽16位是 0011 3因此页号P 3页内偏移W 0xA2B。如果页表中第3项的页框号F 7则物理地址 7 * 0x1000 0xA2B 0x7000 0xA2B 0x7A2B。整个过程里页表只做了一次查表替换偏移量没有经过任何加工。实际系统中硬件访问一个逻辑地址通常需要两次内存访问第一次查页表取页框号第二次真正访问目标数据。这会带来性能损失所以引入了后面要讲的TLB加速机制。页表的设计目标之一就是把这两次访问尽可能变快多级页表的引入主要就是为了缓解页表本身占用内存过大的问题而这些在经典教科书里往往是先讲单级页表、再讲多级页表的递进结构理解顺序别乱。2.3 单级页表的问题与多级页表32位系统、页面4KB时逻辑地址高20位是页号意味着一个进程最多有2^20 1,048,576个页面。每个页表项假设占4字节页表大小就是4MB。一个进程4MB页表听起来还能接受但换成64位系统、同样页面大小页号位数巨大单级页表的大小直接变成天文数字为每个进程分配这么大的连续内存是不现实的。于是有了多级页表把页表本身再分页并建立索引未被使用的页目录项可以不分配对应的二级页表从而按需分配。比如经典的两级页表结构一级页目录项指向二级页表二级页表项指向物理页框。很多进程实际只用到地址空间很小一部分层级结构让内存占用大幅下降。虚拟内存、按需调页在引入多级页表之后真正走向了可用。它带来的代价是每一次地址翻译要多查几级如果没有硬件缓存性能会更难看。现代CPU的MMU里集成了TLB和多级页表遍历的硬件软件侧看到的多级页表其实已经是硬件配合处理过的产物。3. 地址转换的完整流程与局部性原理3.1 从逻辑地址到物理地址的完整路径分页地址转换的基本路径可以概括为三步。第一步从逻辑地址中提取页号P和页内偏移W。第二步用页表基址寄存器加上P乘以页表项大小找到对应页表项检查有效位若页面不在内存触发缺页异常并进入操作系统处理流程。第三步从页表项中取得页框号F与W拼接成物理地址访问内存。这个流程听着简单但里面藏着很多细节。比如页表基址加上“P 乘以页表项大小”时操作系统要确保页号P没有越界——否则一个非法逻辑地址可能直接算出页表范围之外的地址造成不可预期的访问。所以很多体系结构在页表基址寄存器之外还有页表长度寄存器对比页号是否超出范围这属于分页保护机制的一部分不能省。另一个细节是缺页异常的处理缺页时要先看内存中是否有空闲页框如果有就直接调入如果没有就要根据置换算法选择一个牺牲页框把旧页面换出必要时写回磁盘再将新页面换入并更新页表和TLB。这一整串过程在经典操作系统教材里是重点在实际系统里也确是性能关键点。3.2 TLB为什么能把两次内存访问变成接近一次纯软件实现的分页地址翻译理论上需要两次内存访问开销翻倍。实际系统中几乎所有页表访问都具有时间局部性也就是说程序在短时间内反复访问的页面数量很少可能集中在几个页面内。CPU的MMU因此加入了一个高速缓存专门缓存最近用过的页号到页框号的映射叫做TLBTranslation Lookaside Buffer。TLB的容量通常很小几十到几千项但命中率非常高。TLB工作原理是地址翻译先查TLB如果命中直接拿到页框号一次内存访问完成如果未命中走完整页表查询同时把新映射填入TLB。TLB满时也需要替换常见策略有LRU类置换和随机置换有些体系结构还支持软件控制的TLB重填把替换策略交给操作系统。这里有一个常见误区TLB只缓存页号到页框号的映射不缓存页内偏移因为页内偏移不需要翻译直接透传。TLB命中时得到的是一整个物理页框号再和偏移拼接就行。我倾向把TLB理解为“页表的热点缓存”。它的存在说明了一个操作系统设计的一般规律——能用缓存解决的问题优先用缓存而不是让每次操作都走重重逻辑。读内核代码时经常能看到flush_tlb之类的函数它们的作用正是让TLB中无效映射失效避免缓存了旧的页框号导致数据访问到错误内存。在调试内存相关问题时观察TLB相关统计信息命中率、未命中率、重填次数往往比凭空猜代码更快。4. 页面置换算法物理内存不够时的博弈4.1 缺页、抖动与工作集分页存储配合虚拟内存后物理内存可以比进程的逻辑地址空间小进程用到的页面并不需要全部驻留内存这就是按需调页。按需调页的前提是运行过程中访问到不在内存的页面时能当场调入这就是缺页中断。如果缺页频繁到一定程度系统大部分时间都在处理缺页和页面换入换出CPU利用率和吞吐量反而急剧下降这种现象就叫“抖动”。工作集概念在这里很有用一个进程在一段时间内实际访问的页面集合叫工作集。如果物理内存大小足以容纳工作集缺页率就低如果装不下缺页率就会飙升。所以页面置换算法的目标本质上是尽量保证每个进程的工作集能放到内存中。很多现代系统即使没有显式做工作集模型背后用的LRU类近似算法本质上还是在跟踪工作集。4.2 经典算法逐个拆最佳置换算法OPT在理论上最优换掉未来最长时间不会被访问的页面但它需要预知未来现实中不可实现只作为衡量其他算法的基准。先进先出FIFO实现最简单维护一个队列换出最早进入的页面。FIFO的缺陷很明显它不考虑页面是否被频繁使用滚动播放一个经常用到的页面可能反复被换出换入性能不稳定。更糟的是可能出现Belady异常——分配的物理块增多时缺页次数反而增加原因就是FIFO只看进入顺序不管访问模式。最近最久未使用LRU是更符合直觉的算法换出最长时间没有被访问的页面。理论上LRU性能接近OPT但完全实现LRU需要记录每个页面最后一次访问时间并快速找到最小值硬件成本和软件复杂度都不小。因此工业界更多使用LRU的近似实现比如时钟算法Clock。时钟算法把内存中的页面放在一个环形链表上每个页面带一个访问位指针循环扫描。遇到访问位为0的页面就换出遇到访问位为1的页面清零继续往下走。改进版时钟算法还会考虑修改位优先换出既未被访问也未被修改的页面减少磁盘写回开销。实际情况下LRU和Clock的差距取决于访问模式的局部性强弱。局部性越强两者差距越大随机访问模式下差距变小。我见过一些开发者试图自己实现一套“完美LRU”结果复杂度和性能一塌糊涂还不如直接用内核提供的近似算法。先把Clock的改进策略吃透再考虑是否自行扩展是一条稳妥的路线。4.3 页面分配策略与局部性置换算法不是孤立运行的它要配合页面分配策略。每个进程最少需要分配多少页框决定了系统整体能容纳多少进程。分配太少的页框会导致频繁缺页分配太多又挤占其他进程空间。经典做法有三种平均分配、按比例分配、按优先级分配。按比例分配按进程大小分配但进程大小通常和实际活跃页面数不完全成正比实践中效果未必好。优先级分配则是把更多页框给高优先级进程适合交互式系统和实时性要求不高的场景。另一个容易被忽视的点是局部性。页面置换算法能生效的根本原因就是局部性原理。时间局部性指最近访问的内容很可能很快再次访问空间局部性指访问了某个地址后周围地址也容易在不久后被访问。操作系统通过页表项的访问位来捕捉这种局部性Clock算法正是利用访问位来近似LRU。理解了局部性原理才能真正理解TLB为什么高效、预调页prefetching为什么有帮助、页面大小为什么会影响性能。页面太小页表爆炸页面太大内部碎片和空间局部性收益下降通常4KB到16KB是一个平衡区间。5. 常见问题与排查经验5.1 页表太大怎么办很多新手第一次遇到的问题是32位系统4KB页、4MB页表看着还好但换成64位系统页面4KB时页表大小根本没法用单级结构容纳。解决办法是多级页表通过只分配实际用到的页表层级来降低内存占用。但多级页表也带来一个新问题地址翻译要查多级没TLB时性能更差。现代系统的做法是同时结合TLB、多级页表并通过硬件遍历页表来加速。在实操中如果一个进程页表本身占用异常大优先检查是不是地址空间碎片化导致大量稀疏页目录项被分配而不是盲目调大物理内存。大页HugePages是另一种应对页表过大的手段。把页面大小从4KB提升到2MB甚至1GB页表级数和项数大幅降低TLB能覆盖的内存范围变大。对数据库等追求稳定低时延的应用来说大页能显著减少TLB miss。但大页的缺点是内部碎片更明显分配粒度更大对小内存场景反而浪费。Linux下的透明大页THP有自动管理机制不过在使用时要留意它在某些场景下可能引入不稳定的性能波动例如分配大页时发生阻塞。建议先在小范围试点采集数据再决定是否在生产环境开启。5.2 为什么完全LRU很难落地标准LRU需要维护每个页面的最后访问时间并在置换时选取最早的那个。硬件级实现通常靠一个巨大的计数器每次访问都要更新时间戳置换时线性扫描所有页框这在页面数多时完全没有扩展性。软件实现可以做比如维护一个链表但每次访问都要在链表中移动节点开销依然很大。所以工业界普遍接受Clock这类近似算法它们牺牲一定精确性换取O(1)级别的实现成本和硬件友好度。我在实际项目中见过一个奇怪现象某些内存压力下系统缺页率并不高但响应变慢。查到最后发现是页表项“访问位”更新策略导致TLB频繁失效。访问位通常由硬件置位、软件定期清零如果内核清得太频繁就会把访问位信息破坏掉后续置换算法误判页面最近使用情况。这种问题从表面看很像LRU算法失效实际上是TLB和页表标志位交互的陷阱调内核参数比改应用代码更有效。5.3 对照速查分页存储典型问题问题现象可能原因常见处理思路缺页率居高不下分配的页框数过少、工作集大于物理内存增加内存、调整进程优先级、减小进程工作集TLB命中率低页表太小、访问模式局部性差、没开大页开启大页、优化数据结构布局、减少跳转页表内存占用过大单级页表、稀疏地址空间改用多级页表、开启大页换出时磁盘写回频繁修改位信息导致页面反复写回使用改进时钟算法、增大换出缓冲程序运行性能突降抖动检查分配给进程的页框数、降低多道程序度这个表格基本可以当排查清单用。在动手优化前先确认问题到底出在页表结构、TLB、置换策略还是进程本身的内存访问模式上别上来就改参数。很多性能问题看着像分页的锅实际是应用程序数据布局导致缓存失效。5.4 实操排查思路假设系统报出的结果是内存充足但缺页频繁。我一般按三步走。第一步看进程的驻留集大小RSS是否明显小于工作集可以用系统自带的统计工具观察性能计数器和缺页数据。如果RSS太小优先给进程多分页框。第二步看页面置换对象是什么如果大量换出的是频繁修改的页面说明算法对修改位处理不当要优先调整置换策略如果换出的页面大多是只读数据磁盘I/O和写回压力会小很多。第三步看TLB相关的性能计数器若TLB miss比例高于正常水平考虑开大页或改善数据局部性。有一类坑是调试器里看到的逻辑地址和物理地址混淆。很多排查工具给出的是进程逻辑地址并不直接等于物理内存位置。想确认物理页框号得从页表里解析。当年我在看一个内存越界问题时一直盯着逻辑地址找规律后来把页表手动解析出物理页框才定位到是页表项标志位置错导致的错误映射。善用系统提供的页表映射查询机制比硬读代码高效得多。6. 分页存储的局限、优化与现代实践6.1 分段与段页式分页的互补方案分页并不是内存管理唯一的选择。分段的思路是把程序按逻辑单位分段每段是独立地址空间长度可以不同。分段很符合程序员视角比如代码段、数据段、栈段可以独立进行权限控制和共享但分段保持了段的整体性容易产生外部碎片。分页解决了外部碎片和离散分配却对“逻辑模块”没有天然支持一个函数的代码可能被拆成多个页面给共享和保护带来不便。段页式结合了两者先按段划分逻辑地址空间每一段内部再按页划分。程序员看到的还是段操作系统在幕后为每段建页表实际分配以页为单位。段页式结构灵活但代价是查表次数增多地址翻译硬件更复杂。现代主流操作系统很少单独用纯分段往往在虚拟内存层面以分页为主、以类似段的功能补充权限和共享等需求。理解段页式的意义更多在于看清分页的优势和局限不至于把某个单一机制当成银弹。6.2 现代操作系统的分页实践Linux和Windows在核心内存管理上都采用分页机制。Linux下每个进程拥有独立的页表进程切换时切换页表基址系统同时区分内核态和用户态映射有些体系结构用独立地址空间标识符ASID标记页表项减少进程切换时清空TLB的代价。现代x86和ARM都支持多级页表Linux针对不同硬件架构定义了几种页面大小配合透明大页和显式大页给用户不同的性能取舍。另一个现代趋势是硬件辅助虚拟化中的影子页表或两阶段地址翻译。虚拟机场景中客户机操作系统的页表不能直接用于宿主机因为客户机管理的是客户物理地址不是真实物理地址。经典做法是影子页表由虚拟机监视器维护从客户虚拟地址到宿主物理地址的映射硬件支持的两阶段翻译则让客户页表映射到客户物理地址再由第二阶段映射到宿主物理地址。看这类机制时会发现分页的基本原理并没有变只是在多了一层地址空间后页面映射关系变得更复杂缓存和失效的处理也更讲究。6.3 优化角度从页面大小到数据布局写应用层面代码的人可能觉得分页是操作系统的事段页式、多级页表离自己很远。其实不然。程序访问模式直接影响TLB命中率、缺页率、工作集大小从而影响整体性能。一个典型的优化是把热数据在内存中紧凑排列。假如一个结构体的字段散布在多页上每次遍历都可能触发新的页面访问TLB miss率和缺页率都会上升把相关字段放在同一页内局部性变好性能能明显提升。这类优化在数据库、搜索引擎、游戏引擎中都屡见不鲜。再比如大页的取舍大页降低页表压力和TLB miss但分配和回收粒度大内存碎片化加剧。有些服务对启动时间敏感而分配一个大页需要更长的连续物理内存。生产实践中开的做法是先用常规页面跑基线再切大页跑对照观察性能和稳定性的差值再决定是否长期使用。内存管理没有通用的最优配置只有当前场景下的合理选择把分页机制吃透才能真正理解这些权衡而不是跟着网上的参数模板走。我个人做了多年内存相关的开发和调优最大的体会是分页存储并不只是教科书里的一章知识点它几乎是理解所有现代计算性能问题的钥匙。从页表到TLB从置换算法到工作集每一步都在和局部性、成本、复杂度做博弈。把这个机制的基本盘理解透后续学虚拟化、容器隔离、数据库缓冲池、文件系统页缓存都会变得顺理成章。建议学习时多画地址格式图动手把几组逻辑地址翻译成物理地址再写点模拟页面置换的小程序记录缺页率变化远比只看概念来得实在。