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