操作系统核心原理 — 内存管理

用生活化的比喻,让你理解虚拟内存、分页、TLB、页面置换——这些是理解 GC 和内存泄漏的底层机制

前置知识:第03章 进程与线程(理解进程的虚拟地址空间)


阅读指南(初学者必看)

为什么你需要学习操作系统内存管理?

你每天都在和内存打交道——V8 的 GC 管理 JS 堆内存,JVM 的 GC 管理 Java 堆内存,但它们都是建立在操作系统内存管理之上的:

  • 为什么每个进程以为自己有 4GB/256TB 内存?虚拟内存是什么?
  • 为什么 JVM 要用大页面(Large Pages)?大页面有什么好处?
  • TLB 是什么?为什么进程切换比线程切换慢?

学完本章,你能回答:

  • 虚拟内存解决了什么问题?它是怎么工作的?
  • 分页机制的地址翻译过程是怎样的?
  • TLB 的作用是什么?为什么它对性能如此重要?
  • 页面置换算法有哪些?LRU 在游戏纹理缓存中的应用?

本文结构

第一部分:内存管理基础
第二部分:虚拟内存(为什么需要虚拟内存)
第三部分:分页机制(地址翻译过程)
第四部分:TLB(加速地址翻译的缓存)
第五部分:页面置换算法(物理内存不够时怎么办)
第六部分:内存分配算法
第七部分:垃圾回收原理

4.1 内存管理基础

内存结构

内存结构:
┌─────────────┐ 0xFFFFFFFF
│   内核空间   │
├─────────────┤
│   栈空间    │  ← 函数调用、局部变量(向下增长)
│      ↓      │
│      ·      │
│      ·      │
│      ↑      │
│   堆空间    │  ← 动态分配的对象(向上增长)
├─────────────┤
│   BSS段    │  ← 未初始化的全局变量
├─────────────┤
│   数据段    │  ← 已初始化的全局变量
├─────────────┤
│   代码段    │  ← 程序指令
└─────────────┘ 0x00000000

生活比喻:内存就像一个大型仓库。

仓库(内存):
- 货架(内存地址):存放物品
- 管理员(操作系统):分配和回收空间
- 登记簿(页表):记录物品位置

栈区:临时存放区,用完就走
堆区:长期存放区,需要申请和释放

内存管理任务

1. 内存分配
   - 为程序分配内存空间
   - 管理空闲内存

2. 内存回收
   - 回收不再使用的内存
   - 防止内存泄漏

3. 内存保护
   - 防止程序越界访问
   - 实现进程隔离

4. 内存映射
   - 虚拟地址到物理地址
   - 实现共享内存

4.2 虚拟内存

生活类比:虚拟内存就像酒店房间号。每个房间号是虚拟地址(如 301),但实际房间可能在不同楼层、不同位置(物理地址)。前台(MMU)维护一份对照表(页表),帮你从房间号找到实际位置。

为什么需要虚拟内存?

1. 进程隔离:每个进程以为自己独占整个内存
   进程 A 的 0x1000 和进程 B 的 0x1000 是不同的物理地址
   → 一个进程崩溃不会影响其他进程

2. 内存保护:不能访问其他进程的内存
   → 游戏外挂不能直接读其他进程的数据(除非用特殊手段)

3. 内存超售:虚拟地址空间 > 物理内存
   100 个进程各分配 4GB 虚拟内存,物理内存可能只有 16GB
   不常用的内存页换到磁盘(Swap)

4. 内存映射文件:文件直接映射到内存
   读写内存 = 读写文件,操作系统负责同步

地址转换

class VirtualMemory {
  constructor(physicalMemorySize, pageSize = 4096) {
    this.physicalMemory = new Array(physicalMemorySize);
    this.pageSize = pageSize;
    this.pageTable = new Map();
    this.freeFrames = [];
    let numFrames = physicalMemorySize / pageSize;
    for (let i = 0; i < numFrames; i++) {
      this.freeFrames.push(i);
    }
  }

  translate(virtualAddress) {
    let pageNumber = Math.floor(virtualAddress / this.pageSize);
    let offset = virtualAddress % this.pageSize;
    let frameNumber = this.pageTable.get(pageNumber);
    if (frameNumber === undefined) {
      frameNumber = this.allocateFrame();
      this.pageTable.set(pageNumber, frameNumber);
    }
    return frameNumber * this.pageSize + offset;
  }

  allocateFrame() {
    if (this.freeFrames.length === 0) {
      throw new Error('Out of memory');
    }
    return this.freeFrames.shift();
  }
}

4.3 分页机制

虚拟地址结构(以 32 位为例):
┌────────────┬────────────┬────────────┐
│  页目录索引  │  页表索引    │  页内偏移   │
│  (10 bits)  │  (10 bits)  │  (12 bits) │
└────────────┴────────────┴────────────┘

页面大小 = 2^12 = 4KB(最常见的页面大小)

地址翻译过程:
1. 从虚拟地址提取页目录索引 → 查页目录 → 找到页表
2. 从虚拟地址提取页表索引 → 查页表 → 找到物理页框号
3. 物理页框号 + 页内偏移 = 物理地址

CR3 寄存器 → 页目录基址
每次进程切换,CR3 更新 → 页表切换 → 虚拟地址空间切换
4KB 页面的问题:
- 大内存程序需要大量页表项
- 4GB 地址空间 = 1,048,576 个页表项 = 4MB 页表内存
- 游戏服务器 32GB 堆 → 页表占用 32MB!

解决方案:大页面(Huge Pages)
- 2MB 大页:32GB 只需 16,384 个页表项 = 128KB
- 1GB 大页:32GB 只需 32 个页表项

游戏服务器常用:
java -XX:+UseLargePages ...    # JVM 大页面
node --large-pages ...          # Node.js 大页面

4.4 TLB(Translation Lookaside Buffer)

生活类比:TLB 就像"地址簿缓存"。你经常联系的人,你会记住他的号码(TLB 命中),不用每次翻通讯录(查页表)。

TLB 是页表的缓存,在 CPU 内部,访问速度极快

TLB 命中:1~2 个时钟周期
TLB 未命中 → 查页表:100+ 个时钟周期(内存访问)

TLB 容量有限(通常 64~1536 个条目)

TLB flush 的场景:
1. 进程切换(CR3 更新 → 所有 TLB 失效)
   → 这就是进程切换比线程切换慢的主要原因!
2. 页表修改(mprotect 等)
3. 上下文切换优化:PCID(Process-Context Identifiers)
   → 给每个进程一个 ID,TLB 条目标记进程 ID
   → 切换时只 flush 当前进程的 TLB 条目

和 V8 的关系

  • V8 的隔离实例(Isolate)切换时会刷新 TLB
  • V8 使用 mmap 分配大块内存,避免频繁的页表修改
  • 大页面优化可以减少 TLB miss

4.5 页面置换算法

当物理内存不够时,需要把一些页面换到磁盘。选择哪个页面换出?

算法 原理 优劣
OPT(最优) 淘汰未来最久不使用的 理论最优,无法实现
FIFO 先进先出 简单,但可能淘汰常用页
LRU(最近最少使用) 淘汰最久没访问的 接近最优,但开销大
Clock 近似 LRU,用引用位+循环指针 实际常用
LFU(最不经常使用) 淘汰访问次数最少的 适合缓存场景
class PageReplacer {
  constructor(maxPages) {
    this.maxPages = maxPages;
    this.pages = [];
    this.pageFaults = 0;
  }
  access(pageNumber) {
    if (this.pages.includes(pageNumber)) {
      this.onHit(pageNumber);
      return false;
    }
    this.pageFaults++;
    if (this.pages.length >= this.maxPages) {
      this.evict();
    }
    this.pages.push(pageNumber);
    return true;
  }
}

class LRUReplacer extends PageReplacer {
  onHit(pageNumber) {
    let index = this.pages.indexOf(pageNumber);
    this.pages.splice(index, 1);
    this.pages.push(pageNumber);
  }
  evict() {
    this.pages.shift(); // 淘汰最久未用的
  }
}

class ClockReplacer extends PageReplacer {
  constructor(maxPages) {
    super(maxPages);
    this.useBits = new Map();
    this.hand = 0;
  }
  onHit(pageNumber) {
    this.useBits.set(pageNumber, true);
  }
  evict() {
    while (true) {
      let page = this.pages[this.hand];
      if (this.useBits.get(page)) {
        this.useBits.set(page, false);
        this.hand = (this.hand + 1) % this.pages.length;
      } else {
        this.pages.splice(this.hand, 1);
        this.useBits.delete(page);
        return;
      }
    }
  }
}

和游戏开发的关系:LRU 是游戏纹理缓存最常用的淘汰策略。GPU 显存有限,当纹理超出显存时,需要淘汰最久未使用的纹理。


4.6 内存分配算法

class MemoryAllocator {
  constructor(size) {
    this.memory = new Array(size).fill(null);
    this.blocks = [{ start: 0, size: size, free: true }];
  }

  allocate(size) {
    let block = this.findFreeBlock(size);
    if (!block) return null;
    if (block.size > size) {
      this.splitBlock(block, size);
    }
    block.free = false;
    return block.start;
  }

  free(address) {
    let block = this.blocks.find(b => b.start === address);
    if (!block) return;
    block.free = true;
    this.mergeBlocks();
  }

  mergeBlocks() {
    let i = 0;
    while (i < this.blocks.length - 1) {
      let current = this.blocks[i];
      let next = this.blocks[i + 1];
      if (current.free && next.free) {
        current.size += next.size;
        this.blocks.splice(i + 1, 1);
      } else {
        i++;
      }
    }
  }
}

class FirstFitAllocator extends MemoryAllocator {
  findFreeBlock(size) {
    return this.blocks.find(b => b.free && b.size >= size);
  }
}

class BestFitAllocator extends MemoryAllocator {
  findFreeBlock(size) {
    let suitable = this.blocks.filter(b => b.free && b.size >= size);
    if (suitable.length === 0) return null;
    return suitable.reduce((best, current) =>
      current.size < best.size ? current : best
    );
  }
}

内存池

class ObjectPool {
  constructor(createFn, resetFn, initialSize = 10) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
    for (let i = 0; i < initialSize; i++) {
      this.pool.push(this.createFn());
    }
  }

  acquire() {
    if (this.pool.length > 0) {
      return this.pool.pop();
    }
    return this.createFn();
  }

  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

let bulletPool = new ObjectPool(
  () => ({ x: 0, y: 0, vx: 0, vy: 0, active: false }),
  (bullet) => { bullet.x = 0; bullet.y = 0; bullet.vx = 0; bullet.vy = 0; bullet.active = false; },
  100
);

4.7 垃圾回收原理

标记-清除算法

class MarkSweepGC {
  constructor() {
    this.objects = new Map();
    this.roots = [];
    this.freeList = [];
  }

  allocate(size) {
    if (this.freeList.length > 0) {
      return this.freeList.pop();
    }
    let id = Date.now() + Math.random();
    this.objects.set(id, { size, marked: false, data: null });
    return id;
  }

  gc() {
    this.markPhase();
    return this.sweepPhase();
  }

  markPhase() {
    let worklist = [...this.roots];
    while (worklist.length > 0) {
      let id = worklist.pop();
      let obj = this.objects.get(id);
      if (obj && !obj.marked) {
        obj.marked = true;
        if (obj.references) {
          for (let ref of obj.references) {
            worklist.push(ref);
          }
        }
      }
    }
  }

  sweepPhase() {
    let freed = 0;
    for (let [id, obj] of this.objects) {
      if (!obj.marked) {
        this.objects.delete(id);
        this.freeList.push(id);
        freed += obj.size;
      } else {
        obj.marked = false;
      }
    }
    return freed;
  }
}

分代回收

class GenerationalGC {
  constructor() {
    this.youngGeneration = [];
    this.oldGeneration = [];
    this.roots = new Set();
    this.youngThreshold = 100;
  }

  allocate(value) {
    let obj = { value, marked: false, age: 0 };
    this.youngGeneration.push(obj);
    if (this.youngGeneration.length >= this.youngThreshold) {
      this.minorGC();
    }
    return obj;
  }

  minorGC() {
    let survivors = [];
    for (let obj of this.youngGeneration) {
      if (this.isReachable(obj)) {
        obj.age++;
        if (obj.age >= 3) {
          this.oldGeneration.push(obj);
        } else {
          survivors.push(obj);
        }
      }
    }
    this.youngGeneration = survivors;
  }

  majorGC() {
    let survivors = [];
    for (let obj of this.oldGeneration) {
      if (this.isReachable(obj)) {
        survivors.push(obj);
      }
    }
    this.oldGeneration = survivors;
  }
}

自问自答

Q:虚拟内存和物理内存的关系是什么? A:虚拟内存是操作系统给每个进程的"假象"——让进程以为自己独占了连续的地址空间。实际上虚拟地址通过页表映射到物理地址,物理地址可能是分散的、甚至可能暂时在磁盘上(Swap)。虚拟内存的核心价值:进程隔离、内存保护、内存超售。

Q:为什么进程切换比线程切换慢? A:核心原因有两个:1)进程切换需要切换页表(更新 CR3 寄存器),导致 TLB 全部失效,后续内存访问需要重新查页表,性能损失很大;2)线程共享地址空间,切换时不需要切换页表,TLB 仍然有效。TLB miss 的代价是 100+ 个时钟周期,频繁 miss 会严重影响性能。

Q:大页面(Huge Pages)为什么能提升游戏服务器性能? A:1)减少页表项数量→减少页表占用的内存;2)减少 TLB miss→TLB 能缓存更多的地址映射;3)减少页面中断→大页面意味着更少的页面边界。JVM 和 Node.js 都支持大页面优化,对大内存应用效果显著。

Q:LRU 在游戏纹理缓存中怎么用? A:GPU 显存有限(手机可能只有 1-2GB),游戏纹理可能远超显存。LRU 纹理缓存维护一个纹理访问顺序链表,当显存不足时,淘汰最久未使用的纹理。加载新纹理时,如果显存不够,就把最久未用的纹理从 GPU 卸载。这和操作系统的 LRU 页面置换是同一个思想。


实践任务

  • 任务1:编写程序模拟虚拟内存的地址翻译过程(给定虚拟地址,查页表得到物理地址)
  • 任务2:用 pmap <pid> 观察 Java/Node.js 进程的虚拟内存布局,识别各段(代码段、数据段、堆、栈、共享库)
  • 任务3:测试大页面对程序性能的影响(对比 4KB 页面和 2MB 大页面的 TLB miss 率)
  • 任务4:实现一个 LRU 缓存(模拟纹理缓存),测量不同缓存大小的命中率
  • 任务5:用 perf stat -e dTLB-load-misses 测量程序的 TLB miss 情况

与其他章节的关联

本章内容 关联章节 关联点
虚拟内存 第03章 进程与线程 进程切换导致 TLB flush,是进程切换慢的原因
分页+TLB 第05章 I/O与文件系统 内存映射文件(mmap)基于分页机制
页面置换 第10章 实战篇 内存泄漏排查需要理解虚拟内存
LRU 2_3_browser-memory-mastery 纹理 LRU 缓存是操作系统页面置换的应用

上一章:03-操作系统进程与线程 | 下一章:05-操作系统IO与文件系统