跳转至

04 - 内存管理

内存管理与地址映射示意图

建议学习时间:3 小时 🎯 难度等级:⭐⭐⭐⭐ 📋 前置知识:进程概念、CPU 调度基础、计算机组成原理基础


📋 本章目录


一、内存层次结构

现代计算机采用多级存储体系,在访问速度、容量和成本之间取得平衡。越靠近 CPU 的存储器速度越快、容量越小、价格越贵。

1.1 存储层次总览

Text Only
           ┌──────────┐
           │  寄存器   │  ← 最快,纳秒以下
           ├──────────┤
           │ L1 Cache │  ← ~1ns
           ├──────────┤
           │ L2 Cache │  ← ~3-10ns
           ├──────────┤
           │ L3 Cache │  ← ~10-30ns
           ├──────────┤
           │ 内存DRAM  │  ← ~50-100ns
           ├──────────┤
           │  SSD     │  ← ~100μs
           ├──────────┤
           │ 机械硬盘  │  ← ~5-10ms
           └──────────┘

1.2 各层延迟与容量对比

存储层级 典型延迟 典型容量 每 GB 成本(估) 说明
寄存器 < 1ns 几百 B ~ 几 KB CPU 内部,速度最快
L1 Cache ~1ns(约 4 个时钟周期) 32KB ~ 64KB / 核 分为 I-Cache 与 D-Cache
L2 Cache ~3-10ns(约 10 个时钟周期) 256KB ~ 1MB / 核 每核独享
L3 Cache ~10-30ns(约 30-40 个时钟周期) 8MB ~ 64MB 多核共享
内存(DRAM) ~50-100ns 8GB ~ 512GB $3-5 主存,断电丢失
SSD(NVMe) ~10-100μs 256GB ~ 8TB $0.1-0.3 闪存,非易失性
机械硬盘(HDD) ~5-10ms 1TB ~ 20TB $0.02-0.05 旋转盘片,寻道时间长

💡 关键洞察:从 L1 缓存到磁盘,访问延迟增大了约 1000 万倍。这解释了为什么虚拟内存中的缺页中断代价极高。

1.3 局部性原理

内存层次结构之所以有效,依赖于程序的局部性原理(Locality)

  • 时间局部性:最近访问的数据很可能在不久的将来再次被访问(如循环变量)
  • 空间局部性:访问某地址后,其附近的地址也有很大概率被访问(如数组遍历)

二、地址空间

2.1 物理地址 vs 虚拟地址

概念 说明
物理地址(Physical Address) 内存芯片上的实际地址,由地址总线直接寻址
虚拟地址(Virtual Address) 进程看到的逻辑地址,由 CPU 生成,需要转换为物理地址
地址空间 一个进程可使用的所有虚拟地址的集合

2.2 地址转换流程(MMU 的角色)

MMU(Memory Management Unit,内存管理单元) 是 CPU 内部的硬件模块,负责将虚拟地址实时翻译为物理地址。

Text Only
  CPU 产生虚拟地址
  ┌───────────┐     命中      ┌─────────┐
  │   TLB     │──────────────→│ 物理地址 │──→ 访问内存
  └───────────┘               └─────────┘
        │ 未命中
  ┌───────────┐     找到      ┌─────────┐
  │  页表查询  │──────────────→│ 物理地址 │──→ 更新 TLB → 访问内存
  └───────────┘               └─────────┘
        │ 页不在内存(缺页)
  ┌──────────────┐
  │ 缺页中断处理  │──→ 从磁盘调入页面 → 更新页表 → 重新执行指令
  └──────────────┘

2.3 为什么需要虚拟内存?

  1. 进程隔离:每个进程拥有独立的虚拟地址空间,一个进程无法直接访问另一个进程的内存,保证安全性
  2. 超额使用(Overcommit):所有进程的虚拟地址空间之和可以远大于物理内存,因为不是所有页面都需要同时驻留在内存中
  3. 简化编程:程序员不需要关心物理内存的实际布局,每个进程都"以为"自己独占整个地址空间
  4. 共享内存:多个进程可以将各自的虚拟页面映射到同一物理页面,实现高效的进程间通信
  5. 内存映射文件:可以把文件映射到虚拟地址空间,像访问内存一样访问文件

三、分页机制

3.1 页与页框

  • 页(Page):虚拟地址空间被划分为固定大小的块,称为"页"。典型大小:4KB(也有 2MB、1GB 大页)
  • 页框(Frame):物理内存被划分为与页相同大小的块,称为"页框"或"帧"
Text Only
虚拟地址空间           物理内存
┌─────────┐           ┌─────────┐
│  页 0    │──────────→│ 帧 2    │
├─────────┤           ├─────────┤
│  页 1    │──────┐   │ 帧 0    │
├─────────┤      │   ├─────────┤
│  页 2    │──┐  └──→│ 帧 5    │
├─────────┤  │      ├─────────┤
│  页 3    │  │      │ 帧 1    │
├─────────┤  │      ├─────────┤
│  ...     │  └─────→│ 帧 3    │
└─────────┘          ├─────────┤
                     │ 帧 4    │
                     ├─────────┤
                     │ ...     │
                     └─────────┘

地址分解(以 32 位地址、4KB 页为例):

Text Only
虚拟地址(32位):
┌──────────────────────┬────────────┐
│   页号(20位)        │ 页内偏移(12位)│
│   可寻址 2^20 = 1M 页 │ 4KB 内偏移   │
└──────────────────────┴────────────┘

3.2 页表结构(页号→帧号映射)

页表(Page Table) 存储在内存中,每个进程有一张独立的页表。

页号 帧号 存在位 修改位 访问位 保护位
0 2 1 0 1 R/W
1 5 1 1 1 R/W
2 3 1 0 0 R
3 0

3.3 页表项(PTE)结构

Text Only
┌───────┬───────┬───────┬───────┬───────┬──────────────┐
│存在位P│修改位D│访问位A│保护位  │缓存禁止│  帧号(PFN)  │
│ 1bit  │ 1bit  │ 1bit  │ 2bit  │ 1bit  │   20bit      │
└───────┴───────┴───────┴───────┴───────┴──────────────┘

各标志位含义:

标志位 全称 说明
P(Present) 存在位 =1 表示该页在物理内存中;=0 表示在磁盘上(缺页)
D(Dirty) 修改位 =1 表示该页被修改过,换出时需要写回磁盘
A(Accessed) 访问位 =1 表示该页被访问过,供页面置换算法参考
R/W 保护位 控制读/写/执行权限

3.4 多级页表

问题:32 位地址空间 + 4KB 页 → 需要 2^20 = 1M 个页表项。若每项 4 字节,一张页表占 4MB!而大部分地址空间是空的。

解决方案:多级页表,只为实际使用的地址区间创建页表。

二级页表(32 位系统)

Text Only
虚拟地址(32位):
┌────────────┬────────────┬────────────┐
│一级页号(10位)│二级页号(10位)│ 页内偏移(12位)│
└────────────┴────────────┴────────────┘
       │              │            │
       ▼              ▼            │
  ┌─────────┐   ┌─────────┐       │
  │一级页表  │──→│二级页表  │──→ 帧号 + 偏移 → 物理地址
  │(页目录)  │   │(1024项) │
  │(1024项)  │   └─────────┘
  └─────────┘

四级页表(64 位系统,x86-64)

Text Only
虚拟地址(48位有效):
┌──────┬──────┬──────┬──────┬────────────┐
│PML4  │PDPT  │PD    │PT    │ Offset     │
│(9位) │(9位) │(9位) │(9位) │ (12位)     │
└──────┴──────┴──────┴──────┴────────────┘
  L4      L3     L2     L1     页内偏移

💡 Linux 从 4.14 内核开始支持五级页表,用于支持 57 位虚拟地址空间(128PB)。

3.5 TLB(翻译后备缓冲区)

TLB(Translation Lookaside Buffer) 是 MMU 中的高速缓存,缓存最近使用的页表项,避免每次访问内存都查页表。

Text Only
CPU 生成虚拟地址
   ┌─────────┐
   │ 查 TLB   │
   └─────────┘
    /        \
 命中         未命中
  │            │
  ▼            ▼
直接得到     查询页表
物理地址      │
  │          ┌┴┐
  │        命中  缺页
  │          │    │
  │       更新TLB  缺页中断
  │          │    │
  ▼          ▼    ▼
      访问物理内存
参数 典型值
TLB 容量 64 ~ 1024 条目
TLB 命中延迟 0.5 ~ 1 个时钟周期
TLB 命中率 > 99%(利用局部性)
TLB 未命中代价 10 ~ 100 个时钟周期

四、分段机制

4.1 段的概念

分段将进程的地址空间按逻辑功能划分为若干段,每个段有独立的起始地址和长度。

Text Only
进程的虚拟地址空间(典型布局):

高地址 ┌────────────────┐
       │  内核空间       │
       ├────────────────┤  ← 用户/内核边界
       │  栈(Stack)    │  ↓ 向低地址增长
       │                │
       │  (空闲区域)   │
       │                │
       │  堆(Heap)     │  ↑ 向高地址增长
       ├────────────────┤
       │  BSS段(未初始化)│
       ├────────────────┤
       │  数据段(Data)  │
       ├────────────────┤
       │  代码段(Text)  │
低地址 └────────────────┘

4.2 段表结构

段号 段基址 段长度 保护
0(代码段) 0x00400000 0x10000 R/X
1(数据段) 0x00600000 0x08000 R/W
2(堆) 0x00700000 0x20000 R/W
3(栈) 0x7FFF0000 0x10000 R/W

虚拟地址 = 段号 + 段内偏移 地址转换:物理地址 = 段基址 + 段内偏移(若偏移 < 段长度,否则越界中断)

4.3 分页 vs 分段对比

特性 分页 分段
划分依据 物理大小(固定) 逻辑功能(可变)
用户可见性 对用户透明 用户可感知
地址空间 一维(页号+偏移) 二维(段号+偏移)
内部碎片 有(最后一页不满)
外部碎片 有(段大小不等)
共享与保护 以页为单位(粒度粗) 以段为单位(更自然)

4.4 段页式管理

段页式 = 分段 + 分页 的结合:先分段,再在每个段内分页。

Text Only
虚拟地址:
┌────────┬────────────┬────────────┐
│ 段号    │ 段内页号     │ 页内偏移     │
└────────┴────────────┴────────────┘
    │          │              │
    ▼          ▼              │
  段表 ──→ 该段的页表 ──→ 帧号 + 偏移 → 物理地址

💡 x86-64 的实际做法:虽然硬件同时支持分段和分页,但现代 Linux/Windows 实际上将所有段基址设为 0,段限长设为最大,等效于只用分页


五、虚拟内存

5.1 请求分页原理

请求分页(Demand Paging) 是虚拟内存的核心机制:

  • 进程启动时,不需要把所有页面都加载到物理内存
  • 只有当 CPU 访问一个不在内存中的页面时,才触发缺页中断(Page Fault),从磁盘将该页调入内存
  • 这是一种懒加载(Lazy Loading)策略

5.2 缺页中断处理流程

Text Only
  CPU 访问虚拟地址
  查 TLB → 未命中 → 查页表
  页表项存在位 P = 0?
       Yes → 缺页中断!
  ① 保存当前进程上下文
  ② 检查地址是否合法
     (是否在进程的虚拟地址空间内?)
     非法 → 段错误(Segfault),终止进程
     合法 ↓
  ③ 在物理内存中找一个空闲帧
     ├── 有空闲帧 → 直接使用
     └── 无空闲帧 → 调用页面置换算法选择"牺牲页"
                    ├── 牺牲页修改位=1 → 写回磁盘
                    └── 牺牲页修改位=0 → 直接丢弃
  ④ 从磁盘读取缺失页到选定帧(磁盘 I/O,最耗时!)
  ⑤ 更新页表(设 P=1,填入帧号)、更新 TLB
  ⑥ 恢复进程上下文,重新执行引发缺页的指令

5.3 缺页率与性能影响

设: - 内存访问时间 = 100ns - 缺页处理时间 = 8ms = 8,000,000ns(含磁盘 I/O) - 缺页率 = p

有效访问时间 = (1-p) × 100ns + p × 8,000,000ns

缺页率 p 有效访问时间 性能下降
0(无缺页) 100ns
0.001(千分之一) 8,100ns 81 倍
0.000001 108ns 1.08 倍

⚠️ 结论:缺页率必须极低(< 10^-6),否则性能急剧下降。这是页面置换算法如此重要的原因。

5.4 工作集模型与抖动(Thrashing)

工作集(Working Set):在一段时间窗口 Δ 内,进程实际访问的页面集合。

抖动(Thrashing):当系统为一个进程分配的物理帧数远少于其工作集大小时: - 频繁缺页 → 大量磁盘 I/O → CPU 利用率暴跌 - OS 以为 CPU 空闲,引入更多进程 → 情况更糟

Text Only
CPU利用率
   │     ╱╲
   │    ╱  ╲
   │   ╱    ╲
   │  ╱      ╲  ← 抖动发生
   │ ╱        ╲
   │╱          ╲________
   └──────────────────── 进程数(多道程度)

解决策略: - 使用工作集模型,确保每个进程获得足够的帧 - 采用PFF(Page Fault Frequency)策略:监控缺页率,过高则多分配帧,过低则回收帧 - 若资源不足,挂起部分进程


六、页面置换算法

当物理内存帧已满且发生缺页时,需要选择一个"牺牲页"换出。置换算法的目标是最小化缺页率

6.1 OPT(最优置换算法)

原理:替换未来最长时间内不会再被访问的页面。

特点:理论最优,无法实现(需要预知未来),仅用作基准参考

示例:页面引用串 7,0,1,2,0,3,0,4,2,3,0,3,帧数 = 3

访问 7 0 1 2 0 3 0 4 2 3 0 3
帧1 7 7 7 2 2 2 2 2 2 2 2 2
帧2 0 0 0 0 0 0 4 4 4 0 0
帧3 1 1 1 3 3 3 3 3 3 3
缺页

缺页次数:7

Python
def opt_page_replacement(pages, frame_size):
    """OPT 最优页面置换算法"""
    frames = []
    page_faults = 0

    for i, page in enumerate(pages):
        if page not in frames:
            page_faults += 1
            if len(frames) < frame_size:
                frames.append(page)
            else:
                # 找未来最远使用的页面
                farthest = -1
                victim = 0
                for j, frame in enumerate(frames):
                    try:  # try/except捕获异常
                        next_use = pages[i + 1:].index(frame)
                    except ValueError:
                        next_use = float('inf')  # 未来不再使用
                    if next_use > farthest:
                        farthest = next_use
                        victim = j
                frames[victim] = page

    return page_faults

pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"OPT 缺页次数: {opt_page_replacement(pages, 3)}")  # 输出: 9

6.2 FIFO(先进先出)

原理:替换最早进入内存的页面(先来先走)。

优点:实现简单(队列) 缺点:可能换出仍在频繁使用的页面;存在 Belady 异常

Belady 异常

📋 Belady 异常:增加物理帧数,缺页次数反而增多!该现象常见于 FIFO 等非栈算法;而 LRU、OPT 等栈算法不会出现。

经典反例:页面引用串 1,2,3,4,1,2,5,1,2,3,4,5

帧数 缺页次数
3 9
4 10
Python
from collections import deque

def fifo_page_replacement(pages, frame_size):
    """FIFO 先进先出页面置换算法"""
    frames = deque(maxlen=frame_size)
    page_set = set()
    page_faults = 0

    for page in pages:
        if page not in page_set:
            page_faults += 1
            if len(frames) == frame_size:
                removed = frames.popleft()
                page_set.discard(removed)
            frames.append(page)
            page_set.add(page)

    return page_faults

# 演示 Belady 异常
pages = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
print(f"FIFO (3帧): {fifo_page_replacement(pages, 3)} 次缺页")  # 9
print(f"FIFO (4帧): {fifo_page_replacement(pages, 4)} 次缺页")  # 10 → Belady 异常!

6.3 LRU(最近最久未使用)

原理:替换最长时间没有被访问的页面。基于时间局部性——过去最久没用的,将来可能也不会用。

优点:性能接近 OPT,无 Belady 异常 缺点:实现开销较大

实现方式一:栈实现

每次访问一个页面,将其移到栈顶。置换时弹出栈底元素。

实现方式二:计数器实现

为每个页面维护最后访问时间戳,置换时选时间戳最小的。

Python
def lru_page_replacement(pages, frame_size):
    """LRU 最近最久未使用页面置换算法 — 栈实现"""
    frames = []
    page_faults = 0

    for page in pages:
        if page not in frames:
            page_faults += 1
            if len(frames) >= frame_size:
                frames.pop(0)  # 移除最久未使用的(栈底)
            frames.append(page)
        else:
            frames.remove(page)    # 从原位置移除
            frames.append(page)    # 放到栈顶(最近使用)

    return page_faults

# 测试
pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LRU 缺页次数: {lru_page_replacement(pages, 3)}")  # 输出: 12
Python
def lru_counter_implementation(pages, frame_size):
    """LRU 计数器实现"""
    frames = {}  # {page: last_access_time}
    page_faults = 0

    for time, page in enumerate(pages):
        if page not in frames:
            page_faults += 1
            if len(frames) >= frame_size:
                # 找到最久未使用的页面(last_access_time 最小)
                victim = min(frames, key=frames.get)
                del frames[victim]
            frames[page] = time
        else:
            frames[page] = time  # 更新访问时间

    return page_faults

pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LRU (计数器) 缺页次数: {lru_counter_implementation(pages, 3)}")

6.4 LFU(最不经常使用)

原理:替换访问频率最低的页面。频率相同时,替换最早进入的。

优点:考虑了页面的使用频率 缺点:需要维护频率计数;早期高频页面可能长期占据内存("缓存污染")

Python
from collections import defaultdict  # defaultdict带默认值的字典,避免KeyError

def lfu_page_replacement(pages, frame_size):
    """LFU 最不经常使用页面置换算法"""
    frames = set()
    freq = defaultdict(int)       # 页面访问频率
    first_access = {}             # 页面首次进入时间(频率相同时 FIFO)
    page_faults = 0

    for time, page in enumerate(pages):  # enumerate同时获取索引和值
        if page in frames:
            freq[page] += 1
        else:
            page_faults += 1
            if len(frames) >= frame_size:
                # 找频率最低的页面,频率相同选最早的
                victim = min(frames, key=lambda p: (freq[p], first_access[p]))  # lambda匿名函数:简洁的单行函数
                frames.discard(victim)
                del freq[victim]
                del first_access[victim]
            frames.add(page)
            freq[page] = 1
            first_access[page] = time

    return page_faults

pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"LFU 缺页次数: {lfu_page_replacement(pages, 3)}")

6.5 Clock 算法(时钟 / 二次机会算法)

原理:FIFO 的改进版。维护一个循环队列和一个指针,每个页面有一个访问位(A): 1. 页面被访问时,设 A = 1 2. 需要置换时,指针沿环扫描: - 若 A = 0 → 替换该页面 - 若 A = 1 → 给"第二次机会",将 A 置 0,指针移到下一个

Text Only
        指针 →
        ┌───┐
    ┌───│ A=1│←── 访问过,置0,跳过
    │   └───┘
    │     ↓
  ┌───┐ ┌───┐
  │A=0│ │A=0│←── A=0,替换此页!
  └───┘ └───┘
    ↑     ↓
    │   ┌───┐
    └───│A=1│←── 访问过,置0,跳过
        └───┘
Python
def clock_page_replacement(pages, frame_size):
    """Clock 时钟页面置换算法"""
    frames = [None] * frame_size
    use_bit = [0] * frame_size     # 访问位
    pointer = 0                     # 时钟指针
    page_faults = 0
    count = 0                       # 当前帧中实际有页面的数量

    for page in pages:
        # 检查是否已在帧中
        found = False
        for i in range(frame_size):
            if frames[i] == page:
                use_bit[i] = 1  # 标记为最近使用
                found = True
                break

        if not found:
            page_faults += 1
            if count < frame_size:
                # 还有空闲帧
                frames[count] = page
                use_bit[count] = 1
                count += 1
            else:
                # 时钟算法扫描
                while use_bit[pointer] == 1:
                    use_bit[pointer] = 0  # 给第二次机会
                    pointer = (pointer + 1) % frame_size
                frames[pointer] = page
                use_bit[pointer] = 1
                pointer = (pointer + 1) % frame_size

    return page_faults

pages = [7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1]
print(f"Clock 缺页次数: {clock_page_replacement(pages, 3)}")

6.6 改进 Clock 算法

在基本 Clock 算法基础上,同时考虑访问位(A)修改位(D),优先替换"未访问且未修改"的页面(换出代价最低)。

优先级(从高到低选择替换):

类别 (A, D) 含义 替换优先级
第1类 (0, 0) 未访问、未修改 最优先替换
第2类 (0, 1) 未访问、已修改 次优先
第3类 (1, 0) 已访问、未修改 再次
第4类 (1, 1) 已访问、已修改 最后替换

扫描过程: 1. 第一轮:找 (0,0) 的页面,不修改任何位 2. 第二轮:找 (0,1) 的页面,同时将扫描过的 A 位清 0 3. 若仍未找到,回到第一轮重新扫描(此时已有页面的 A 被清 0)

6.7 算法对比总结

算法 实现复杂度 性能 Belady 异常 实际使用
OPT 最优(基准) 无法实现
FIFO ★☆☆ 较差 极少单独使用
LRU ★★★ 很好 思想广泛应用
LFU ★★★ 适合热点数据
Clock ★★☆ 接近 LRU Linux 实际使用
改进 Clock ★★☆ 优于 Clock 实际 OS 使用

七、内存分配策略

7.1 连续内存分配

当空闲内存以多个不连续的"空洞"形式存在时,如何为新进程分配内存?

策略 描述 优点 缺点
首次适配(First Fit) 从头扫描,找到第一个足够大的空闲块 速度快,倾向于保留高地址大块 低地址产生多碎片
最佳适配(Best Fit) 最小的能满足需求的空闲块 留下尽量大的空闲块 产生大量极小碎片
最差适配(Worst Fit) 最大的空闲块分配 剩余空间可能仍可用 大作业可能无法分配

💡 实践中,First Fit 通常表现最好,因为它搜索简单且分布合理。

7.2 伙伴系统(Buddy System)

Linux 内核用于管理物理页面的分配算法。

核心思想: 1. 内存按 2 的幂 划分(1页、2页、4页、8页、…) 2. 分配时,找到最小的满足需求的 2^k 大小块 3. 若块太大,对半分裂,一半分配,一半放入空闲链表 4. 释放时,检查"伙伴"(地址相邻的同级块)是否空闲,若是则合并

Text Only
初始: 1024KB 完整空闲
分配 100KB → 需要 128KB → 分裂过程:
1024 → [512 | 512]
        [512 | [256 | 256]]
        [512 | [256 | [128 | 128]]]
                             ↑ 分配这个

7.3 Slab 分配器(Linux 内核)

伙伴系统按页分配,但内核中大量小对象(如 task_structinode)远小于一页。

Slab 分配器 解决这一问题: - 预先分配一组相同大小对象的缓存池(Slab) - 分配/释放对象时直接从 Slab 中取/还,极快 - 避免反复执行伙伴系统的分裂/合并操作

Text Only
Slab Cache (task_struct):
┌──────┬──────┬──────┬──────┬──────┐
│ obj1 │ obj2 │ obj3 │ obj4 │ obj5 │  ← 预分配的同类型对象
│ 使用  │ 使用  │ 空闲  │ 空闲  │ 使用  │
└──────┴──────┴──────┴──────┴──────┘

查看系统 Slab 信息:

Bash
cat /proc/slabinfo
# 或使用更友好的工具
slabtop

八、Linux 内存管理实战

8.1 /proc/meminfo 解读

Bash
cat /proc/meminfo

关键字段:

字段 含义
MemTotal 物理内存总量
MemFree 完全空闲的内存
MemAvailable 可用内存(含可回收缓存)
Buffers 块设备 I/O 缓冲区(元数据缓存)
Cached 页面缓存(文件内容缓存)
SwapTotal 交换空间总量
SwapFree 空闲交换空间
Dirty 等待写回磁盘的脏页
AnonPages 匿名页面(堆、栈等,无文件后端)

8.2 free 命令详解

Bash
free -h
Text Only
              total        used        free      shared  buff/cache   available
Mem:           15Gi       6.2Gi       1.8Gi       512Mi       7.3Gi       8.5Gi
Swap:          8.0Gi       0.5Gi       7.5Gi

⚠️ 关键理解free 值很小不代表内存不够!Linux 会尽量利用空闲内存做缓存。真正可用内存看 available 列。

8.3 vmstat 监控

Bash
vmstat 1 5   # 每秒采样一次,共5次
Text Only
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs  us sy id wa st
 1  0  51200  18432  12800 320000    0    0    12    24  150  300  15  5 78  2  0

重点关注: - si/so:Swap In / Swap Out,若持续不为 0,说明物理内存不足 - bi/bo:Block In / Block Out,磁盘 I/O - r:等待运行的进程数 - b:不可中断睡眠的进程数

8.4 OOM Killer 机制

当系统内存耗尽时,Linux 的 OOM(Out Of Memory)Killer 会选择杀掉一个进程来释放内存。

为每个进程计算 oom_score/proc/<pid>/oom_score),分数越高越可能被杀。

Bash
# 查看进程的 OOM 分数
cat /proc/$(pidof nginx)/oom_score  # $()命令替换:执行命令并获取输出

# 调整 OOM 分数(-1000 到 1000),-1000 表示永不被杀
echo -1000 > /proc/$(pidof sshd)/oom_score_adj

8.5 大页内存(Huge Pages)

默认 4KB 页面在大内存场景(数据库等)下,页表条目过多,TLB 命中率下降。大页(2MB 或 1GB)可以减少页表项数量。

Bash
# 查看大页配置
cat /proc/meminfo | grep Huge  # |管道:将前一命令的输出作为后一命令的输入

# 分配 1024 个 2MB 大页(共 2GB)
echo 1024 > /proc/sys/vm/nr_hugepages

# 挂载 hugetlbfs
mkdir -p /mnt/hugepages
mount -t hugetlbfs nodev /mnt/hugepages

💡 透明大页(THP):Linux 可自动将普通 4KB 页合并为 2MB 大页,无需应用程序修改:

Bash
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never

8.6 mmap 内存映射

mmap 将文件或设备映射到进程的虚拟地址空间,实现"像访问内存一样访问文件"。

C
// C 语言 mmap 示例
#include <sys/mman.h>  // 引入头文件
#include <fcntl.h>
#include <stdio.h>

int main() {
    int fd = open("data.txt", O_RDONLY);
    // 映射文件到内存
    char *addr = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);  // 指针:存储变量的内存地址
    printf("First byte: %c\n", addr[0]);  // 像数组一样访问
    munmap(addr, 4096);
    close(fd);
    return 0;
}

mmap 的优势: - 避免 read()/write() 的用户态/内核态数据拷贝 - 实现进程间共享内存(MAP_SHARED) - 大文件的随机访问效率高


九、面试高频题 📋

题目 1:解释虚拟内存的作用

:虚拟内存为每个进程提供独立的虚拟地址空间。主要作用:① 进程隔离——每个进程拥有独立的地址空间,互不干扰;② 超额使用——所有进程虚拟地址空间之和可大于物理内存;③ 简化编程——程序员无需关心物理内存布局;④ 内存保护——通过页表权限位防止非法访问;⑤ 共享——多个进程可映射到同一物理页面。

题目 2:什么是 TLB?为什么需要它?

:TLB(Translation Lookaside Buffer)是 MMU 中的高速缓存,缓存最近使用的"虚拟页号→物理帧号"映射。由于每次内存访问都需要查页表(存在内存中),没有 TLB 就需要两次内存访问(查页表 + 访问数据)。TLB 命中率通常 > 99%,极大减少地址翻译的开销。

题目 3:分页和分段的区别?

分页将地址空间按固定大小(如 4KB)划分,对用户透明,消除外部碎片但有内部碎片;分段按逻辑功能划分(代码段、数据段等),大小可变,用户可见,无内部碎片但有外部碎片。现代系统大多使用分页或段页结合。

题目 4:什么是 Belady 异常?哪些算法存在?

:Belady 异常是指增加物理帧数后缺页次数反而增多的反直觉现象。该现象通常出现在 FIFO 等非栈算法中;LRU、OPT 等栈算法不存在此现象——栈算法的定义是:n 个帧中的页面集合是 n+1 个帧中的子集。

题目 5:比较 FIFO、LRU、Clock 算法

:FIFO 最简单但性能最差且有 Belady 异常;LRU 性能好(接近 OPT)但硬件实现成本高;Clock 是 FIFO 和 LRU 的折中——用一个访问位和循环扫描近似 LRU 效果,实现开销低于 LRU,是 Linux 等实际 OS 采用的方案。

题目 6:什么是抖动(Thrashing)?如何解决?

:当进程分配的物理帧远小于其工作集时,频繁缺页导致大量磁盘 I/O,CPU 利用率急剧下降,这就是抖动。解决方法:① 使用工作集模型保证每个进程获得足够帧数;② 采用 PFF(页面故障频率)策略动态调整帧分配;③ 通过中级调度挂起部分进程释放帧。

题目 7:多级页表为什么能节省空间?

:单级页表必须为整个虚拟地址空间创建所有页表项(32 位系统 → 4MB 页表)。多级页表将页表分层,只有实际使用的地址区间才创建下级页表。大部分进程只使用少量地址空间,未使用区域的页表不需要分配内存,大幅减少页表占用。

题目 8:Linux 的 free 命令中 buff/cache 是什么?内存不足如何判断?

Buffers 是块设备 I/O 缓冲区,缓存磁盘元数据;Cache 是页面缓存,缓存文件内容。Linux 尽量利用空闲内存做缓存以加速 I/O,这些缓存在必要时可回收。判断内存是否不足应看 available 列,而非 free 列。若 available 持续较低且 vmstat 中 si/so > 0,说明物理内存确实不足。

题目 9:mmap 和 read/write 的区别?

read/write 需要在用户空间和内核空间之间复制数据(2 次拷贝);mmap 将文件直接映射到进程虚拟地址空间,通过缺页中断按需加载,零拷贝。mmap 更适合大文件的随机访问和进程间共享内存场景。但对小文件顺序访问,read 有时更快(mmap 有映射/解映射开销)。

题目 10:伙伴系统和 Slab 分配器分别解决什么问题?

伙伴系统解决物理页面分配问题,按 2 的幂次方分配连续物理页,通过分裂/合并减少外部碎片。Slab 分配器建立在伙伴系统之上,解决内核小对象分配问题——预分配同类型对象池,避免反复进行页面级分配/释放;对象释放后不归还给伙伴系统而是缓存在 Slab 中以供快速重用。


十、练习题 ✏️

练习 1:页面置换计算

给定页面引用串 1, 2, 3, 4, 2, 1, 5, 6, 2, 1, 2, 3, 7, 6, 3, 2, 1, 2, 3, 6,物理帧数为 4。

分别计算 FIFO、LRU 的缺页次数,并比较结果。

练习 2:地址翻译

系统采用二级页表,虚拟地址 32 位,页面大小 4KB。 1. 一级页表和二级页表各有多少项? 2. 若页表项大小为 4 字节,一个二级页表占多少空间? 3. 若一个进程只使用了 12MB 的虚拟地址空间(从地址 0 开始),实际使用了多少内存存储页表?

练习 3:缺页性能计算

假设内存访问时间为 100ns,缺页处理时间为 10ms。若期望有效访问时间不超过 200ns,缺页率 p 最大可以是多少?

练习 4:实战操作

在 Linux 系统中: 1. 使用 free -h 查看当前内存使用情况 2. 使用 vmstat 1 10 监控内存变化 3. 找到系统中 OOM score 最高的前 5 个进程 4. 查看系统是否启用了透明大页

练习 5:编程实现

用 Python 实现改进 Clock 算法(同时考虑访问位 A 和修改位 D)。要求: - 输入:页面引用串、帧数、每次访问是否为写操作 - 输出:缺页次数及每次置换的过程


📚 延伸阅读