前言
你写了一个 malloc(1GB),内核真的立即给了你 1GB 物理内存吗?不,它只是画了一张"地图"。真正分配物理页是当你访问那些地址的时候。
Linux 内存管理是操作系统最复杂的子系统之一。从虚拟地址到物理地址、从 4KB 小页到 1GB 大页、从 TLB 命中到缺页中断——每一层设计都在平衡灵活性与性能。
PART 01:虚拟内存 —— 每个进程的"幻觉"
每个进程都以为自己独占了整个地址空间:
32位进程 64位进程0x00000000 ┌─────────┐ 0x0000000000000000 ┌──────────────┐ │ 代码+数据 │ │ 代码+数据 │ ├─────────┤ ├──────────────┤ │ 堆 │ │ 堆 │ ├─────────┤ ├──────────────┤ │ ↓ │ │ ↓ │ │ 空闲区域 │ │ 空闲区域 │ │ ↑ │ │ ↑ │ ├─────────┤ ├──────────────┤ │ 栈 │ │ 栈 │0xFFFFFFFF └─────────┘ 0x7FFFFFFFFFFF └──────────────┘
虚拟内存的好处
- **简化**:每个进程看到连续的地址空间,不需要关心物理碎片
- **共享**:共享库的物理内存只有一份,映射到多个进程的虚拟地址
- **惰性分配**:分配虚拟地址不消耗物理内存,用到才给
# 查看进程虚拟内存布局cat /proc//mapscat /proc//smaps# 更详细(含各段实际物理内存使用)
PART 02:MMU 与页表
地址转换
虚拟地址 0x7F123456 ↓CPU → MMU(内存管理单元) ↓查页表(Page Table) ↓物理地址 0x3A000456
四级页表(4-Level Paging)
虚拟地址 (64位)┌──────┬──────┬──────┬──────┬──────────┐│ PGD │ PUD │ PMD │ PTE │ 偏移量 ││ 9bit │ 9bit │ 9bit │ 9bit │ 12bit │└──┬───┴──┬───┴──┬───┴──┬───┴──────────┘ ↓ ↓ ↓ ↓ PGD → PUD → PMD → PTE → 物理页 (4KB)
每个页表项(PTE)映射 4KB 物理页。一个 64 位进程的完整页表,光是页表本身就可能占用几百 MB 内存。
5-Level Paging(内核 5.0+,Intel Ice Lake+):再多一级,支持 512PB 地址空间:
PGD → P4D → PUD → PMD → PTE
PART 03:TLB —— 地址转换的缓存
为什么需要 TLB
每次内存访问都需要 MMU 查页表,而查页表本身需要多次内存访问(4 级页表 = 4 次内存访问)。TLB 缓存最近使用的虚拟→物理地址映射。
没有 TLB: 每次内存访问 → 4 次页表查询 → 400ns有 TLB(命中): 每次内存访问 → 1 次 TLB 查询 → ~1ns
大页(Huge Page)降低 TLB Miss
4KB 小页: 1GB 数据 → 262144 个页 → TLB 需要 262144 个条目 TLB 只有 ~50 个条目 → 疯狂 Miss2MB 大页: 1GB 数据 → 512 个大页 → TLB 只占 512 个条目 Miss 大幅减少1GB 大页: 1GB 数据 → 1 个巨页 → TLB 只需要 1 个条目
PART 04:Huge Page 实战
透明大页(THP)
内核自动将 4KB 页合并为 2MB 大页。但 THP 有两大问题:
- **khugepaged 线程**:扫描内存并合并,消耗 CPU
- **内存碎片**:分配不到连续 2MB 时回退到 4KB,导致延迟抖动
内核自动将 4KB 页合并为 2MB 大页。但 THP 有两大问题:
# 查看 THP 状态cat /sys/kernel/mm/transparent_hugepage/enabled# always [madvise] never# always:内核自动合并所有区域(默认)# madvise:只在 madvise(MADV_HUGEPAGE) 区域使用# never:禁用# 数据库场景建议 neverecho never > /sys/kernel/mm/transparent_hugepage/enabled# 或 madvise(只在显式指定的区域使用)echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
显式大页(HugeTLB)
手动预留物理内存作为大页,应用通过 mmap 或 shmget 使用:
# 预留 1024 个 2MB 大页echo 1024 > /proc/sys/vm/nr_hugepages# 查看预留情况cat /proc/meminfo | grep -i hugeHugePages_Total: 1024HugePages_Free: 768HugePages_Rsvd: 256HugePages_Surp: 0# 挂载 hugetlbfsmount -t hugetlbfs none /mnt/huge
MySQL/PostgreSQL 使用大页
# MySQL 8.0# 确保已预留大页echo 4096 > /proc/sys/vm/nr_hugepages# MySQL 配置large-pages=ON# MySQL 会在启动时锁定大页# PostgreSQL# PG 13+ 支持大页huge_pages = on# PG 会自动通过 mmap 使用大页
使用大页的性能提升
PART 05:缺页中断
惰性分配的全过程
ptr = malloc(1GB); // 只分配了虚拟地址,没有物理页ptr[0] = 'a'; // 访问这个地址 → CPU 触发缺页中断 // 内核分配物理页 → 建立页表映射 // 返回用户态 → 重新执行访问指令
# 查看缺页统计cat /proc//stat | awk '{print $10, $12}'# majflt minflt# majflt:主缺页(需要磁盘 IO,慢!)# minflt:次缺页(不需要磁盘 IO,快)
主缺页 vs 次缺页
# 锁定进程的内存页面防止被换出mlockall(MCL_CURRENT | MCL_FUTURE);# 配置 MySQLmemlock# my.cnf 中设置,防止被 swap
PART 06:Page Fault 排查
谁在触发大量缺页
# 按进程查看缺页次数ps -eo pid,minflt,majflt,cmd --sort=-majflt | head# 实时监控缺页perf stat -e page-faults,major-faults,minor-faults -p
预读(read-ahead)
内核检测到顺序读模式时会提前加载更多页:
# 查看预读参数cat /sys/block/nvme0n1/queue/read_ahead_kb# 默认 128KB# 大文件顺序读场景调高echo 2048 > /sys/block/nvme0n1/queue/read_ahead_kb
PART 07:内存性能排查工具箱
| | | | | |
|---|
| | | | | |
| | | | | |
| | | | | |
| `perf stat -e TLB-misses` | | `perf stat -e dTLB-load-misses,iTLB-load-misses -a sleep 5` | | | |
| | `vmstat 1`(si/so 列有值说明在 swap) | | | |
| | | | | |
总结
内存管理是”层层缓存”的设计:CPU 寄存器 ↓ (~1ns)L1/L2/L3 Cache ↓ (~5-40ns)TLB(地址转换缓存) ↓ (~1ns 命中 / 80ns Miss)页表(4 级 / 5 级) ↓物理内存 (DRAM) ↓ (~100ns)Swap / 磁盘 ↓ (~10ms)
- **大页**是降低 TLB Miss 最有效的武器
- **THP** 有 CPU 开销,数据库场景建议禁用
- **HugeTLB** 显式大页适合可预测的内存分配
预告:《OOM Killer 深度剖析:oom_score、oom_adj、cgroup 内存限制与内存超卖》当内存耗尽,内核如何决定杀谁?