1. 什么是Linux的内存分页管理:它不是“虚拟内存”的同义词,而是现代操作系统落地的物理基石
很多人第一次听说“Linux内存分页管理”,下意识就把它等同于“虚拟内存”或者“swap分区”。这就像把发动机说成是“汽车能跑的原因”——没错,但远远不够。分页管理(Paging)是Linux内核在
物理内存资源极其有限的前提下,为每个进程构建独立、安全、可扩展的地址空间所依赖的一套底层硬件协同机制
。它不负责数据换入换出(那是page reclaim和swap子系统的事),也不直接决定程序能不能运行(那是调度器和内存分配器的事),但它决定了:你写的
malloc(100MB)
到底有没有真正占用100MB物理内存?为什么两个进程都能访问地址
0x7fff00000000
却互不干扰?当程序试图读取一个从未写过的变量时,内核是怎么在毫秒级内“无感”地给它分配一页干净内存的?这些问题的答案,全藏在分页管理的三级页表结构、TLB缓存策略、页表项(PTE)标志位设计,以及内核对MMU(内存管理单元)的精确控制中。
我带过不少刚从嵌入式裸机开发转过来的工程师,他们最常踩的坑就是:在ARM Cortex-A系列板子上用
mmap()
映射一段设备寄存器,结果发现读写行为异常——不是值不对,就是触发了不可预测的总线错误。查了一周日志,最后发现根本原因在于页表项里漏设了
AP[2:1]
(Access Permission)位,导致CPU在用户态下尝试访问本应只允许内核态访问的内存区域。这种问题,光看C代码永远找不到,必须沉到页表映射这一层。所以,理解分页管理,不是为了去手写页表初始化汇编(那早被内核封装死了),而是为了在遇到OOM Killer突然杀掉你的关键进程、
dmesg
里刷出一长串
page allocation failure
、或者perf trace里看到大量
page-fault
事件时,你能立刻判断:这是内存真的耗尽了?还是某个驱动误用了
__GFP_HIGHMEM
标志导致高端内存分配失败?又或者,只是因为
vm.swappiness=100
让内核过于激进地把匿名页换出,反而拖慢了响应?
这个主题的核心关键词非常明确: 页表(Page Table)、页目录(Page Directory)、页中间目录(PMD)、页表项(PTE)、TLB(Translation Lookaside Buffer)、缺页异常(Page Fault)、大页(Huge Page)、反向映射(RMAP)、页回收(Page Reclaim) 。它不涉及任何用户态应用开发技巧,也不讲怎么优化Java堆内存,它讲的是Linux如何用几KB的页表数据结构,管理TB级的虚拟地址空间,并在x86_64和ARM64两种主流架构上,用完全不同的页表层级(x86_64是4级,ARM64默认是4级但支持3级/5级)达成同一目标。如果你的工作场景包括性能调优、内核模块开发、容器资源隔离(cgroups v1/v2的memory controller底层依赖)、或安全加固(如SMAP/SMEP防护绕过分析),那么分页管理不是“可学可不学”的选修课,而是你每天都在和它打交道、却可能从未看清它真面目的那个“影子伙伴”。
2. 分页管理的整体设计思路:为什么非得用多级页表?一张大表不行吗?
2.1 从“扁平页表”到“多级页表”的必然选择
假设我们抛弃所有现代设计,回到最朴素的想法:为整个64位虚拟地址空间(2^64字节 ≈ 1.8×10^19字节)建一张扁平页表。每页大小固定为4KB(2^12字节),那么总共需要2^(64-12) = 2^52个页表项。每个PTE在x86_64上占8字节,这张表的总大小就是2^52 × 8 = 2^55字节 = 32PB。你没看错,是32拍字节。即使你把全球所有数据中心的SSD加起来,也装不下一张这样的页表。更荒谬的是,绝大多数进程实际只使用几MB到几GB的虚拟内存,却要为那剩下的99.999%的地址空间预留并初始化32PB的页表内存——这不仅是浪费,更是工程灾难。
多级页表正是为解决这个“空间爆炸”问题而生。它的核心思想是:
按需分配、惰性创建、层级索引
。以x86_64的四级页表为例(PGD → PUD → PMD → PTE),虚拟地址被拆成5段:
[11:0] 页内偏移(12位,确定页内字节)
[20:12] PTE索引(9位,定位页表项)
[29:21] PMD索引(9位,定位页中间目录项)
[38:30] PUD索引(9位,定位页上级目录项)
[47:39] PGD索引(9位,定位页全局目录项)
[63:48] 符号位(保留,用于未来扩展)
关键点来了:内核
不会一次性为所有4级目录分配内存
。它只在进程首次访问某个虚拟地址,且该地址对应的某一级目录项为空(值为0)时,才动态分配下一级目录的物理页,并填入其物理地址。比如,一个只用了栈和代码段的简单进程,可能只分配了PGD(1个页)和少量PUD/PMD(各几个页),而PTE层则只在真正发生缺页时才逐页分配。实测一个空的
bash
进程,其页表内存开销通常不到16KB;而一个加载了几十个共享库的Firefox浏览器,页表本身也仅占用几百KB——这比32PB小了25个数量级。
提示:你可以用
pmap -x <pid>命令查看某个进程的页表内存占用(对应MMU或PSS列中的pgtables项),或者更底层地,读取/proc/<pid>/status里的MMU字段。这不是估算,是内核实时统计的真实物理内存消耗。
2.2 硬件与软件的深度协同:MMU如何把虚拟地址变成物理地址?
分页管理绝不是纯软件算法,它是CPU硬件(MMU)和内核软件之间一场精密的“双人舞”。当CPU执行一条访存指令(如
mov %rax, (%rbx)
),它拿到的是虚拟地址。此时,MMU会自动介入:
- 地址分解 :将虚拟地址按上述规则拆解为各级索引。
-
逐级查表
:从CR3寄存器(存储PGD物理基地址)开始,用PGD索引找到PGD项;若该项的
Present位为0,则触发缺页异常;否则,提取其中的物理页框号(PFN),左移12位得到PUD物理地址;再用PUD索引查PUD项……如此递推,直到PTE。 -
权限检查
:每查一级,MMU都会检查该项的
User/Supervisor、Read/Write、Execute Disable等标志位。如果当前CPU处于用户态(CPL=3),而PGD项的U/S位为0(仅内核可访问),则立即触发#GP(General Protection)异常,由内核处理。 - 物理地址合成 :最终PTE中包含目标页的物理页框号(PFN),将其左移12位,再加上虚拟地址的低12位(页内偏移),就得到了真正的物理地址。
- TLB加速 :这个过程如果每次都走内存,速度会极慢(一次查表就要几次内存访问)。因此,MMU内置了一个高速缓存——TLB(Translation Lookaside Buffer)。它像CPU的L1缓存一样,存储最近用过的“虚拟页号→物理页框号”映射。命中TLB时,地址转换在1个时钟周期内完成;未命中时,才触发完整的多级查表流程,并将结果填入TLB。
内核的职责,就是确保CR3指向正确的PGD物理地址,并在进程切换时刷新TLB(通过
mov %rax, %cr3
指令),同时在页表内容变更(如
mmap()
、
fork()
、
munmap()
)后,及时使相关TLB条目失效(
invlpg
指令或
tlb_flush_*
函数族)。这解释了为什么
fork()
之后子进程能立即访问父进程的内存——内核并没有复制物理页,而是让子进程的页表项
指向同一物理页
,并设置PTE的
Copy-on-Write
(COW)标志。只有当任一进程尝试写入时,MMU检测到COW位,触发缺页异常,内核才在异常处理中分配新页、复制数据、更新PTE。
2.3 大页(Huge Page):为性能而生的“特快专列”
标准4KB页在通用场景下很灵活,但对数据库、科学计算等内存密集型应用却是性能瓶颈。原因很简单:一次访问1GB数据,如果用4KB页,需要262144次TLB查找;而用2MB大页,只需512次;用1GB大页,仅需1次。TLB容量有限(典型x86_64 CPU的L1 TLB只有64项),频繁TLB miss会导致严重的性能抖动。
Linux支持两种大页:
-
透明大页(THP, Transparent Huge Page)
:内核自动在后台将连续的4KB页“合并”为2MB页。启用后(
echo always > /sys/kernel/mm/transparent_hugepage/enabled),malloc()分配的大块内存有很大概率被映射为2MB页。优点是零侵入,缺点是内存碎片化时可能失败,且合并过程有开销。 -
显式大页(Explicit Huge Page)
:管理员预先在启动时预留固定数量的2MB或1GB页(
echo 1024 > /proc/sys/vm/nr_hugepages),应用通过mmap()指定MAP_HUGETLB标志来申请。优点是100%确定性,无运行时开销,缺点是需要应用改造和精细的内存规划。
我在线上MySQL集群做过对比测试:同样负载下,启用THP后
InnoDB buffer pool
的TLB miss率下降72%,QPS提升18%;而改用显式2MB大页后,QPS再提升5%,且P99延迟波动降低40%。但要注意,THP在某些场景(如大量小对象分配的Java应用)反而会因内存扫描开销导致GC暂停时间变长——这再次印证:分页管理不是“开了就赢”,而是需要结合 workload 特征做权衡。
3. 核心细节解析与实操要点:从页表项标志位到反向映射的精妙设计
3.1 页表项(PTE)的8字节里,藏着整个内存安全的密码
一个x86_64的PTE是64位(8字节),但并非所有位都用于存储物理页框号(PFN)。它的布局是高度结构化的,每一组比特都有明确语义。以下是最关键的几位(基于Intel SDM Vol.3A):
| 位范围 | 名称 | 含义 | 实操意义 |
|---|---|---|---|
| 0 | Present (P) | 1=页在内存中,0=不在(触发缺页) |
mmap()
后首次访问必触发此位为0的缺页,内核在此分配物理页
|
| 1 | Read/Write (R/W) | 1=可写,0=只读 |
mprotect(addr, len, PROT_READ)
即清除此位,写操作触发#PF
|
| 2 | User/Supervisor (U/S) | 1=用户态可访问,0=仅内核态 |
内核空间(如
0xffff800000000000
起)的PTE此位必为0
|
| 3 | Page-Level Write-Through (PWT) | 控制写策略(直写/回写) | 影响PCIe设备DMA内存的缓存一致性,驱动开发必关 |
| 4 | Page-Level Cache Disable (PCD) | 1=禁用cache,0=启用 | 显存、IO内存常设此位,避免CPU cache污染 |
| 5 | Accessed (A) | MMU自动置1,表示该页被访问过 |
内核
kswapd
回收页时,只回收A=0的页(LRU基础)
|
| 6 | Dirty (D) | MMU自动置1,表示该页被写入过 |
COW页在写入后,内核清除此位并分配新页;
mmap(MAP_SHARED)
文件映射页的脏状态由此位标识
|
| 7 | PAT (Page Attribute Table) | 指向PAT寄存器索引,定义内存类型 | 与PWT/PCD配合,精细控制MTRR(Memory Type Range Register) |
| 8-11 | Global (G) | 1=TLB条目全局有效(进程切换不flush) | 内核代码/数据页常设,减少TLB flush开销 |
| 12-51 | Page Frame Number (PFN) | 物理页的高40位地址 | 左移12位即得物理页起始地址(4KB对齐) |
| 52-62 | Software Use |
内核自定义,如
_PAGE_SWP_SOFT_DIRTY
(用于KSM)
|
memcg
、
ksm
、
userfaultfd
等子系统扩展用途
|
| 63 | NX (No-Execute) | 1=禁止执行,0=允许 | SMEP/SMAP防护的基础,防止ROP攻击 |
注意:ARM64的PTE布局完全不同(例如用
AP[2:1]代替U/S位,用UXN/ PXN代替NX位),但设计哲学一致:用最少的比特编码最多的控制语义。跨架构开发时,切勿硬编码位操作,必须使用<asm/pgtable.h>中定义的pte_*()宏。
一个经典实操案例:你想监控某个进程的内存访问模式,判断它是否在随机访问(导致TLB miss高)还是顺序访问。传统方法是
perf record -e mem-loads
,但精度不够。更底层的方法是:利用
/proc/<pid>/pagemap
接口(需root),读取其PTE的
Accessed
位。内核提供
mincore()
系统调用可批量查询,但
pagemap
能获取原始PTE值。步骤如下:
-
open("/proc/self/pagemap", O_RDONLY)获取文件描述符; -
计算目标虚拟地址
addr对应的pagemap偏移:(addr / 4096) * 8; -
pread(fd, &pte_val, 8, offset)读取8字节PTE值; -
if (pte_val & (1UL << 5)) { /* 该页被访问过 */ }。
我曾用此法定位到一个Python服务的性能瓶颈:它用
mmap()
映射了一个1GB的只读索引文件,但业务逻辑中存在大量
seek()
跳转,导致TLB频繁miss。将文件按逻辑块拆分为多个小
mmap()
区域,并在不用时
munmap()
,TLB miss率下降65%。
3.2 反向映射(RMAP):内核如何快速找到“谁在用这页物理内存”?
分页管理是单向的:虚拟地址 → 物理地址。但内核经常需要反向操作:比如,当一个物理页要被回收时,必须知道哪些进程的页表项正指向它,以便解除映射(
try_to_unmap()
);又如,KSM(Kernel Samepage Merging)要合并相同内容的页,必须找出所有映射了该页的VMA(Virtual Memory Area)。如果没有反向映射,内核只能暴力遍历所有进程的所有页表——这在千核服务器上是不可接受的。
RMAP的解决方案是:
为每个物理页(
struct page
)维护一个链表,记录所有映射它的VMA
。具体实现是
struct page
中的
_mapcount
字段(记录映射次数)和
mapping
指针(指向
struct address_space
,即文件或匿名内存的抽象)。对于匿名页(
malloc
分配),
mapping
指向
struct anon_vma
,后者包含一个
rb_root
红黑树,存储所有映射该页的
anon_vma_chain
节点。每个节点关联一个
vm_area_struct
(VMA)。
这意味着,当你调用
mmap()
创建一个新映射时,内核不仅修改页表,还会:
-
如果是文件映射,将该VMA加入文件
address_space的i_mmap红黑树; -
如果是匿名映射,创建或复用
anon_vma,并将VMA加入其红黑树; -
更新
struct page的_mapcount。
这个设计带来了显著开销:每次
fork()
都要复制
anon_vma
并建立新链表;每次
munmap()
都要遍历RMAP链表解除映射。但换来的是O(log N)的反向查找效率(N为映射数),远优于O(N×M)的暴力扫描(M为进程数)。在容器场景下,一个宿主机运行数百个Pod,每个Pod有数十个进程,RMAP几乎是内存回收的唯一可行方案。
3.3 页回收(Page Reclaim)与OOM Killer:分页管理的“压力阀”
分页管理的终极挑战,是如何在物理内存即将耗尽时,优雅地释放资源。这由
kswapd
内核线程和直接回收(direct reclaim)共同完成。其核心流程是:
-
水位检测
:内核维护
pages_min、pages_low、pages_high三个水位。当空闲页低于pages_low,kswapd被唤醒;低于pages_min,触发直接回收(同步阻塞当前分配线程)。 -
LRU链表扫描
:内存被划分为Active/Inactive LRU链表(匿名页和文件页各两组)。
kswapd扫描Inactive链表,对每页:-
若
PageReferenced且PageActive,则提升到Active链表; -
若
PageReferenced但!PageActive,则清PageReferenced并重置PageActive; -
若
!PageReferenced,则标记为可回收。
-
若
-
页回收决策
:
-
文件页
:若干净(
PageDirty=0),直接page_cache_release();若脏(PageDirty=1),提交writeback,等待IO完成后再释放。 -
匿名页
:若
swappiness > 0,尝试换出到swap分区;否则,直接OOM Kill。
-
文件页
:若干净(
vm.swappiness
参数(0-100)是调节匿名页换出倾向的关键杠杆。值为0时,内核只在绝对必要时(如
pages_min
被击穿)才换出匿名页;值为100时,匿名页和文件页被同等对待。线上数据库服务器通常设为1-10,避免宝贵的buffer pool被无谓换出;而内存受限的嵌入式设备可能设为100,优先保活。
OOM Killer的触发条件是:所有zone的空闲页都低于
pages_min
,且无法通过回收释放足够内存。此时,内核遍历所有进程,计算
badness_score
:
score = total_vm * 1000 / (sqrt(rss + swap + nr_ptes + nr_pmds) + 1)
RSS(Resident Set Size)越大、swap越少、页表项越多的进程得分越高,越容易被杀死。这就是为什么
docker run --memory=2g
的容器,即使只用了1.5GB RSS,也可能因OOM Killer被杀——因为其
total_vm
(虚拟内存总量)可能高达10GB(
mmap()
了大文件但未访问),拉高了分母。
4. 实操过程与核心环节实现:手把手拆解一个缺页异常的完整生命周期
4.1 从用户态
printf("hello")
到内核
do_page_fault()
的17步追踪
让我们以一个最简单的场景切入:一个静态链接的
hello
程序,执行
printf("hello")
时,字符串字面量
"hello"
存储在
.rodata
段,该段在
mmap()
时被映射为只读页。但进程启动时,内核并未为其分配物理页,而是采用
延迟分配(Lazy Allocation)
。当CPU首次执行
mov %rax, %rdi
(将字符串地址载入寄存器)时,一切平静;但当
call printf
进入libc,
printf
内部尝试读取该地址的第一个字节时,MMU发现对应PTE的
Present
位为0,立即触发
缺页异常(#PF)
。以下是内核处理此异常的完整路径(基于x86_64 5.10内核):
-
CPU硬件动作
:保存当前
RIP(指令指针)到RSP,压入错误码(含U/S、W/R等信息),跳转到IDT第14号中断向量(page_fault)。 -
汇编入口
:
arch/x86/entry/entry_64.S中的page_fault标签,保存通用寄存器,调用C函数do_page_fault()。 -
地址合法性检查
:
do_page_fault()首先调用search_exception_table()检查RIP是否在内核异常表中(用于copy_from_user等容错),此处不匹配,继续。 -
获取故障地址
:
read_cr2()读取CR2寄存器(CPU自动存入触发缺页的虚拟地址)。 -
查找VMA
:调用
find_vma(mm, address),在进程的mm_struct->mmap红黑树中搜索覆盖address的vm_area_struct。.rodata段的VMA被找到。 -
权限验证
:检查VMA的
vm_flags(如VM_READ)是否允许本次访问(错误码显示是读操作,W/R=0),通过。 -
判断页类型
:
vma_is_anonymous(vma)返回false(这是文件映射),进入handle_mm_fault()。 -
故障分类
:
handle_mm_fault()调用__handle_mm_fault(),根据PTE状态(!present && !prot_none)判定为FAULT_FLAG_VMA。 -
分配页表项
:调用
pmd_alloc()、pud_alloc()、pgd_alloc()(如需),确保各级页表已存在。 -
分配物理页
:调用
alloc_pages_vma(GFP_HIGHUSER_MOVABLE, 0, vma, address, 0),从ZONE_NORMAL或ZONE_HIGHMEM分配一个4KB页。 -
读取文件内容
:调用
filemap_fault(),通过mapping->a_ops->readpage()(通常是mpage_readpage())从磁盘读取.rodata对应块到新分配的页。 -
设置PTE
:调用
mk_pte(page, vma->vm_page_prot)生成PTE值(含_PAGE_PRESENT、_PAGE_USER、_PAGE_RW等),并用set_pte_at()写入页表。 -
TLB刷新
:调用
flush_tlb_one()使新PTE对CPU可见。 -
更新页状态
:
SetPageUptodate(page)、UnlockPage(page)。 -
返回用户态
:
do_page_fault()返回,CPU从RSP恢复寄存器,重新执行触发缺页的那条mov指令。 -
第二次访问
:此时PTE已
Present,MMU成功查表,mov指令正常完成。 -
后续优化
:内核可能预读(
readahead).rodata后续几页,减少下次缺页概率。
整个过程在纳秒级完成,用户态程序毫无感知。但如果你用
perf record -e page-faults -g ./hello
,就能捕获到这次事件,并在
perf report
中看到完整的调用栈:
do_page_fault
→
handle_mm_fault
→
filemap_fault
→
mpage_readpage
。
4.2 手动模拟页表操作:用
/proc/<pid>/pagemap
和
/proc/<pid>/maps
逆向工程
虽然不能直接修改页表(需内核模块),但我们可以用用户态工具“窥探”其状态。以下是一个完整的实操脚本,用于分析一个进程的内存布局:
#!/bin/bash
# analyze_pagemap.sh <pid>
PID=$1
if [ -z "$PID" ]; then
echo "Usage: $0 <pid>"
exit 1
fi
echo "=== Process $PID Memory Map ==="
cat /proc/$PID/maps | head -20
echo -e "\n=== Page Table Analysis (first 10 VMA entries) ==="
# 解析/proc/$PID/maps,提取前10个VMA的起始地址
awk '$1 ~ /^[0-9a-f]+-[0-9a-f]+/ && NR<=10 {print $1}' /proc/$PID/maps | while read vma_range; do
start=$(echo $vma_range | cut -d'-' -f1)
end=$(echo $vma_range | cut -d'-' -f2)
size=$((0x$end - 0x$start))
# 计算该VMA第一个页的pagemap偏移
page_offset=$((0x$start / 4096 * 8))
if [ $size -gt 0 ]; then
# 读取pagemap中该页的PTE值(需root)
if [ -r "/proc/$PID/pagemap" ]; then
pte_val=$(dd if=/proc/$PID/pagemap bs=1 skip=$page_offset count=8 2>/dev/null | od -An -tx8 | tr -d ' ')
if [ -n "$pte_val" ] && [ "$pte_val" != "0000000000000000" ]; then
present=$((0x$pte_val & 1))
writable=$((0x$pte_val & 2))
user=$((0x$pte_val & 4))
dirty=$((0x$pte_val & 64))
pfn=$((0x$pte_val >> 12 & 0x000ffffffffff000))
echo "VMA [$start-$end] (size:$size): PTE=0x$pte_val | Present:$present Writable:$writable User:$user Dirty:$dirty PFN:0x$pfn"
else
echo "VMA [$start-$end]: Not present or pagemap unreadable"
fi
fi
fi
done
运行此脚本(需root权限),你会看到类似输出:
VMA [55e2a0000000-55e2a0001000] (size:4096): PTE=0x8000000000000087 | Present:1 Writable:1 User:1 Dirty:1 PFN:0x8000000000000
其中
0x87
的二进制是
10000111
,对应
P=1, R/W=1, U/S=1, A=1, D=1
,完全符合我们对
.data
段的预期。
4.3 调优实战:为Redis配置大页与内存策略
Redis是内存数据库,对分页管理极为敏感。以下是生产环境的标准调优步骤:
步骤1:启用透明大页(THP)
# 临时启用
echo always > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 永久生效(/etc/rc.local或systemd service)
echo 'echo always > /sys/kernel/mm/transparent_hugepage/enabled' >> /etc/rc.local
defrag=never
至关重要,避免THP后台整理导致Redis主线程卡顿。
步骤2:关闭swap倾向
# 减少匿名页换出
echo 1 > /proc/sys/vm/swappiness
# 确保Redis内存不被轻易换出
echo 'vm.swappiness = 1' >> /etc/sysctl.conf
步骤3:绑定NUMA节点(多路服务器)
# 查看NUMA拓扑
numactl --hardware
# 启动Redis时绑定到本地内存节点
numactl --cpunodebind=0 --membind=0 /usr/local/bin/redis-server /etc/redis.conf
避免跨NUMA节点访问内存,减少延迟。
步骤4:监控关键指标
# 监控THP状态
grep -i huge /proc/meminfo
# 监控Redis内存使用(注意RSS vs VSZ)
ps aux --sort=-%mem | grep redis
# 监控缺页率(每秒)
watch -n 1 'grep "pgpgin\|pgpgout\|pgmajfault" /proc/vmstat'
理想状态下,
pgmajfault
(主缺页)应趋近于0(启动后稳定),
pgpgin/pgpgout
(页入/出)应极低。
我在线上部署过一个32GB内存的Redis实例,未调优前
pgmajfault
达200+/s,P99延迟波动超20ms;启用THP并绑定NUMA后,
pgmajfault
降至0,P99延迟稳定在0.3ms以内。这印证了:分页管理的调优不是玄学,而是可量化、可验证的工程实践。
5. 常见问题与排查技巧实录:那些让你深夜抓狂的分页相关故障
5.1 故障速查表:症状、根因与一线诊断命令
| 症状 | 可能根因 | 诊断命令 | 关键指标解读 |
|---|---|---|---|
dmesg
刷屏
Out of memory: Kill process ...
| 物理内存+swap耗尽,OOM Killer触发 |
free -h
, `cat /proc/meminfo | grep -E "(Mem
| Swap |
strace
显示大量
mmap
失败,
errno=12
(ENOMEM)
|
vm.max_map_count
超限(每个进程最多65536个VMA)
|
cat /proc/sys/vm/max_map_count
,
cat /proc/<pid>/maps | wc -l
|
进程
maps
行数 >
max_map_count
;常见于Java应用(大量JIT编译、类加载)
|
perf top
显示
do_page_fault
高频出现
| 频繁缺页,可能是内存碎片或THP未生效 |
perf record -e page-faults -g -p <pid> sleep 10
,
perf report
|
page-faults
事件占比 > 5%;调用栈集中在
filemap_fault
(文件映射)或
alloc_pages_vma
(匿名页)
|
top
中
%wa
(IO wait)极高,但磁盘IO不高
|
kswapd
忙于回收页,但文件页脏页太多,
writeback
阻塞
|
iostat -x 1
, `cat /proc/vmstat | grep -E "(pgpgin
| pgpgout |
容器内
free
显示内存充足,但应用OOM
|
cgroups v1的
memory.limit_in_bytes
限制,内核OOM在cgroup层级触发
|
cat /sys/fs/cgroup/memory/docker/<container_id>/memory.usage_in_bytes
,
cat /sys/fs/cgroup/memory/docker/<container_id>/memory.limit_in_bytes
|
usage_in_bytes
接近
limit_in_bytes
;
dmesg
中
Memory cgroup out of memory
提示
|
mmap()
返回
ENOMEM
,但
free
显示内存充足
|
ZONE_DMA32
(32位地址空间)耗尽(常见于32GB以上内存的32位内核或某些驱动)
|
cat /proc/zoneinfo
,
dmesg | grep -i "dma32"
|
Node 0, zone DMA32
的
free_pages
< 1000;需升级到64位内核或调整
vm.lowmem_reserve_ratio
|
5.2 独家避坑技巧:来自十年线上事故的血泪总结
技巧1:
mmap()
的
MAP_POPULATE
不是银弹
很多开发者听说“预分配页表可以避免运行时缺页”,就给所有
mmap()
加上
MAP_POPULATE
。这是巨大误区。
MAP_POPULATE
会强制内核在
mmap()
返回前,为整个映射区域分配并建立所有页表项。对于1GB的
mmap()
,这意味至少256000次内存分配和页表更新,
mmap()
调用可能阻塞数百
9005

被折叠的 条评论
为什么被折叠?



