Appearance
虚拟内存与页面置换
什么是虚拟内存
每个进程看到的是独立、连续的虚拟地址空间。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”当作默认方式。