关注

操作系统内存管理全解:虚拟内存、页表、COW、malloc、OOM一篇搞定

前言

这篇文章把操作系统内存管理的核心概念从头捋一遍——虚拟内存、页表、分段分页、写时复制、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 后可能不归还 OSfree 后立即归还 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

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--