操作系统核心原理 — 内存管理
用生活化的比喻,让你理解虚拟内存、分页、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与文件系统