文章目录
前言
这篇文章把操作系统内存管理的核心概念从头捋一遍——虚拟内存、页表、分段分页、写时复制、malloc 原理、内存不足时 OOM 怎么处理。不是文档搬运,是从"为什么需要它"开始讲清楚每一个机制。
面试被问到内存管理,很多人能背概念,但说不清楚背后的逻辑。这篇文章就是为了解决这个问题。
一、操作系统为什么要搞虚拟内存
最直接的答案:让多个程序安全地共用同一套硬件。
没有虚拟内存,两个程序同时写同一块物理地址,数据直接乱掉。所以 OS 给每个进程画了一个"私有地址空间",这就是虚拟内存。
虚拟内存带来三个核心好处:
1. 突破物理内存限制
程序实际运行符合局部性原理——不是所有代码同时跑,不常用的页可以换出到磁盘 swap 区,腾出物理内存给当前活跃的进程。
2. 进程隔离
每个进程有自己的页表,相互不可见。进程 A 的虚拟地址 0x1000 和进程 B 的 0x1000 映射到不同物理页,互不干扰。
3. 内存访问保护
页表项里有权限标记位,可以控制某块内存只读、可读写、不可执行。这是内存安全的基础。
二、分段 vs 分页,到底什么区别
很多人在这里混。用一张对比表说清楚:
分段 分页
划分依据 逻辑(代码/数据/栈) 固定大小(4KB)
大小 可变 固定
碎片类型 外部碎片(段间空洞) 内部碎片(页内浪费)
用户感知 有意义 无意义(就是个内存块)
现代使用 基本废弃 主流
分段是给程序员看的抽象,分页是给硬件用的实现。
分段为什么有外部碎片?
段大小可变,段之间会出现不规则空洞,时间久了内存碎成筛子,难以复用。
分页为什么没有外部碎片?
页大小固定,任何空闲页都能立刻分配给任何进程,不存在"这块空洞太小用不了"的问题。
Linux 实际上绕过了 x86 的段机制(段基址全设 0),底层完全用页表管理内存。
三、虚拟地址变成物理地址的完整过程
以 x86-64 四级页表为例,走一遍完整流程。
第一步:CPU 产生虚拟地址
执行:mov rax, [0x7fff12345678]
虚拟地址 0x7fff12345678 送给 MMU
第二步:查 TLB(CPU 内部缓存)
TLB 命中 → 直接拿物理地址,结束
TLB 未命中 → 去内存查页表
第三步:拆解虚拟地址(48位,分5段)
[47:39] PML4 索引(9位)
[38:30] PDP 索引(9位)
[29:21] PD 索引(9位)
[20:12] PT 索引(9位)
[11:0] 页内偏移(12位)
第四步:逐级查页表(四次内存访问)
CR3 → PML4 表基地址
↓ 用[47:39]索引
PML4 表项 → PDP 表基地址
↓ 用[38:30]索引
PDP 表项 → PD 表基地址
↓ 用[29:21]索引
PD 表项 → PT 表基地址
↓ 用[20:12]索引
PT 表项 → 物理页帧号(PFN)
第五步:拼出物理地址
物理地址 = 物理页帧号 × 4096 + 页内偏移
写入 TLB,下次直接命中
第六步:访问,或触发缺页
页表项 Present=1 → 访问物理内存,完成
页表项 Present=0 → 缺页异常
→ OS 从磁盘换入页
→ 更新页表项
→ 重新执行指令
四、程序的内存布局长什么样
一个进程的虚拟地址空间,从低到高分成 6 块:
高地址
┌──────────────┐
│ 栈 stack │ 局部变量、函数调用帧,向下增长,默认 8MB
├──────────────┤
│ 文件映射段 │ 动态库、mmap 分配的内存
├──────────────┤
│ 堆 heap │ malloc 在这里分配,向上增长
├──────────────┤
│ BSS 段 │ 未初始化全局变量(程序启动时清零)
├──────────────┤
│ 数据段.data │ 已初始化全局变量、静态变量
├──────────────┤
│ 代码段.text │ 可执行指令,只读
├──────────────┤
│ 保留区 │ 防止空指针解引用,故意不映射
低地址
代码段下面那块"保留区"为什么存在?因为大多数系统认为小地址(接近 0)是非法地址,NULL 指针就是 0x0。保留区让空指针解引用直接崩溃而不是读到脏数据,这是一种主动保护。
五、malloc 背后在干什么
malloc 不是系统调用,是 C 库函数。它向 OS 申请内存有两种方式,取决于申请大小:
< 128KB → brk() 把堆顶指针向上移动
> 128KB → mmap() 在文件映射区分配匿名内存
关键细节:malloc 申请的是虚拟内存,不是物理内存。
malloc(1MB)
→ OS 划出一段虚拟地址范围
→ 此时不分配物理内存
→ 第一次读写 → 缺页中断 → OS 才分配物理页
这就是"延迟分配"。好处是程序申请了大内存但实际只用一部分时,不会浪费物理资源。
malloc(1KB) vs malloc(1MB) 的区别:
| malloc(1KB) | malloc(1MB) | |
|---|---|---|
| 系统调用 | brk() | mmap() |
| 位置 | 堆段 | 文件映射段 |
| 释放方式 | free 后可能不归还 OS | free 后立即归还 OS |
brk 方式释放内存后 glibc 会缓存起来复用,不立刻还给 OS。mmap 方式 free 后直接 munmap,物理内存立刻释放。
六、写时复制(Copy-on-Write)
fork() 创建子进程时,如果立刻复制父进程的全部物理内存,速度会很慢(几百 MB 数据全拷贝)。
COW 的策略:先共享,真正要写的时候再复制。
fork() 之后:
父页表 ──┐
├──→ 同一块物理内存(只读标记)
子页表 ──┘
子进程写某页 → 触发写保护中断
→ OS 复制那一页到新物理地址
→ 子页表指向新页(可写)
→ 父页表继续指向原页
→ 两者独立
只复制被写到的页,其余页继续共享。
这对 fork() 后立刻 exec() 的场景极度友好(shell 执行命令就是这样),因为根本不会触发写操作,COW 开销接近零。
七、内存不足时发生什么
malloc() → 申请虚拟内存 → 访问时缺页中断
→ 内核查空闲物理内存
↓ 有空闲
直接分配,建立页表映射
↓ 没有空闲
开始回收内存
├─ 后台回收(kswapd):异步,不阻塞进程
└─ 直接回收(direct reclaim):同步,阻塞进程
↓ 回收后仍不够
OOM Killer 出手
→ 选一个占内存最多的进程,直接杀掉
→ 循环直到释放足够内存
哪些内存可以被回收?
- 文件页:内核缓存的磁盘/文件数据。干净页直接释放,脏页先写回磁盘再释放。
- 匿名页:堆、栈这类没有文件载体的数据。通过 Swap 换出到磁盘,释放物理内存。
回收顺序基于 LRU 算法,优先回收 inactive_list 里最久未访问的页。
八、页面置换算法
缺页时内存满了,要换出一页给新页腾位置。选谁换出,靠置换算法:
| 算法 | 全称 | 策略 | 缺点 |
|---|---|---|---|
| OPT | 最佳置换 | 置换未来最久不用的页 | 需要预知未来,不可实现 |
| FIFO | 先进先出 | 置换最早进来的页 | 可能置换热页(Belady 异常) |
| LRU | 最近最久未使用 | 置换最久没被访问的页 | 实现开销大 |
| Clock | 时钟算法 | LRU 的近似实现 | 精度低于 LRU |
| LFU | 最不常用 | 置换访问次数最少的页 | 历史热页难以淘汰 |
Linux 实际用的是 LRU 的变体——active/inactive 双链表,是 Clock 思路的工程化实现。
总结
操作系统内存管理的核心逻辑就一条:对上提供抽象,对下管理物理资源。
- 虚拟内存:隔离进程、突破物理限制
- 页表+MMU:虚拟地址到物理地址的翻译机制
- 分页:替代分段,解决外部碎片
- 缺页机制:延迟分配,按需加载
- COW:fork 时的惰性复制
- OOM:最后的兜底手段
把这条链打通,面试里关于内存的问题基本都能回答了。
下一篇:进程调度——CPU 时间怎么分配给多个进程
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_87961121/article/details/163082870




