写在前面
本文讲操作系统的内存管理:虚拟内存、页表、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、上下文切换
核心认知:内存不是无限免费的——分配、翻译、回收都有成本。高性能编程的本质之一,就是理解这些成本并尽量减少。
系列总结
操作系统底层五篇完结:
- I/O 模型:5 种模型,前 4 种同步、异步 I/O 才是真异步
- 进程线程协程:资源/调度单位,协程轻量适合高并发
- Reactor/Proactor:I/O 模型如何变成网络框架(Nginx/Netty/Redis)
- 零拷贝:sendfile/mmap/splice 减少多余拷贝
- 内存管理:虚拟内存、页表、TLB、malloc、内存池
这些是 Nginx、Redis、Kafka、Kestrel 高性能的底层根基。理解了它们,再看任何中间件、框架的性能文章,都能看懂"为什么"。