操作系统内存管理全解:虚拟内存、页表、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 后可能不归还 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 时间怎么分配给多个进程


相关推荐
tachibana21 小时前
hot100 前 K 个高频元素(347)
java·数据结构·算法·leetcode
AOwhisky1 小时前
Python 学习笔记(第五期)——组合数据类型:列表、元组、集合与字典精讲——核心知识点自测与详解
开发语言·笔记·python·学习·云计算
砚凝霜1 小时前
软考网络工程师|第 2 章 信道延迟、传输介质、数据编码、数字调制、PCM 完整备考笔记
网络·笔记·pcm
万亿少女的梦1681 小时前
基于Spring Boot、Java与MySQL的网络订餐系统设计与实现
java·spring boot·mysql·系统设计·网络订餐
To_OC9 小时前
LC 131 分割回文串:刚学回溯时,我连怎么切字符串都想不明白
javascript·算法·leetcode
咩咩啃树皮10 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
旖-旎10 小时前
LeetCode 518:零钱兑换||(完全背包)—— 题解
c++·算法·leetcode·动态规划·背包问题
鱟鲥鳚10 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
To_OC10 小时前
LC 42 接雨水:暴力超时卡半天?前后缀数组一用就通了
javascript·算法·leetcode