Skip to content

虚拟内存与页面置换 ​

什么是虚拟内存 ​

每个进程看到的是独立、连续的虚拟地址空间。CPU 的 MMU(内存管理单元)依据页表把虚拟页映射到物理页框;映射不存在或权限不符时触发异常,由内核处理。

为什么页内偏移不需要翻译 ​

虚拟页与物理页框大小相等、且都按页大小对齐,这是页表翻译“高位替换 + 低位透传”的前提:

text
虚拟地址 = [ 虚拟页号 VPN | 页内偏移 offset ]
                    │             │
            查页表翻译│             │原样保留
                    ▼             ▼
物理地址 = [ 物理页框号 PFN | 页内偏移 offset ]

例如页大小 4KB(偏移占低 12 位),虚拟地址 0x12345678 的页号是 0x12345、页内偏移是 0x678;若页表把该页号映射到物理页框 0xA0000,物理地址就是 0xA0000 << 12 | 0x678 = 0xA0000678。

原因在于:

  • 页表是页粒度的映射,只负责“第几页 → 第几页框”;
  • CPU 按字节寻址,页内偏移用于在 4096 个字节中定位具体字节,不需要也不应该翻译;
  • 虚拟页与物理页框大小相同且对齐,页内相对位置在两端完全一致,低位偏移位原样透传即可。

这样设计的好处:TLB 只需缓存页级条目,一条 TLB 项覆盖 4KB 连续地址,页内访问零翻译开销。

需要注意:页大小一致是设计前提(启用大页时虚拟页和物理页会同时变为 2MB/1GB);但虚拟页与物理页在数量上不对等——虚拟地址空间远大于物理内存,大量虚拟页没有对应的物理页框(未映射或已换出),这正是缺页异常存在的原因。

进程内存隔离如何实现 ​

每个进程有自己的页表。即使进程 A 与进程 B 使用相同虚拟地址,它们也会经各自页表映射到不同的物理页;若页表中没有映射或权限不允许,CPU 会触发异常,内核通常终止违规进程(如段错误)。进程切换时,CPU 同时切换当前使用的页表,因此用户态进程不能直接访问其他进程的私有内存。

页表还包含读、写、执行以及用户态/内核态等权限位。内核以更高特权级管理页表和物理内存;共享内存、mmap 和共享库则是内核显式让多个进程映射同一物理页的例外,此时需要额外同步来保护共享数据。

虚拟内存带来:

  • 隔离与保护:不同进程默认无法任意读写彼此内存;页面可标记为可读、可写、可执行;
  • 地址空间抽象:程序不必知道物理内存位置,方便装载、共享库与地址随机化;
  • 按需调页:只在真正访问时把页面装入物理内存;
  • 文件映射与共享:文件可映射到内存,多个进程也可映射同一只读代码页或共享内存页;
  • 超额使用的有限支持:不活跃匿名页可换出到 swap,或可丢弃后从文件重新读取;但频繁换页会造成严重抖动,不能把磁盘当作等价内存。

reserve、commit 与 resident:不要把“申请内存”混成一件事 ​

讨论“进程占了多少内存”时,至少要区分三个层次:

层次含义典型现象
保留(reserve)虚拟地址范围被占用,避免与其他映射冲突地址空间看起来很大,但未必消耗同等 RAM
提交/承诺(commit)OS/运行时承诺该范围在需要时有后备来源(RAM、swap 或文件)不同 OS 的 overcommit 策略不同,不能简单等同于已用物理内存
驻留(resident / RSS)页面当前实际在物理内存中首次访问、换入换出和回收会改变它

malloc 或 new 成功只说明分配器当前能给出可用地址;在按需分页和 overcommit 系统上,物理页常在首次读写时才真正分配。反过来,映射存在也不保证页面永远驻留;内存压力下文件页可被丢弃,匿名页可被换出。实际排障要结合虚拟大小、RSS、PSS、swap、页错误和 cgroup 限额,而不是只看一个“内存使用量”。

缺页与交换 ​

访问未驻留页面会产生缺页异常(page fault):内核检查地址是否合法,若合法则从文件或交换区载入页,更新页表后恢复指令;若非法则向进程报告错误(例如段错误)。

当物理内存紧张,系统会回收页面:干净的文件页可直接丢弃后按需重读;脏页需回写;匿名页可能写入交换空间。持续的高缺页率导致系统大部分时间在调页,称为抖动(thrashing)。

内存压力、回收与 OOM ​

内存紧张时,Linux 通常先回收可再生或可换出的页面,而不是立即杀进程:

  • 干净文件页(page cache、可重新读取的文件映射)可直接丢弃;
  • 脏文件页要先回写,才能释放页框;
  • 匿名页(堆、栈等)没有文件后备,必要时可换出到 swap;没有 swap 或 swap 不足时压力更大;
  • 后台回收(如 kswapd)会提前尝试维持空闲页;若不足,触发分配的线程可能进入直接回收并产生明显延迟;
  • 回收和分配仍无法满足约束时,内核或 cgroup 会触发 OOM 处理,选择牺牲进程释放资源。

OOM 并不只是“物理内存占用最大的进程必然被杀”:选择还会考虑 OOM score、内存 cgroup、进程可调整的 oom_score_adj、权限和系统保留任务等因素。服务端应监控 RSS、工作集、major page fault、swap in/out、direct reclaim、cgroup memory events 与 OOM kill,并设置合理内存上限和背压,而不是依赖 OOM 作为容量控制。

常见页面置换算法 ​

算法核心思想特点
OPT淘汰未来最长时间不会被访问的页理论最优,需要预知未来,不能实际实现,常作基准
FIFO淘汰最早进入内存的页简单,但可能出现 Belady 异常
LRU淘汰最近最久未使用的页利用时间局部性;严格实现成本高
LFU淘汰访问次数最少的页利用频率局部性;需处理“历史热点”和计数老化
Clock / Second Chance环形扫描访问位,访问位为 1 则清零并跳过,为 0 则淘汰近似 LRU、实现成本低,操作系统常见

现代系统通常采用 LRU 的近似与多队列/工作集等策略,而非直接实现教材中的单一算法。

fork 与写时复制(Copy On Write,COW) ​

fork() 创建子进程时,内核需要复制进程的任务状态、虚拟内存区域描述和页表结构;它通常不立即复制全部用户物理页。父子进程的私有页先共同指向原来的物理页,并以只读/COW 方式映射:

一方首次写入时触发保护异常,内核才分配新物理页、复制旧内容并更新写入方页表,随后重新执行那条写指令。这样 fork 的初始成本与被修改的页面数解耦,特别适合“fork 后很快 exec”的模型。

边界:COW 不是所有映射的通用规则;显式共享内存、MAP_SHARED 映射本来就应让写入可见。私有文件映射则可能在写入时产生私有 COW 页。fork 后的多线程程序在子进程中调用非 async-signal-safe 函数也有锁状态风险,通常应尽快 exec 或使用更合适的进程创建接口。

malloc、brk 与 mmap 的实现边界 ​

malloc 是 C/C++ 运行库提供的分配器接口,不是系统调用。常见 Linux 分配器会混合使用:

  • brk/sbrk 风格堆扩展:移动进程 program break,适合从传统 heap arena 获取连续虚拟地址;
  • 匿名 mmap:单独创建虚拟内存映射,适合较大块、特殊对齐或独立释放等需求;
  • 线程缓存、多个 arena、批量缓存:减少多线程锁竞争和频繁陷入内核。

“申请小于/大于某个固定阈值必走 brk/mmap”是错误的面试结论。阈值和策略会随 glibc/其他分配器版本、运行时负载、arena、配置和大小动态变化;释放小对象也可能只是回到分配器缓存,而不立即归还 OS。new 通常在分配原始存储后构造对象,底层未被 C++ 标准要求必须调用 malloc。C API 与对象生命周期边界见 字节操作与 C API。

C/C++ 常见内存区域(概念模型) ​

不同 ABI、编译器和操作系统的具体布局不同,以下是理解生命周期的常见划分:

区域内容生命周期/管理
代码段(text)可执行指令,常为只读可共享程序映像存在期间
只读数据字符串字面量、只读常量等程序映像存在期间
数据段(data)已初始化的全局/静态对象静态存储期
BSS零初始化的全局/静态对象静态存储期
堆(heap)动态分配对象及分配器管理区域显式释放或 RAII 管理
栈(stack)调用帧、自动局部对象、部分参数作用域/函数返回时自动销毁

“栈上/堆上”描述的是常见实现与分配方式,不应代替 C++ 的正式概念:自动、动态、静态和线程存储期。

堆与栈 ​

  • 栈通常按调用帧后进先出管理,分配/回收快但容量有限;深递归或大局部数组可能栈溢出。
  • 堆由分配器管理,适合运行期大小或跨作用域生命周期的对象;分配成本和碎片问题更复杂。
  • C++ 中应优先让对象由值、容器和智能指针按 RAII 管理,而不是把“手动 new/delete”当作默认方式。

使用 Markdown 与 VitePress 构建