本文深入解析 Linux 内核的虚拟内存区域(VMA)子系统——进程地址空间的核心抽象。从红黑树到 mmap_lock,从缺页调优到大页透明,带你看清 mm_struct 背后的完整机制。
一、VMA 的设计哲学
1.1 问题的本质
每一个用户进程都独占一个"巨大的"虚拟地址空间(64 位上 128TB)。一个典型进程的内存布局杂乱无章:代码段、数据段、堆、共享库 mmap 区、栈、vdso、vvar……它们大小不同、权限各异、生命周期交错。
内核如何用统一的数据结构描述这些"区域"?答案就是 struct vm_area_struct,简称 VMA。
1.2 核心抽象模型
用户进程的地址空间本质上是一个"区间集合"。每个 VMA 描述一个连续的虚拟地址区间:
进程地址空间(mm_struct)
┌─────────────────────────────┐
│ [0x400000) ELF .text │ VMA: 只读+可执行
│ [0x600000) ELF .data │ VMA: 读写
│ [0x7f0000000000) 堆 │ VMA: 读写 (向上增长)
│ [随机gap] │ (无映射)
│ [0x7f...8000) mmap 区域 │ VMA: 读写/可执行 (多个)
│ [栈guard page] │ (无映射)
│ [0x7ffd00000000) 栈 │ VMA: 读写 (向下增长)
│ [0x7fff00000000) vdso │ VMA: 只读+用户可执行
└─────────────────────────────┘
VMA 不存储实际数据——它只是"一个区间上的策略描述":这段虚拟内存的权限是什么?背后有没有物理页?如果是文件映射,对应哪个文件的哪个偏移?
二、数据结构全解析
2.1 mm_struct — 进程地址空间的总管家
struct mm_struct {
struct { // VMA 管理核心
struct rb_root mm_rb; // VMA 红黑树根节点
struct rw_semaphore mmap_lock; // 地址空间读写信号量
struct vm_area_struct *mmap; // VMA 链表头(排序链表)
};
unsigned long task_size; // 用户态地址空间上限
unsigned long start_code, end_code; // 代码段边界
unsigned long start_data, end_data; // 数据段边界
unsigned long start_brk, brk; // 堆的起止
unsigned long start_stack; // 栈起始地址
unsigned long arg_start, arg_end; // 参数区
unsigned long env_start, env_end; // 环境变量区
pgd_t *pgd; // 页全局目录(顶级页表)
atomic_t mm_users; // 活跃使用者数
atomic_t mm_count; // 引用计数
// 大页相关
atomic_long_t hugetlb_usage;
// NUMA 策略
struct mmu_gather *mm_mt; // 用于延迟 TLB 刷新
// 缓存控制
struct file __rcu *exe_file; // 可执行文件
};
关键字段说明:
- mm_rb:所有 VMA 组织的红黑树,O(log n) 区间查找
- mmap_lock:读写信号量,保护整个 VMA 树/链表,是内存操作最重要的锁
- mmap:链表头,按地址排序的 VMA 链表,遍历很快
- pgd:顶级页表指针,直接用于 MMU 硬件遍历
2.2 vm_area_struct — 区间的完整描述
struct vm_area_struct {
unsigned long vm_start; // 区间起始(包含)
unsigned long vm_end; // 区间结束(不包含)
struct mm_struct *vm_mm; // 所属地址空间
pgprot_t vm_page_prot; // 页保护属性(R/W/X/User)
unsigned long vm_flags; // 行为标志(下面详解)
struct { // 红黑树/链表节点
struct rb_node vm_rb;
unsigned long rb_subtree_last;
};
struct list_head vm_next; // 链表后继
const struct vm_operations_struct *vm_ops; // 操作函数集
unsigned long vm_pgoff; // 文件映射:文件内偏移(页为单位)
struct file *vm_file; // 映射的文件(匿名映射为 NULL)
struct anon_vma *anon_vma; // 匿名映射的反向映射链
// 大页与特殊映射
struct vm_special_mapping *vm_private_data; // vdso/vvar 等
};
vm_flags 是理解 VMA 行为的钥匙:
| 标志 | 含义 | 典型场景 |
|---|---|---|
| VM_READ | 可读 | 所有 VMA |
| VM_WRITE | 可写 | 堆/栈/mmap(文件写映射) |
| VM_EXEC | 可执行 | 代码段、共享库代码段 |
| VM_SHARED | 共享 | MAP_SHARED mmap、fork 后共享 VM_MAYWRITE |
| VM_GROWSDOWN | 向下扩展 | 栈 |
| VM_GROWSUP | 向上扩展 | 某些架构的堆 |
| VM_DONTCOPY | fork 不继承 | 某些特殊映射 |
| VM_DONTEXPAND | 不自动扩展 | vdso |
| VM_PFNMAP | 直接映射物理页 | 设备 mmap |
| VM_MIXEDMAP | 混合PFN和struct page | 大页+普通页混合 |
| VM_HUGETLB | 大页映射 | hugetlbfs |
| VM_SOFTDIRTY | 软脏页跟踪 | /proc/pid/clear_refs |
| VM_ARCH_1..N | 架构特定 | 各平台扩展 |
2.3 两种查找路径
内核用红黑树和链表同时维护 VMA,各取所长:
查找场景 使用结构 时间复杂度
单次 addr→VMA 红黑树 O(log n)
遍历全部 VMA 链表(按地址排序) O(n)
区间查询(插入/删除) 红黑树 O(log n)
fork 复制全部 链表 O(n)
红黑树节点关键值 = vm_start,但巧妙的是还维护了 rb_subtree_last(子树中最大 vm_end),使得"判断新区间是否重叠"可以提前剪枝。
三、VMA 的生命周期
3.1 创建 VMA
创建路径主要有三:
1. do_mmap() ← mmap()/mremap()/MAP_SHARED 等用户调用
2. elf_map() ← execve() 加载 ELF 文件时
3. stack expanding ← 栈缺页时自动向低地址扩展
do_mmap() 的完整流程:
do_mmap(file, addr, len, prot, flags, vm_flags, pgoff)
→ get_unmapped_area() // 在地址空间找一个合适空位
→ mmap_region() // 插入 VMA
→ kmem_cachep 分配 vm_area_struct
→ vma_link() // 插入红黑树 + 链表
→ call_mmap(file) // 如果有文件,调用 file->f_op->mmap()
→ 返回(不分配物理页——按需分配!)
关键点:mmap 只创建 VMA,不分配物理页。这后面的缺页中断才真正把虚拟地址和物理页帧绑定。
3.2 合并与拆分
合并(vma_merge):
当一个新 VMA 与前后相邻的 VMA 具有相同的 vm_flags、文件映射、权限等属性时,内核会自动合并为一个更大的 VMA,避免 VMA 数量膨胀。
现有:[0x1000-0x2000) RW [0x2000-0x3000) RW [0x3000-0x4000) RW
新映射:[0x2000-0x3000) RW → 跳过(已存在)
新映射:[0x1800-0x2800) RW → 合并为 [0x1000-0x3000) RW
拆分(split_vm):
当对一段 VMA 的子区间执行保护变更(mprotect)、权限修改、或 madvise 时,内核必须将一个 VMA 拆成多个。
现有:[0x1000-0x4000) RW
执行:mprotect(0x2000, 0x1000, PROT_READ)
结果:[0x1000-0x2000) RW [0x2000-0x3000) R [0x3000-0x4000) RW
(一个 VMA → 三个 VMA)
这就是为什么频繁的 mprotect 会导致 VMA 数量暴增(可达数百万),拖慢 fork/exit/mproc 等操作——每遍历一次链表都是 O(n)。
3.3 删除 VMA
do_munmap(addr, len)
→ find_vma() // 找到覆盖目标区间的 VMA
→ split_vma() // 如果是部分解除,先拆分
→ unmap_region() // 遍历页表,清除映射 + 释放物理页
→ free_pgtables() // 回收页表自身占用的内存
→ remove_vma() // 从红黑树 + 链表移除
→ kmem_cache_free() // 释放 VMA 结构体
注意:解除映射时,已修改的文件映射会回写磁盘,匿名映射的修改则永久丢失(交换到 swap 的也不恢复)。
四、缺页中断 — VMA 到物理页的桥梁
4.1 缺页处理的完整路径
每个 VMA 在创建时都不对应物理页。当 CPU 访问某个没有映射的虚拟地址时,触发缺页异常(Page Fault),MMU 把控制权交给内核的缺页处理程序。
// x86_64 缺页入口
do_page_fault(regs, error_code)
→ __do_page_fault()
→ find_vma(addr) // 1. VMA 查找——这个地址合法吗?
→ 没找到 → SEGV (段错误)
→ 检查 vm_flags vs access_type // 2. 权限检查
→ 写只读页 → SEGV or COW
→ 执行不可执行页 → SEGV
→ handle_mm_fault() // 3. 真正的缺页处理
→ hugetlb_fault() // 大页路径
→ or:
→ handle_pte_fault() // 普通页路径
→ do_fault() // a. 文件映射缺页
→ file->f_op->fault() // 从磁盘读入
→ do_swap_page() // b. 被换出的页
→ swapin_readahead()
→ do_anonymous_page() // c. 匿名页首次使用
→ alloc_zeroed_user_highpage()
4.2 三种缺页类型
文件映射缺页(Major Fault):
- 访问内容已在 page cache → 只需 PTE 指向缓存页(Minor Fault)
- 内容不在 page cache → 从磁盘读入,阻塞进程(Major Fault)
- 典型场景:首次执行共享库中的函数、首次mmap后访问大文件
匿名映射缺页: - 访问从未写过的新匿名页 → 分配零页(Lazy,实际是全局零页的第一个写时触发 COW) - 典型场景:malloc 大内存后首次读写
换出页缺页(Swap Fault): - 匿名页被 kswapd 换出 → 从 swap 设备读回 → 阻塞进程 - 典型场景:系统内存紧张后重新访问之前分配但长期未用的内存
4.3 写时复制(COW)
COW 是 fork() 高效的核心。fork 后父子共享同一物理页,但全部标记为只读。任一进程首次写入时触发 COW 缺页,内核复制一份新页。
fork() 前:进程A → 物理页P [RW]
fork() 后:进程A → 物理页P [R] (COW)
进程B → 物理页P [R] (COW)
A 写 → 缺页 → 分配新页P' → A 指向P'[RW] → B 保持P[R]
性能影响:COW 是 Web 服务器(Nginx/Java 等)fork 后紧跟高内存写入场景的主要性能杀手——每次 COW 都触发缺页中断(~1-5μs),大量 fork 会导致严重延迟。使用 vfork() 或 posix_spawn() 可避免无意义的 COW。
五、mmap_lock 与并发性能
5.1 mmap_lock 的作用
mmap_lock 是一个读写信号量(rw_semaphore),保护:
- VMA 红黑树/链表的完整性(防止并发插入/删除导致数据结构损坏)
- 缺页处理中的一致性(防止并行操作改变地址空间布局)
读锁持有者可并发:多个线程同时触发缺页
写锁独占:mmap/munmap/mprotect/expand_stack
5.2 mmap_lock 的性能瓶颈
在多线程应用中,mmap_lock 是一个非常常见的争用点:
场景 1:密集 mmap/munap 操作
→ 写锁阻塞所有缺页(读锁)→ 延迟飙升
场景 2:大量线程同时首次分配大内存
→ 多个匿名页缺页竞争同一个读锁
→ 虽然读锁可并发,但 cache line bouncing 仍然致命(特别是 NUMA)
场景 3:ptrace / /proc/pid/maps 读取
→ 读锁被 mmap 写锁阻塞 → top/strace 卡顿
5.3 优化方向:lockless 与 分区锁
Linux 5.x 引入的关键优化:
- VMA 遍历 lockless:通过
mas_lockless()机制,在不需要修改 VMA 树的情况下进行无锁遍历,减少读锁竞争。 - 页表锁(PGT):对页表修改使用更细粒度锁,替代 mmap_lock 的部分职责。
- mapcount 引用计数:通过 mapcount 跟踪页被多少 VMA 引用,安全释放无需持有 mmap_lock。
六、大页(Huge Pages)与透明大页
6.1 传统大页(hugetlbfs)
显式调用 mmap(MAP_HUGETLB) 或通过 hugetlbfs 文件系统直接分配。
- 大小:x86_64 上 2MB 或 1GB
- 永不换出,内核启动时预分配池中预留
- 减少 TLB miss 和页表遍历(5 级页表下,2MB 大页只需要 PMD 一级映射)
// 显式大页映射示例
void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB,
-1, 0);
6.2 透明大页(THP)
让应用"不知不觉"地享受大页的好处——内核在后台扫描匿名页,自动合并成 2MB 大页。
工作流程:
1. 应用申请匿名内存(默认 4KB 页)
2. khugepaged 内核线程异步扫描
3. 发现连续 512 个有效 4KB 页 → 合并为 2MB 大页
4. 后续分配使用 pmd_alloc_huge_pmd() → 直接分配 2MB
开关策略:
/sys/kernel/mm/transparent_hugepage/enabled = "madvise"
always → 所有匿名映射都尝试 THP
madvise → 仅 MADV_HUGEPAGE 标记的区域(默认,大多场景推荐)
never → 关闭
数据库场景建议关闭 THP(Oracle/Redis/Postgres 官方均推荐):THP 的碎片化延迟和 khugepaged CPU 开销在内存密集数据库场景下有负面影响。
# 数据库服务器推荐配置
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
6.3 vm_max_map_count 与 VMA 上限
/proc/sys/vm/max_map_count 控制单个进程最多可拥有的 VMA 数量:
默认值:65530(老版本)→ 65536(现代)
典型超标场景:
- Java JIT 编译器频繁 mmap 可执行代码区
- Redis 使用 THP 且开启 AOF rewrite 时大量 mmap
- Go runtime 的内存分配器大量小 VMA
调整:
sysctl -w vm.max_map_count=262144
VMA 过多的后果:
- fork() 变慢(复制 VMA 树 + 页表)
- 缺页路径 find_vma() 耗时增加(虽然红黑树 O(log n),但常数变大)
- /proc/pid/maps 输出膨胀 → top/strace 卡顿
七、反向映射(Reverse Map)机制
7.1 为什么需要反向映射
当内核需要回收一个物理页时,必须知道"哪些进程的哪些 VMA 引用了这个页"。如果没有反向映射,回收一个共享库代码页需要扫描所有进程的页表——这是不可接受的。
7.2 anon_vma — 匿名页的反向映射
匿名页(堆、栈、MAP_ANONYMOUS)通过 anon_vma 实现反向映射:
内存页 (page)
↔
anon_vma 结构(描述一组共享匿名映射的 VMA)
↔
链表中的多个 VMA
COW 时,fork 后子进程继承父的 anon_vma(通过 anon_vma_chain 链接),并在自己首次写入时脱离原 anon_vma。
7.3 优先搜索树(Prio Tree)
文件映射使用基于区间红黑树的优先搜索树,高效回答"给定一个页帧,哪些页表项指向它?"。
八、实战诊断
8.1 VMA 映射查看
# 进程完整 VMA 布局
cat /proc/<pid>/maps
# 统计 VMA 数量 (wc -l 行数)
grep -c '.' /proc/<pid>/maps
# 查看大页使用情况
grep -i huge /proc/<pid>/smaps
grep -E 'AnonHugePages|ShmemPmdMapped' /proc/meminfo
8.2 缺页统计
# 单次进程缺页统计
ps -o min_flt,maj_flt,cmd <pid>
# 系统级别缺页速率
vmstat 1
→ 关注 si/so (swap in/out) 和 re (页回收)
# 详细统计
grep -E 'pswpin|pswpgfault|pgmajfault' /proc/vmstat
8.3 mmap_lock 争用分析
# 使用 perf 检测 mmap_lock 等待
perf trace -e 'rwsem:*' -p <pid>
# 更精确的锁争用检测(需要 lock_stat)
echo 1 > /proc/sys/kernel/lock_stat
# ...运行工作负载...
cat /proc/lock_stat | grep mmap_read
echo 0 > /proc/sys/kernel/lock_stat
九、常见问题与调试
9.1 OOM Killer 的选择逻辑
当内存耗尽时,OOM Killer 选择受害进程的公式:
oom_score = (占用页面数 × 1000 / 总内存) × 10^(oom_score_adj / 1000)
影响因素:
- RSS 大小(越大越高)
- oom_score_adj(-1000 到 +1000,-1000 永不 kill)
- 特权进程轻微减分
- 运行时间越长,分数越低(避免 kill 重要进程)
VMA 数量本身不直接影响 OOM 评分,但 VMA 越多意味着更多间接开销(页表、anon_vma 等),加剧内存压力。
9.2 栈溢出 vs MAP_GROWSDOWN
经典问题:当栈扩展到刚好碰到相邻 mmap 区域时,Linux 默认允许栈向下扩展(guard page 机制),但如果程序跳过 guard page 一次性超限,将触发 SIGSEGV 而非栈溢出合并。
# 设置栈大小限制
ulimit -s unlimited # 允许无限栈(危险!慎用)
ulimit -s 8192 # 8MB 默认线
9.3 mmap 与文件截断的竞争(Truncate Race)
一个经典的 TOCTOU 漏洞:
线程 A:mmap(fd) → 开始映射文件
线程 B:truncate(fd, 0) → 文件变空
线程 A:访问映射区域 → 访问已释放页帧 → BUS ERROR (SIGBUS)
解决方案:文件映射时使用 MAP_PRIVATE(写时复制,截断不受影响)或持有文件锁。
十、演进趋势
10.1 Maple Tree(Linux 6.1+)
用新的 Maple Tree(MA树)替代 VMA 红黑树 + 双重链表:
优势:
- 区间查询更高效(原本是 O(log n)+O(k),现在是直接定位)
- 减少锁争用(支持 RC )
- 简化代码路径(一种结构替代 rb+list)
Maple Tree 是一种适用于区间(range-based)数据的 B 树变体,节点可存多个区间端点。
10.2 DAMON — 数据访问监控
Linux 5.15+ 引入的框架,可以非侵入地监控应用的内存访问模式,指导 THP 合并决策:
工作流:
1. 划定监控区域(一组 VMA)
2. 周期性采样 PTE access bit
3. 输出热力图 → 自动调整 NUMA 页面迁移 / THP 合并范围
10.3 CXL 内存与 VMA 扩展
Compute Express Link(CXL)引入持久内存容量层级,未来 VMA 可能新增:
- VM_CXL_TIER:标识 CXL 连接的内存层级
- 跨 NUMA 节点的 VMA 迁移策略
- 由 DAMON 驱动的自动冷热分层
总结
VMA 是 Linux 内存管理的"骨架"——每个进程的地址空间就是一棵精心组织的 VMA 树。理解 VMA 的结构、查找算法、生命周期以及它们如何通过缺页中断与物理页绑定,是理解整个虚拟内存子系统的关键。
关键数字速查:
- VMA 查找:红黑树 O(log n),单次约 50-200 CPU cycles
- mmap_lock 等待:多线程超过 1000+ threads 时常 >10% wall time
- 缺页开销:Minor ~1-5μs,Major 取决于磁盘延迟(ms 级),Swap 介于两者之间
- VMA 上限:默认 65536,可通过 vm.max_map_count 调高
- COW 触发:fork 后首次写时,~10-50μs(需要复制整个 4KB 页)
从 Slab 分配到 VMA,Linux 内存管理的每个子系统都是一块精妙的拼图——它们共同支撑起了从 kB 到 PB 规模的内存管理。

发表评论 取消回复