
提到操作系统里的存储器管理很多人第一反应是“哦讲过内存背过概念考完就忘了”。但如果你真做过开发、跑过服务、排查过系统卡顿或者进程被杀你会发现这套机制其实每时每刻都在背后运转。写这篇文章就是为了把这块硬骨头啃透用实际场景带你理顺“内存到底怎么管的”。这篇内容适合正在学操作系统课程、准备考研复试或者想系统补基础的同学也适合那些写代码时遇到过内存泄漏、服务莫名被 kill、想搞清楚 free 和 top 输出到底意味着什么的开发者。我不会只堆概念而是把存储器管理从“要解决什么问题”一路拆到“具体怎么落地”最后再附上实际排查经验。1. 存储器管理的核心问题物理内存永远不够用1.1 为什么说内存管理是操作系统的命脉先想一个场景你打开电脑同时挂着微信、浏览器开着二十个标签页、IDE 里跑着工程、后台还有一个 Docker 容器机器居然没有卡死——这不是物理内存变大了而是操作系统一直在做“资源腾挪”。CPU 执行指令时数据和代码必须待在内存里才能被访问但物理内存是有限的、共享的、昂贵的。于是存储器管理的基本使命就出来了在多个进程并发共享物理内存的前提下保证数据不出错、性能不崩溃并且让“内存不够用”这件事尽量少发生。它要做的事情拆开来看就是分配、回收、地址转换、保护、共享以及内存空间不足时的“腾挪”。如果你只是背“存储器管理的功能”这条简答题理解会很浅。把它看成一家快递仓库的运作就好懂多了货架上只能放有限的包裹但每天进库出库的包裹远超货架容量仓库管理员的职责就是决定谁上架、谁暂存到外部中转场、谁出库后释放货架。操作系统就是那个仓库管理员而中转场就是磁盘上的交换区。1.2 从机器码到虚拟内存把尴尬问题交给硬件配合早期系统给程序的是物理地址程序写死了从某个地址开始运行。这种方式在单任务时代没问题但多道程序出现后就麻烦了程序不知道别的进程占用了哪里自己写的地址可能覆盖别人的数据程序一旦超过物理内存大小就直接跑不了。于是现代操作系统和 CPU 联手给每个进程画了一张“假地图”——虚拟地址空间。程序在这个假地图上随便布局CPU 里的 MMU内存管理单元负责把假地址翻译成真实物理地址。这个翻译过程每执行一条指令都在发生所以性能特别关键。这也是后来引入 TLB快表、多级页表等一系列优化手段的根本原因。理解存储器管理的主线就一句话程序用虚拟地址假地址访问硬件加操作系统把假地址翻译成物理地址真地址翻译不了就中断由操作系统介入处理。2. 连续分配与伙伴系统老办法也有生存空间2.1 固定分区与动态分区的局限早期内存管理用连续分配就是说一个进程占一块连续的物理内存。固定分区简单粗暴把内存切成几块大小固定的小区每个进程占一块缺点是小进程占大区是浪费大进程进不了小区而且并发度受限。动态分区灵活一些进程需要多大就挖多大块出来但反复分配和释放后会出现大量外部碎片——明明总空闲内存够却因为不连续导致一个新进程因为找不到一块连续的足够大的区域而无法加载。这个场景很像停车场的停车位被散乱占用明明整个停车场还有空位但新来的大车就是没位置停。为了解决外部碎片大家会做紧凑compaction把已分配的进程挪到一起把空闲区合并成大块。但进程在物理内存里搬家地址要全部改掉代价很大。所以动态连续分配在后来的大内存场景里慢慢退居二线但它的几种分区选择算法——首次适应、最佳适应、最坏适应——依然是很多考试题目的基础也适合理解算法取舍。2.2 伙伴系统的设计精妙之处Linux 内核管理物理页面时用的是一种叫伙伴系统Buddy System的算法它把空闲内存按 2 的幂次切块。比如请求 5 个页面系统分配 8 个页面的块释放时如果相邻同样大小的块也是空闲的就合并成更大的块。伙伴系统的核心优势是分配和释放都很快而且大块内存有保障合并操作也很容易判断“伙伴”的位置——只要知道起始地址和块大小伙伴地址就能用位运算直接算出来所以内核里到处都是位运算和链表操作。这种设计相对于动态分区极大减少了碎片化也降低管理开销。但是伙伴系统也有一个问题分配 5 个页面却给 8 个页面会造成内部碎片里面没用完的 3 个页面。所以 Linux 在伙伴系统上层还加了 SLAB/SLUB 分配器专门给内核里那些经常创建和释放的小对象比如task_struct、inode缓存。这层机制理解了再看/proc/slabinfo就不会懵。2.3 连续分配的适用场景和后端思考不要觉得连续分配落后就完全没用。在很多实时系统、嵌入式裸机环境中没有 MMU内存就是一大块连续地址这时候分配算法只能靠这类方案。另外DMA 操作经常需要物理连续的内存内核里有dma_alloc_coherent这类接口就是为了拿连续物理块。我实际写驱动时踩过这种坑分配一大块 DMA buffer用kmalloc和__get_free_pages去拿连续物理页但如果你拿的是vmalloc分配出来的内存虚拟地址连续但物理页可能不连续DMA 设备按物理地址搬运数据时就全乱了。这就是存储器管理知识直接落到工程里的典型例子。3. 分页与页表虚拟地址翻译的工程核心3.1 分页怎么解决“连续”的世纪难题分页是把物理内存切成固定大小的页框通常 4KB把进程的虚拟地址空间也切成同样大小的页。进程不需要连续占物理内存虚拟页可以映射到任意物理页框通过页表来记录映射关系。这样一来外部碎片彻底消失因为所有分配都以页为单位内部碎片最多浪费一页以内可接受。这是现代操作系统最核心的思路。你写的程序以为自己有一块连续的地址空间比如从 0x0000 到 0xffff实际上每一页都被打散放在物理内存的各个角落只是页表帮你把假象维持住了。这就是虚拟内存机制的第一个关键词假连续真离散。3.2 多级页表和 TLB为了省空间和速度如果一个进程虚拟地址空间是 4GB页大小 4KB那一张扁平页表需要约 100 万个页表项每个进程一张光页表就占 4MB 内存非常夸张。所以现代 CPU 普遍用多级页表把 100 万个页表项按索引拆成多级如果某个一级索引下的二级页表完全没用就可以不分配节省大量内存。多级页表省了空间但翻译地址要多查几次内存。为此 CPU 里加了一个小缓存叫 TLBTranslation Lookaside Buffer把最近用过的虚拟页号到物理页框号的映射存起来。命中 TLB一次地址翻译几乎零成本没命中就要走完整的多级页表查内存性能差几十倍。而上下文切换会清空 TLB所以频繁切换进程会严重影响性能这也是为什么有些优化场景会考虑 CPU 亲和性绑定减少进程在核间漂移导致的 TLB 失效。这里我想起一个非常实际的例子一套数据库服务跑在高配服务器上QPS 就是上不去。排查发现进程频繁被调度到不同 CPU 核心每次迁移都要重建 TLB数据库这种随机访问密集的程序代价特别明显。用taskset绑核之后性能直接提升 20% 以上。存储器管理不单是内核的事应用层也能感知。3.3 页表项里不只有地址页表项里除了物理页框号还包含一堆标志位存在位这个页在不在内存、读写位、用户/内核权限位、脏位这页被写过没有、访问位这页最近有没有被访问过。这些标志位是实现页面置换、写时复制、内存保护的基础。比如fork()创建子进程传统做法是把父进程全部内存复制一份成本巨大。Linux 用写时复制COW优化子进程的页表先指向父进程的物理页并把这些页标记为只读任何一方要写触发缺页异常内核才真正复制那一页。这就是“假共享真隔离”的经典案例。你如果注意过 VmRSS 和 VmSize 的差别会发现进程虚拟内存很大但实际物理占用量很小很多时候就是这个机制在起作用。3.4 段式与段页式的存在感分段是按程序逻辑划分比如代码段、数据段、堆栈段每段有自己的基址和长度。分段的好处是共享和保护很自然比如多个进程可以共享同一个代码段缺点也很突出——段大小不一物理内存容易产生外部碎片。现实中 x86 架构实际用的是段页式先分段段内再分页。Linux 设计上大量弱化了分段基本只用分段让所有进程的代码段、数据段都覆盖整个线性地址空间然后靠分页做隔离和映射。也就是说逻辑段还在但内存离散这件事主要交给分页承担。这对你有两层意义认识层面理解了为什么 Linux 进程的地址空间布局里还分 text、data、heap、mmap、stack 区域这是分段概念在虚拟空间上的借用工程层面当你写链接脚本或看反汇编时地址都是虚拟地址回归到“分段是为了逻辑清晰分页是为了物理落地”这两句话就完全通顺了。4. 虚拟内存与页面置换假大空地址空间是怎么撑住的4.1 局部性原理虚拟内存的地基虚拟内存的核心是让程序能跑在比物理内存更大的虚拟空间里。它是靠什么骗过程序的局部性原理。时间局部性刚访问过的数据很快还会被访问所以适合加缓存空间局部性访问了某个地址附近的地址大概率很快也会被访问所以适合预读和按页加载。我们开发的程序在空间和时间上都有明显集中性比如循环、函数调用栈的频繁使用所以操作系统没必要把一个进程的所有页都放内存里只需要把正在用的页放进来暂时没用的页可以先放在磁盘交换区。这种“按需调页”策略让物理内存利用率大幅提升。4.2 缺页中断从磁盘搬数据有多贵当 CPU 访问的虚拟页不在物理内存时MMU 会触发缺页异常操作系统接管后检查地址合法性找一个空闲物理页框从磁盘读入所需页面更新页表再回到用户态重新执行那条指令。磁盘 I/O 是微秒到毫秒级CPU 指令是纳秒级一次缺页的成本比普通内存访问慢好几个数量级。所以如果你的程序出现大量缺页性能会断崖式下跌。所谓“现象就是程序开始卡、系统响应迟钝”往往就是内存压力大导致频繁换页。这里必须强调一个指标页错误率也就是访问内存时页面不在内存里的比例。只要这个数字很低虚拟内存策略就工作得很好一旦升高系统就开始“颠簸”thrashing所有进程都在等磁盘CPU 利用率反而低服务基本不可用。4.3 页面置换算法对比为什么 LRU 很难做到当内存满了又来了一个新页必须踢掉一个旧页。理想策略叫 OPT往后看哪个页最久不用就淘汰谁但未来不可知所以只能做近似。FIFO先进先出实现简单但可能会把即将用到的页踢出去Belady 异常也让它表现不稳。LRU淘汰最近最久没用的页性能接近最优但为每个页面记录“最近使用时间”成本高硬件难以精确实现。LFU看的是“最近一段时间内被访问的次数”次数最少淘汰但可能让历史上的高频页永远不被换走比如曾经的启动代码页实际效果不如 LRU。Clock时钟算法用环形链表加访问位来近似 LRU扫描时看到访问位为 0 就淘汰为 1 就清 0 并跳过。开销小性能接近 LRULinux 的页面回收就是基于类似逻辑做的。我用一个访问序列帮大家直观感受假设物理块 3 个访问序列为7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1。FIFO 和 LRU 的缺页次数明显不同LRU 通常表现更优但代价是实现复杂。工程上局部性原理决定了近似 LRU 已经足够好这也是为什么操作系统实战用 Clock 而不是教科书式的 LRU。4.4 交换空间与分配策略OOM 是最后手段交换空间swap的作用是给虚拟内存一个“磁盘扩展区”。Swap 不是越大越好因为磁盘速度比内存慢太多但完全没有 swap 的风险是内存一紧张内核只能直接 OOM。服务器上通常建议保留少量 swap甚至用 zram 压缩内存页来临时缓解压力。Linux 下还有一个swappiness参数控制内核回收匿名页需要换出到 swap和文件页直接丢弃再从磁盘读的倾向程度。默认 60数值越小越不愿意用 swap从而尽量把物理内存留给活跃程序。数据库服务器一般会调低到 1 或 10避免磁盘换页把性能拉垮。当内存完全耗尽内核会启动 OOM Killer 挑进程杀。它根据进程的 oom_score 来选择通常挑占用大、优先级低的进程。这就是为什么有时候 Redis 或者 MySQL 莫名其妙挂掉翻/var/log/messages能看到 “Out of memory: Kill process”。这种情况不能只调 OOM 参数必须检查内存泄漏和配置否则杀一次还会再来第二次。5. 实操演练用 Linux 工具透视内存管理5.1 free、top、vmstat 到底在看什么纸上谈兵不够我分享一下我排查内存问题的标准动作。先看free -hfree -h total used free shared buff/cache available Mem: 15Gi 2.1Gi 9.3Gi 245Mi 3.9Gi 12Gi Swap: 2.0Gi 0B 2.0Gi注意free列显示的并不是真正的可用内存因为 Linux 会把空闲内存拿去做页缓存page cache用来加速文件读写。所以available才是应用程序真正可用的量。如果你发现 free 很小但 available 很大机器其实没压力这是正常现象不用慌。再配合vmstat 1看si和so两列如果持续不为 0说明系统正在从 swap 换入换出内存已经紧张了。top里的RES是进程实际占用的物理内存VIRT是虚拟内存总量两者差距大很正常不用因为 VIRT 大就紧张。5.2 用 /proc 和工具定位内存占用要定位到底是哪个进程在吃内存可以用top按内存排序也可以用一行命令把 TOP 10 拉出来ps aux --sort-%mem | head -11要详细看一个进程的地址空间映射可以查它的 smapscat /proc/pid/smaps | grep -A 10 Rss:在这里可以看到进程总 RSS 里堆占多少、栈占多少、共享库占多少。比如发现某个进程的堆区 Rss 异常大说明它有大量动态分配没释放或者内存碎片化严重。再进一步用valgrind或 AddressSanitizerASan做内存检测是最直接的定位手段。比如我写 C/C 服务时经常用gcc -fsanitizeaddress重新编译一次跑一遍测试就能直接看到是哪一行发生了堆溢出或泄漏。这类工具就是建立在“操作系统帮你记账”的基础之上没有内存管理机制这些工具都无法工作。5.3 一次线上服务 OOM 的排查全过程前两年我遇到过一次线上服务频繁被杀现象是每隔一两天某个 worker 进程的 RSS 就缓慢上升最终触发 OOM Killer。我当时的排查思路是这样的第一先用free -g和vmstat 1 5确认是不是整机内存不够。结果发现整机还有空闲但 cgroup 限制的容器内内存已经到顶所以被杀的是容器内进程。这说明内存限制在容器层而不是物理内存整体。第二进入容器后用cat /sys/fs/cgroup/memory/memory.usage_in_bytes观察内存使用曲线又通过ps aux --sort-%mem锁定了某个 Java 进程占得最多。第三对 Java 应用做 heap dump用 MAT 分析发现是某个本地缓存没有设置过期策略积累了大量未释放对象。调整缓存上限和回收策略后容器内存稳定下来。这个案例里每个排查动作其实都落地在存储器管理的概念上RSS 对应物理页框占用cgroup 内存限制对应操作系统分配机制的上层约束heap dump 对应堆区对象分布。你理解了底层原理排查问题会更有方向而不是靠瞎试。6. 学存储器管理必须避开的认知坑6.1 不要把虚拟内存理解成“不可能实现的事”有些同学会把虚拟内存简单等同于“拿磁盘当内存用”这不够准确。虚拟地址空间是一个独立于物理内存的逻辑层交换区只是它在磁盘上的延伸。实际上很多情况下进程的虚拟页从未真正进入过物理内存比如申请了但没访问的堆内存也没有交换到磁盘它们只是“画了一页纸的饼”在页表里连存在位都没有。理解了这一点你就知道为什么 malloc 一大块内存之后top里 RSS 没有立刻变大——因为分配内存只是修改了进程的地址空间映射真正分配物理页是首次访问写入时才发生的。这叫“惰性分配”是操作系统在性能上的重要取舍。6.2 不要死记硬背用页面置换理解缓存设计页面置换算法不只是操作系统试卷上的题目在应用层随处可见。Redis 里有内存淘汰策略比如 allkeys-lruCPU 的 cache 替换策略也类似数据库的 buffer pool 也在做类似 LRU 的管理。你学懂一次就把分布在各处的东西都串起来了。我在实际写缓存模块时最初直接用哈希表加 FIFO 队列做淘汰结果热点数据经常被冷数据挤掉。后来改成类似 Clock 的实现给每个缓存项加访问位每次淘汰时扫描效果明显改善。这就是把操作系统知识直接迁移到业务代码的过程。6.3 多道程序设计对内存管理的反向影响并发度提升了进程数量多起来内存管理压力也随之增大。上下文切换时的地址空间切换、TLB 刷新、缺页率上升都会直接影响系统的整体吞吐量。存储器管理并不仅仅是“给每个进程分配一点内存”这么简单它是在并发与性能、隔离与共享之间不断权衡的设计工程。比如你会看到很多高性能服务器采用“线程池 单进程多线程”模型其中一个重要原因就是线程之间共享地址空间不需要频繁切换地址空间和 TLB。而多进程模型更稳定但上下文切换开销更大——这个选择背后依然有存储器管理的身影。7. 手写一个简单的内存管理模拟器理解更深7.1 实现思路如果看概念还是不过瘾我建议你自己动手写一个模拟器。不需要真去改内核而是在用户态模拟一个虚拟内存系统内存池用一个大数组表示物理页框固定数量虚拟地址空间按页切分然后用页表记录映射关系。模拟 FIFO、LRU、Clock 三种置换算法统计缺页次数。写的过程中你会深刻体会几个问题页表和多级索引的关系自己实现一遍才知道为什么一级目录能省内存。LRU 维护时间戳开销大所以 Clock 算法在工程里更常见这个感受不是看书能得来的。缺页率受访问序列影响很大你随便构造一个序列不同算法表现差异就出来了。7.2 核心代码骨架这里给一个简化的模拟核心逻辑很直观#include stdio.h #include stdlib.h #include string.h #define PAGE_FRAMES 3 /* 物理页框数 */ #define PAGE_TABLE_SIZE 16 /* 虚拟页数 */ int page_table[PAGE_TABLE_SIZE]; int frames[PAGE_FRAMES]; int frame_count 0; int clock_hand 0; void init() { for (int i 0; i PAGE_TABLE_SIZE; i) page_table[i] -1; for (int i 0; i PAGE_FRAMES; i) frames[i] -1; } int access_page(int page) { if (page_table[page] ! -1) return 0; /* 命中 */ /* 缺页 */ int victim -1; if (frame_count PAGE_FRAMES) { victim frame_count; } else { victim clock_hand; /* 简化FIFO替代 */ clock_hand (clock_hand 1) % PAGE_FRAMES; } for (int i 0; i PAGE_TABLE_SIZE; i) { if (page_table[i] victim) { page_table[i] -1; break; } } page_table[page] victim; frames[victim] page; return 1; } int main() { int refs[] {7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1}; int n sizeof(refs) / sizeof(refs[0]); int faults 0; init(); for (int i 0; i n; i) { faults access_page(refs[i]); } printf(缺页次数: %d\n, faults); return 0; }这是我简单写的演示你可以在此基础上把查找空页框的逻辑替换成真正的 LRU 计时或者 Clock 扫描访问位然后对比缺页次数。这个过程比背十遍教材更容易建立起直觉。注意这个代码只是算法演示真实系统的页表、TLB、缺页处理、写回机制都远比这个复杂但核心的“缺页 - 选 victim - 换入换出”和这里完全一致。7.3 用模拟器验证算法差异用教材标准序列跑一下FIFO 和 LRU 的缺页次数会不同。建议自己多构造几组序列——比如全顺序访问、循环访问、突发性访问——看看不同访问模式下算法的优劣势。你会发现 Clock 在多数情况下接近 LRU但实现成本低得多。我当初做课程设计时把这个模拟器加了一个交互界面输入访问序列能看到每个时刻页表、物理框、置换情况一下子就把“置换算法的动态过程”变成了看得见的东西。强烈建议你也这样做一遍比只写一个算法函数价值高十倍。写到这里存储器管理的主线已经全部过了一遍。我个人的体会是这门知识最容易的错误学法就是拆成一个一个孤立的知识点去背比如“分页是什么、分段是什么、LRU是什么”背完就忘。真正让人通透的标志是你拿到任意一段关于程序内存的实际数据都能解释出背后的操作行为。最后再分享一个小技巧如果你正在准备面试或者考试不妨闭上眼睛在纸上画一遍“一个程序从 malloc 到访问一个地址的完整路径”从虚拟地址到页表、TLB、物理页框、缺页中断、进程切换、swap 换入换出每一步标注出涉及的数据结构和硬件机制。能连贯地把这条路径讲清楚存储器管理这关才算真的过了。