操作系统学习笔记(五):内存管理

写在前面

本文讲操作系统的内存管理:虚拟内存、页表、TLB、malloc、内存池。理解这些才能明白为什么"内存比磁盘快万倍"、为什么程序要少分配、GC 为什么会影响性能。


一、虚拟内存

1.1 为什么需要虚拟内存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
没有虚拟内存(早期):
  程序直接操作物理内存
  问题:
    ✗ 进程间互相踩内存(A 写错地址破坏 B)
    ✗ 内存不够用(物理内存有限)
    ✗ 程序地址不固定(换台机器/换次运行地址就变)

有虚拟内存:
  每个进程有自己独立的、连续的虚拟地址空间
  CPU 看到的是虚拟地址,由 MMU 翻译成物理地址
1
2
3
4
5
6
虚拟内存的好处:
  ✓ 进程隔离(每个进程独立地址空间,互不干扰)
  ✓ 内存"变多"(虚拟空间可大于物理内存,用磁盘做扩展)
  ✓ 地址固定(程序总是从固定地址加载,不用关心物理位置)
  ✓ 按需分配(用到才分配物理页,懒加载)
  ✓ 共享内存(多个进程的虚拟页映射到同一物理页)

1.2 地址翻译

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
程序用虚拟地址 → MMU(内存管理单元)翻译 → 物理地址

  虚拟地址空间(64位,实际用 48 位 = 256TB)
  ┌────────────────┐
  │ 用户空间(高地址)│
  │ 栈 ↓             │
  │ 共享库            │
  │ 堆 ↑             │
  │ 数据段            │
  │ 代码段            │
  └────────────────┘
  ┌────────────────┐
  │ 内核空间          │
  └────────────────┘

  每个进程都觉得自己独占这整个空间
  实际物理内存由 OS 分配页

二、分页与页表

2.1 分页机制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
虚拟内存按固定大小切成「页」(通常 4KB)
物理内存也切成「页框」(4KB)

虚拟页 → 映射到 → 物理页框
不连续:虚拟页 0/1/2 可映射到物理页框 5/100/3

  虚拟页  ┌───┬───┬───┬───┐
          │ 0 │ 1 │ 2 │...│
          └─┬─┴─┬─┴───┴───┘
            │   │
  物理页框 ┌─▼─┬─▼─┬───┬───┐
          │ 5 │100│...│ 3 │   (页表记录这个映射)
          └───┴───┴───┴───┘

2.2 页表

1
2
3
4
5
6
7
8
9
页表 = 记录「虚拟页 → 物理页」映射的数据结构

  每个进程一个页表
  多级页表(x86-64 通常 4 级):
    虚拟地址拆成 5 段,逐级查表

  为什么多级:节省页表内存
    单级:256TB/4KB = 640亿项,每进程页表就几百GB,不可能
    多级:只存用到的部分,按需创建

2.3 TLB(Translation Lookaside Buffer)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
问题:每次地址翻译要查多级页表(多次内存访问),太慢

TLB = CPU 内的页表缓存
  缓存最近用过的「虚拟页→物理页」映射
  命中 TLB → 一次拿到物理地址(极快)

  TLB 很小(几百项),但命中率极高(因为局部性)

  这就是为什么上下文切换贵:
    切换进程 → 页表换了 → TLB 失效 → 短期内翻译变慢

三、缺页中断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
访问一个虚拟地址时:
  1. 查页表,发现该页不在物理内存(未分配/被换出到磁盘)
  2. 触发「缺页中断」(Page Fault)
  3. OS 接管:
     - 分配新的物理页(首次访问)
     - 或从磁盘换回数据(之前被换出)
  4. 更新页表,恢复执行

  缺页中断是慢的(涉及磁盘 I/O)
  频繁缺页 = 抖动(thrashing),性能暴跌

四、内存分配(malloc 的真相)

4.1 用户态分配

1
2
// C 的 malloc / C++ 的 new / 语言的分配
void *p = malloc(100);   // 申请 100 字节
1
2
3
4
5
6
7
8
9
malloc 的实现(如 glibc ptmalloc):
  小块(< 128KB):用 brk 扩展堆
    brk 指针上移,堆增长
    free 后内存不归还 OS,留作复用
  大块(≥ 128KB):用 mmap 直接映射
    独立区域,free 后归还 OS

  malloc 本身是用户态内存分配器
  真正向 OS 要内存用系统调用(brk/mmap)

4.2 内存碎片

1
2
3
4
5
内部碎片:申请 100 字节,分配器给了 128 字节(对齐),浪费 28
外部碎片:频繁分配/释放后,空闲内存碎成小块,无法满足大请求

  即使总空闲内存够,也可能因为碎片导致分配失败
  分配器(jemalloc/tcmalloc)的一大工作就是对抗碎片

4.3 高性能内存分配器

1
2
3
4
5
6
7
glibc ptmalloc — 通用,但有锁竞争、碎片问题
jemalloc      — FreeBSD/Redis/Facebook 用,抗碎片、多线程好
tcmalloc      — Google,线程缓存,多线程高性能
mimalloc      — 微软,现代、快

  高性能程序(Redis、.NET runtime、Go runtime)都有自己的分配器
  共同优化:线程本地缓存(避免锁)、按大小类(对抗碎片)、多级分配

五、内存池

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
为什么要有内存池:
  malloc/free 频繁调用有开销(系统调用、锁、碎片)
  高频分配场景(如每秒处理万级请求)开销大

内存池思路:
  预先一次性申请大块内存
  自己管理分配/回收
  避免反复 malloc/free

  .NET 的 GC 堆、Nginx 的 pool、对象的 ArrayPool 都是内存池思想

  Nginx pool(见 Nginx 深入原理篇):
    每个请求一个 pool,请求中所有分配从 pool 切
    请求结束,pool 整体释放 → 极快,无碎片,无泄漏

六、页面置换

1
2
3
4
5
6
7
8
9
物理内存不够时,OS 把不常用的页换到磁盘(swap)
换出哪页?用置换算法:

  LRU(最近最少使用)— 实际常近似实现
  LFU(最不经常使用)
  Clock(时钟算法,LRU 的近似)

  抖动:频繁换页,磁盘 I/O 暴涨,性能崩溃
  生产环境通常关闭 swap(或设低),避免抖动

七、为什么这些影响程序性能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
1. 分配不是免费的
  每次 malloc/new → 分配器工作 → 可能让 GC 回收 → 缺页
  所以"减少分配"是性能优化的核心(Span、ArrayPool、对象池)

2. 访问局部性影响 TLB/缓存命中
  顺序访问数组 → 缓存命中好
  随机访问链表/指针追逐 → 缓存失效,慢
  所以数组/连续内存比链表快(缓存友好)

3. GC 与内存管理
  GC 堆 = 内存池的一种
  分配越多 → GC 越频繁 → 暂停越久
  减少分配 = 减少 GC = 提升性能

4. 上下文切换贵
  TLB 失效是切换开销的重要部分
  所以协程(不切进程/线程,TLB 不失效)比线程快

八、小结

  • 虚拟内存:每进程独立地址空间,隔离+按需分配+可大于物理内存
  • 分页+页表:4KB 页,多级页表映射虚拟→物理
  • TLB:页表缓存,命中则快;切换进程失效,所以上下文切换贵
  • 缺页中断:页不在内存时触发,涉及磁盘,慢
  • malloc:小块用 brk(堆),大块用 mmap;碎片是分配器的头号敌人
  • 内存池:预分配+自管理,避免反复 malloc(Nginx pool、GC 堆、ArrayPool)
  • 对性能的影响:分配有成本、缓存局部性、GC、上下文切换

核心认知:内存不是无限免费的——分配、翻译、回收都有成本。高性能编程的本质之一,就是理解这些成本并尽量减少。


系列总结

操作系统底层五篇完结:

  1. I/O 模型:5 种模型,前 4 种同步、异步 I/O 才是真异步
  2. 进程线程协程:资源/调度单位,协程轻量适合高并发
  3. Reactor/Proactor:I/O 模型如何变成网络框架(Nginx/Netty/Redis)
  4. 零拷贝:sendfile/mmap/splice 减少多余拷贝
  5. 内存管理:虚拟内存、页表、TLB、malloc、内存池

这些是 Nginx、Redis、Kafka、Kestrel 高性能的底层根基。理解了它们,再看任何中间件、框架的性能文章,都能看懂"为什么"。