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

写在前面

本文讲操作系统的内存管理:虚拟内存、页表、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
虚拟内存的好处:
  ✓ 进程隔离(每个进程独立地址空间,互不干扰)
  ✓ 内存"变多"(虚拟空间可大于物理内存,用磁盘做扩展)
  ✓ 地址抽象(程序使用虚拟地址,不必关心物理位置;ASLR、PIE 等机制还会改变进程间或运行间地址)
  ✓ 按需分配(用到才分配物理页,懒加载)
  ✓ 共享内存(多个进程的虚拟页映射到同一物理页)

1.2 地址翻译

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

  虚拟地址空间(64位,实际常用 48 位 = 256TB;较新的 CPU 可选 5 级分页,扩展到 57 位)
  ┌────────────────┐
  │ 用户空间(高地址)│
  │ 栈 ↓             │
  │ 共享库            │
  │ 堆 ↑             │
  │ 数据段            │
  │ 代码段            │
  └────────────────┘
  ┌────────────────┐
  │ 内核空间          │
  └────────────────┘

  每个进程都觉得自己独占这整个空间
  实际物理内存由 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 = 2^36 ≈ 687亿项,每进程页表就几百GB,不可能
    多级:只存用到的部分,按需创建

2.3 TLB(Translation Lookaside Buffer)

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

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

  TLB 容量和层级因 CPU 而异,局部性良好时可获得较高命中率

  这就是为什么上下文切换贵:
    切换地址空间可能使部分 TLB 项失效;PCID/ASID 等硬件机制可以保留带地址空间标识的条目

三、缺页中断

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

  Minor Page Fault 可通过分配物理页或建立已有映射解决,不需要磁盘 I/O
  Major Page Fault 需要从存储读取页面,延迟显著更高
  内存压力下频繁换入换出会形成抖动(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 为例,阈值可动态调整或配置):
  较小分配通常从 arena/堆中满足,必要时用 brk 扩展
    brk 指针上移,堆增长
    free 后内存不归还 OS,留作复用
  较大分配可能用 mmap 单独映射;不能把 128KB 当作固定规则
    独立区域,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 与缓存;PCID/ASID 等机制可减少这类代价
  协程切换通常避免内核调度开销,但性能仍取决于运行时实现和任务行为

八、小结

  • 虚拟内存:每进程独立地址空间,隔离+按需分配+可大于物理内存
  • 分页+页表:4KB 页,多级页表映射虚拟→物理
  • TLB:页表缓存,地址空间切换可能影响其中的条目;现代硬件可用地址空间标识降低代价
  • 缺页中断:页表映射尚未就绪时触发;只有 Major Page Fault 必须从存储读取数据
  • malloc:用户态分配器通过 arena、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 高性能的底层根基。理解了它们,再看任何中间件、框架的性能文章,都能看懂"为什么"。