搞过一段时间Linux服务器和业务性能优化的人,大概都遇到过这样的瞬间:进程里一个指针访问的是虚拟地址,CPU拿着它去找数据,最终落到的却是物理页框,中间这层转换的核心就是页表。平时业务照常跑,你几乎感觉不到页表的存在,可一旦出现“内存明明还剩不少,进程却malloc失败”“free显示还有几十G,业务却卡得一塌糊涂”这类诡异现象,最后追查下来,十有八九要绕回到Linux页表和内存管理这条链路上。
这篇东西不是教科书,是我把从虚拟地址到物理页框这条链路从头到尾过了一遍之后,整理出来的完整路径:虚拟地址为什么要存在、Linux用几级页表做翻译、物理页框怎么分配和回收、真出了线上问题该用什么命令和工具去观察这一整条链路。适合刚接触Linux内核、准备面试,或者正被线上内存问题折磨的同学。跟着文章走一遍,你能理解页表的设计逻辑,也能在问题来临时知道该往哪个方向查。
1. 虚拟地址这层“中间商”到底解决了什么问题
1.1 如果没有虚拟地址,世界会怎样
我们先做一个思想实验:假设所有程序直接读写物理地址。机器上跑两个进程,进程A往地址0x1000写了数据,进程B也要用0x1000,两边直接撞车,数据互相踩踏。更麻烦的是,程序编译时,链接器会把变量、函数安排到固定的地址上,比如把main函数放在0x400000,如果物理内存里这个区域已经被别的程序占用了,这个进程根本没法启动。
就算我们规定“谁先加载谁先用”,让编译器生成可重定位代码,运行前把里面的地址全部改一遍,这种方案在单进程时代勉强能用,但对多进程、动态库、按需加载这些现代操作系统的基本需求来说,就是一场灾难。程序一多,物理内存碎片化就会变得非常严重:内存东一块西一块都是小空洞,想找一段大的连续区域都难。
虚拟地址就是来解决这一系列问题的。操作系统给每个进程画一张独立的“地址地图”,也就是虚拟地址空间。进程自己以为独占整个地址空间,想怎么布局就怎么布局,实际上它访问的每个地址,都要由操作系统和硬件协同翻译成真实的物理地址。程序员写代码时不需要关心物理内存里哪个区域是空的,这一切都被透明处理掉了。
1.2 虚拟内存机制带来的多重红利
引入虚拟地址之后,很多从前难做或者做不了的事情,变得顺理成章。
第一个红利是进程隔离。每个进程的虚拟地址空间都互相独立,进程A无论如何也访问不到进程B的地址空间,除非显式用共享内存、mmap这类跨进程机制。某进程因为野指针写坏了内存,崩溃的只有它自己,不会顺手把别的进程拖下水。
第二个红利是按需分配。程序编译出来很大,但运行时不一定要把全部内容都装进物理内存。比如一个程序有100MB的代码段,实际执行到的可能只有20MB。虚拟内存允许“先用虚拟地址布局,等真正访问到某页时再分配物理页框”,这个过程就是后面要讲的缺页异常(page fault)。没有虚拟内存,你就必须把整个程序一次性加载进内存,启动速度和内存利用率都会很难看。
第三个红利是swap交换。物理内存不够时,系统可以把不常用的匿名页写到磁盘的swap分区上,腾出物理页框给别的进程用。数据还在虚拟地址空间里“原地不动”,只是它对应的物理页框被回收了。等进程再次访问时,再通过缺页异常从swap读回来。这套机制的前提就是虚拟地址和物理页框解耦。
第四个红利是fork的写时复制(Copy-on-Write,COW)。fork出来的子进程并不需要立刻复制父进程的全部物理内存,只需要把父进程的页表复制一份,并把所有页标记为只读。任何一方要写数据时,触发缺页异常,内核才真正复制那个页。一个几百MB的进程fork,瞬间就能完成,关键就在这里。
1.3 地址翻译这件事,到底是谁在做
虚拟地址转物理地址不能靠软件在每一条指令访问内存时都跑一遍函数,那样性能会低到无法接受。现代CPU里有一个专门干这活的硬件单元,叫内存管理单元(Memory Management Unit,MMU)。CPU执行指令访问某个虚拟地址时,MMU会自动完成查页表、获取物理地址的过程。
页表本身是内核在内存里维护的数据结构。MMU不负责“决定”页表长什么样,它只负责“读取”页表并按规则翻译。操作系统负责建立、修改、销毁页表,硬件负责在指令执行路径上高速查询。如果MMU发现某个虚拟地址没有对应的有效映射,就会触发一个异常,把控制权交回内核,由内核决定是帮忙建立映射,还是给进程判死刑(Segmentation Fault)。
用一句话概括:内核维护规则和地图,MMU负责按图索骥。理解了分工,再看后面所有流程都会很顺畅。
2. Linux页表的层级结构与地址翻译过程
2.1 为什么页表要设计成多级,而不是一张大表
先说一个直觉方案:用一个数组做页表,把虚拟地址的高位当作数组下标。一个64位系统,假设虚拟地址用48位,页大小4KB,那么一个进程的全地址空间有2^48 / 2^12 = 2^36 ≈ 680亿个页。每个页表项哪怕只占8字节,这一个数组就要占用512GB内存。这还只是一个进程,显然不现实。
所以Linux把页表设计成了多级结构。x86-64上常见的是四级页表,层级依次是PGD(Page Global Directory)、P4D(Page 4th Directory)、PUD(Page Upper Directory)、PMD(Page Middle Directory)、PTE(Page Table Entry)。在四级页表的配置里,P4D这层会被“折叠”掉,相当于只剩PGD、PUD、PMD、PTE四级。
多级结构的好处是“按需分配”。进程哪怕声明了巨大的虚拟地址空间,只要没真正使用,就不需要为它建立完整的页表。一个进程可能映射了2GB虚拟地址,但实际只用了50MB,那它只需要建立少数PGD项指向的少量下层级页表,而不是为整个2GB一次性建出所有表项。这让页表的内存开销从“虚拟地址空间大小”变成了“实际使用的内存范围大小”,量级完全不同。
用图书馆查书来类比:你不必知道图书馆所有书放在哪,你先查区域索引,再去对应区域查书架号,然后查层号,最后查具体位置。大多数情况下,你只需要沿着一条路径走下去,沿途其他书架和你无关。
2.2 拿一个具体地址拆开看翻译全过程
为了看得更清楚,我们随便取一个用户态虚拟地址,比如0x7f2c4a1b9000(看起来很像栈或mmap出来的地址)。在x86-64四级页表下,一个48位虚拟地址会被拆成五段:
- 前9位:PGD索引
- 接着9位:PUD索引
- 接着9位:PMD索引
- 接着9位:PTE索引
- 最后12位:页内偏移
每级索引9位,因为一个页表页是4KB,每项8字节,正好装512个表项,2^9 = 512,严丝合缝。翻译过程大致是:
- 从CR3寄存器拿到当前进程的PGD页基址。
- 用虚拟地址的bit47到bit39作为下标,在PGD页里找到对应表项,该表项指向PUD页。
- 用bit38到bit30作为下标,在PUD页里找到对应表项,指向PMD页。
- 用bit29到bit21作为下标,在PMD页里找到对应表项,指向PTE页。
- 用bit20到bit12作为下标,在PTE页里找到对应表项,该表项里记录着物理页框号(PFN)。
- 物理地址 = 物理页框号 << 12 + 页内偏移(低12位)。
整个过程对用户态程序完全透明,你只需要知道,一次“看似普通”的内存访问,在硬件层面可能要走四五次内存查找。这也是为什么硬件缓存(TLB)如此重要,后面单独说。
为什么要用9位而不是其他位数?这完全是工程权衡的结果。每个页表页占用4KB,正好容纳512个8字节表项,连续内存读取效率好,索引计算也方便(位运算直接搞定,不用乘除法)。你不需要记住每一级的细节,要记住的是“逐级索引、按需分配、低12位是页内偏移”这个核心结构。
2.3 页表项里到底写了什么
页表项不只是一个指向下一级的指针。在x86-64架构里,每个PTE是64位,高52位存放物理页框号,低12位是各种标志位。下面这张表是我在实际排查中经常要对照的:
| 标志位 | 含义 | 说明 |
|---|---|---|
| bit 0 | Present | 页面是否在物理内存中,为0时访问会触发缺页异常 |
| bit 1 | R/W | 是否可写,0表示只读 |
| bit 2 | U/S | 用户态是否可访问,0表示仅内核态可访问 |
| bit 5 | Accessed | 页面是否被访问过,内核会定期清理该位用于统计 |
| bit 6 | Dirty | 页面是否被写入过,写回文件系统或swap时要用 |
| bit 7 | PS | 页大小标志,置1时PMD或PUD层直接指向大页 |
| bit 8 | Global | 全局页,进程切换时TLB不淘汰该条目 |
| bit 63 | NX | 禁止执行,防止代码注入攻击的关键开关 |
低12位里包含这么多语义,平时看到“页表项”感觉像一个黑盒,其实核心信息就这几样:页面在不在(Present)、能不能写(R/W)、谁能用(U/S)、有没有被用过(Accessed)、干不干净(Dirty)、可不可以执行(NX)。排查漏洞和做系统调优时,经常要回头看一眼这些位。
还有个容易被忽略的点:PTE里存物理页框号时,天然就支持“物理页不在内存里”的状态。当Present为0时,CPU不知道这个地址该不该存在,就把决定权交给内核的缺页异常处理函数。这也是“虚拟地址到物理页框”整条链路里最灵活的一环。
2.4 性能生命线:TLB和它带来的加速
前文说了,一次虚拟地址翻译可能要查好几级页表,每次都从内存读,一条普通指令访问数据的开销会变得非常夸张。所以CPU里做了一个高速缓存,专门保存最近用过的“虚拟页号 -> 物理页框号”映射关系,这就是TLB(Translation Lookaside Buffer)。
TLB的原理和CPU的L1/L2缓存类似:命中就免掉多级页表查询,只做一次快速的直接转换。实际程序中,指令访问有很强的局部性,比如循环里的数组,连续访问的页就那么几个,TLB命中率通常很高。Linux内核里很多优化也围绕TLB展开,比如“大页”为什么能提升性能:一个2MB的大页只需一个TLB条目,而同样覆盖2MB空间,普通4KB页需要512个条目。TLB条目数有限,用了大页,覆盖空间一下子就大了几十倍。
进程切换时,不同进程的页表不同,老的TLB条目如果不清理,后续翻译就会出错。老的做法是进程切换时全量刷新TLB,成本不低。现代CPU引入了PCID(Process Context Identifier),TLB条目会带上进程标识,切换时保留与自己进程ID匹配的条目,大大降低了切换开销。这也是为什么有些服务器场景下面,线程多的进程性能表现更好,因为线程共享进程地址空间,线程之间切换不涉及TLB刷新。
3. 从虚拟地址到物理页框:缺页、分配与回收的完整路径
3.1 缺页异常:虚拟和物理之间的“缓存未命中”
前面翻译地址,讲的是页表项已经存在且Present标志位为1的情况。但Linux的策略是“宁可晚分配,不可早分配”:很多页在第一次访问前,页表项根本不存在,或者Present标志位是0。当MMU发现某个虚拟地址没有有效映射时,就会触发缺页异常,进入内核的缺页处理流程。
缺页异常大致分两类。第一类是轻微缺页(minor fault):页面已经在物理内存里,只是当前进程的页表里还没建立映射。典型场景是按需分配内存:进程调用malloc时,内核只是修改了虚拟地址空间布局,并没有真的给物理页框;真正往这块内存写入第一个字节时,MMU一翻译发现没有页表项,内核就分配一个物理页框、填充零、建立PTE,全程不需要读磁盘,速度很快。
第二类是严重缺页(major fault):页面内容不在物理内存里,需要从磁盘读取。常见于程序刚启动时,可执行文件中的代码段还没加载进内存;或者某个页被swap换出到磁盘,进程要访问它时需要先读回。一次major fault可能带来几十毫秒的IO延迟,对性能影响很大。所以很多性能分析工具会特别关注major fault次数。
缺页处理函数拿到发生异常的虚拟地址之后,会先去查对应的虚拟内存区域(VMA,也就是进程地址空间里“这段地址用来干什么”的元数据)。VMA定义了这段区域是文件映射、匿名映射,是可读、可写还是可执行。根据VMA的类型和缺页原因,内核决定是分配新页、读文件、读swap,还是直接返回一个错误给进程(比如访问了非法地址,导致SIGSEGV)。
有个很典型的面试题:“malloc到底分不分配物理内存?”答案是不分配。malloc本质上只是往进程地址空间里注册一段VMA,真正分配物理页框发生在第一次访问时。用strace观察会发现malloc本身不触发多少系统调用,而第一次memset这块内存时,才可能有minor fault大量发生。
3.2 物理页框从哪来:伙伴系统与slab分配器
缺页异常说“分配一个物理页框”,那物理页框从哪个池子里来?答案是Linux内核的伙伴系统(Buddy Allocator)。伙伴系统把物理内存按2的幂次分成order 0到order 10共11个链表:order 0每个块1页,order 1每个块2页,order 10每个块1024页。请求分配连续块时,优先在合适的order链表里找,如果没有,就向上合并更大的块,然后拆成两半。
举个例子:要分配8页连续内存,内核先去order 3的链表找,找到就直接用;找不到就去order 4拿16页,拆成两个8页,一个返回给调用者,另一个挂回order 3链表。释放内存时反向合并,所以叫“伙伴系统”。这种设计让物理页框的分配和回收效率很高,也有效缓解物理内存碎片。
但伙伴系统也有局限:它按“页”做单位,一页4KB起步,普通内核对象经常只需要几十字节。如果每个小对象都占整个页,内存浪费就太严重了。因此Linux又实现了slab分配器(新内核是slub),它在伙伴系统拿到的页框上再切出一个个小对象缓存,比如kmalloc-16、kmalloc-32、kmalloc-64这些常用对象池。你写内核模块时用的kmalloc,实际就是从这些池子里拿对象的。
从缺页角度来说,分配物理页框给用户进程时,走的是伙伴系统的alloc_pages路径。这个路径上还要考虑GFP标志,比如要不要允许IO、要不要允许文件系统操作、能不能睡眠。这些标志影响分配失败时的行为和调用点是否安全,这也是为什么内核代码里到处都能看到GFP_KERNEL、GFP_ATOMIC这种参数。
3.3 页面生命周期与回收:从LRU到OOM
物理页框不是分出去就永久归某个进程了。Linux内核维护一组LRU链表,把物理页按“最近使用”排序。当系统内存压力变大时,内核会先尝试回收这些页:文件页直接写回文件系统后释放,匿名页则需要写入swap再释放。
内存回收有两个主要路径。一个是后台回收线程kswapd:每个内存节点都有一个kswapd内核线程,当内存水位线低于某个阈值时,它会在后台慢慢回收;另一个是直接回收direct reclaim:当前进程分配内存时发现内存不足,只能自己停下来参与回收,这会带来明显的延迟尖峰。
如果回收之后还是不够,内核最终会调用OOM Killer选择一个进程杀掉。选谁呢?内核会根据oom_score给每个进程打分,分数高意味着“内存占用大、存活价值低、杀掉的代价小”,然后选择分数最高的进程下手。生产环境里你经常能看到那种“某个进程突然凭空消失,dmesg里出现Out of memory: Kill process”的记录,背后就是这条路径。
从“虚拟地址到物理页框”的视角来看,回收机制的存在意味着“页表项里的Present标志位不总是1”。一个匿名页被swap out后,PTE里Present被清0,同时记录swap分区的哪个slot存着这个页的数据。下次进程访问时,再触发缺页异常,缺页处理函数发现这个地址关联的是swap entry,就知道该去读swap了。整个过程对进程不可见,但物理页框已经被别人用上了。
3.4 页表自身的内存开销,比你想象的更值得关注
页表本身也占用物理内存。一个进程的页表开销有多大?我们来粗算一下:假设一个进程实际使用2GB内存,按普通4KB页计算,大约需要512万个PTE条目,每个8字节,仅PTE这一层就是约40MB。再加上PGD、PUD、PMD层,总数还要略高。所以一个“看着只占了500MB RSS”的进程,可能背后还有几十MB页表开销。
页表内存的开销在“进程数量很多”的场景下会格外明显。比如一个服务器上跑着几百个Java或Node进程,每个进程哪怕只用几十MB,页表内存累加起来就很可观。如果你在排查Linux内存问题时发现,RSS统计之后算下来和free显示的used对不上,很大一部分差异就来自页表、内核slab、VMA结构等“不可见”的内核内存消耗。
这个问题还有一个变体:内存碎片导致页表增长。页表页本身也是一个个4KB页面,如果内核分配不出连续的物理页,某些层级页表页会分散在物理内存各处,进一步加大TLB的负担。这也是为什么内存压力大的机器上,不光业务响应慢,系统整体都会有一种“黏黏糊糊”的不流畅感。
4. 实操环节:亲手观察虚拟地址到物理页框的转换
4.1 用/proc接口查看进程地址空间
纸上谈兵再多,不如自己动手看一眼。Linux下每个进程都有一个/proc/ /maps文件,列出了进程完整的虚拟地址空间布局。随便找一个运行中的进程,比如一个sleep进程,然后执行:
sleep 600 & pid=$! cat /proc/$pid/maps输出是一堆形如“地址范围 权限 偏移 设备 inode 路径”的行。地址范围就是虚拟地址,权限里的r、w、x、p/s分别表示读、写、执行、私有、共享。你会看到像heap、[stack]、各种.so共享库映射、乃至vdso这样的特殊区域。
再看更细的统计,用smaps文件:
cat /proc/$pid/smaps | head -50每个VMA区域下面会有Rss、Pss、Size、KernelPageSize、MMUPageSize、Swap等字段。Rss表示该区域在物理内存中占用的页数,Pss则是把共享内存按比例分摊之后的值,判断一个进程“真实”占用内存时Pss比Rss更准确。这里你能直观感受到:进程“虚拟地址空间”很大,但真正“落到物理页框”的就是Rss和Swap这两块。
4.2 读取pagemap,从虚拟地址找到物理页框号(学习演示)
/proc/ /pagemap可以让我们从虚拟地址反查出对应的物理页框号。这个操作需要root权限,并且仅限于在你自己的机器上调试自己的进程,请勿在生产环境或他人机器上尝试。这是一个非常直观的验证手段,可以让你亲眼看到“页表翻译”的结果。
下面是一个读取自身pagemap的Python脚本示例,目的是验证当前进程某个变量的虚拟地址到底映射到了哪个物理页框:
import os import struct def get_pfn(vaddr): """ 仅作学习演示:读取当前进程 /proc/self/pagemap 返回 (物理页框号, present) """ with open("/proc/self/pagemap", "rb") as f: offset = (vaddr >> 12) * 8 f.seek(offset) data = f.read(8) if len(data) < 8: return None, False entry = struct.unpack("Q", data)[0] present = (entry >> 63) & 1 pfn = entry & ((1 << 55) - 1) return pfn, present # 分配一块内存并写入,触发物理页分配 buf = bytearray(4096) buf[0] = 1 addr = id(buf) # Python对象的地址,不完全等于bytearray的数据地址,演示用 # 更好的方式是使用 ctypes.addressof import ctypes addr = ctypes.addressof(ctypes.c_char.from_buffer(buf)) pfn, present = get_pfn(addr) print(f"虚拟地址: 0x{addr:x}") print(f"物理页框号: {pfn}") print(f"Present: {present}") if present and pfn: print(f"物理地址约: 0x{pfn << 12:x} + 0x{addr & 0xfff:x}")脚本里我用了bytearray和ctypes拿到一个实实在在的用户态缓冲区地址,然后去pagemap里查它的PTE,取回物理页框号。运行后你会看到输出里的虚拟地址和PFN,同时因为buf[0]=1触发了物理页分配,Present一定是1。这比读一百遍理论都直观:页表就是这么把虚拟地址翻译成物理页框的。
注意pagemap的PFN字段在不同内核版本里有权限限制,部分发行版默认隐藏,返回的PFN可能会是0。遇到这种情况不要慌,说明你的发行版出于安全考虑限制了PFN读取,功能本身没有坏。Go语言也有类似的unsafe机制可以取地址,但用Python演示最直观。
4.3 用perf和vmstat观察缺页与内存行为
光看静态映射还不够,我们来看看动态的缺页行为。Linux的perf工具可以直接统计一个进程的page fault次数:
perf stat -e page-faults,minor-faults,major-faults,dTLB-load-misses ./your_program输出里会明确告诉你程序运行期间发生了多少次minor fault、多少major fault、以及多少次dTLB加载未命中。如果major fault很多,说明程序大量访问了还没加载进内存的磁盘页,代码或数据局部性可能有问题;如果dTLB-load-misses很高,说明TLB覆盖不住工作集,可能需要考虑大页方案。
系统层面,vmstat可以做宏观观察:
vmstat 1关注si和so两列,分别表示swap in和swap out,单位是KB/s。如果这两个数持续有值,说明物理内存已经不够用了,系统正在频繁换页。这是最常见的线上内存问题信号。另外在perf record + perf report里也可以看到页表相关的热点,但在排查初期,用perf stat先看缺页和TLB未命中足够了。
4.4 常用内存管理开关:overcommit、透明大页与回收控制
Linux有几个vm控制项,线上调优经常要碰。
一个是overcommit(超额分配)。默认overcommit_memory=0,即“启发式”模式,内核允许轻微超额分配,但不允许离谱到明显分配不出物理内存的程度。如果设置为1,表示永远允许超额分配,malloc总是成功(哪怕物理内存早就不够);设置为2,表示禁止超过一定比例的超额分配,比例由overcommit_ratio控制。理解这个参数非常重要,否则你会遇到“free还有20G,malloc却返回NULL”的魔幻场景。
另一个是透明大页(THP)。THP会自动把连续的4KB页合并成2MB大页,减少页表项数量、提高TLB命中率,理论上很美好。但代价是分配大页时需要找连续物理内存,容易产生延迟尖峰,在某些延迟敏感的应用里反而引发性能抖动。很多数据库和高频交易系统在生产环境会主动把THP关掉:
echo never > /sys/kernel/mm/transparent_hugepage/enabled要不要关THP没有标准答案,要看具体负载。我曾经碰到过一个Java服务偶发抖动,最后排查下来就是THP在后台分配大页时阻塞了线程。设置成never之后抖动消失。但也有些场景下THP对性能有明显正面帮助,所以一定要用测试数据说话,不要凭感觉。
页面回收相关的控制项还包括vfs_cache_pressure、swappiness等。swappiness定义了内核在回收匿名页和文件页之间的倾斜程度,默认60,如果希望尽量少用swap,可以调低到10左右,但也不是越低越好,设成0在某些内核版本里并不等于完全不换出匿名页,这一点要心里有数。
5. 常见问题与排查技巧实录
5.1 内存还有很多,malloc却失败
现象:free显示物理内存和swap都有不少剩余,但进程malloc或者new对象时返回失败,或者直接崩溃。
排查思路先分清是哪个层面拒绝的。如果进程当前没有达到cgroup内存限制,先看overcommit配置。overcommit_memory=2时,系统会按照CommitLimit限额来拒绝超额分配,即使物理内存还有富余也可能失败。执行cat /proc/meminfo查看Committed_AS和CommitLimit,如果Committed_AS接近CommitLimit,就是把overcommit策略改成启发式(0)或者放宽限额(调高overcommit_ratio)。
另一种常见原因是被cgroup限了内存。查一下/sys/fs/cgroup/memory/memory.limit_in_bytes或者当前容器对应的memory.max,也许进程RSS早就撞到天花板了,只是free看不到容器的水位。
最后还有一种容易被忽略的情况:进程虚拟地址空间耗尽了。虽然64位地址空间看起来无限大,但如果进程反复malloc却不释放,或者有严重的内存泄漏,VMA数量暴涨,同样可能导致资源耗尽。用cat /proc/ /maps | wc -l看看VMA数量,如果异常高,那就是泄漏伴随VMA膨胀。
5.2 RSS不高,但系统内存却一直减少
现象:所有进程的RSS加起来并不高,但free里的available内存持续下降,最后触发OOM。
这种情况十有八九是页表或内核slab内存吃掉了内存。先看/proc/meminfo里的PageTables、Slab、KernelStack、VmallocUsed几项,如果PageTables特别大,说明有大量进程或者某进程页表膨胀。页表膨胀通常意味着进程映射了大量虚拟地址,哪怕RSS不高,页表也会占用可观内存。
Slab过高则要分类型:/proc/slabinfo里可以看是哪些对象占了内存。我遇到过最常见的是dentry和inode缓存,文件系统操作用完后没有及时回收。可以用echo 2 > /proc/sys/vm/drop_caches试回收,但这个方法只能缓解症状,根治还是要找到哪个进程/哪个路径在疯狂创建文件或打开文件不关闭。
如果PageTables和Slab都正常,还要怀疑一下是否某个进程在做大量的mmap映射并只访问了一部分,造成页表按需创建但物理页没分配。这种场景下,观察/proc/ /status里的VmPTE字段能看到端倪。
5.3 大页和透明大页导致性能抖动
现象:服务平均延迟正常,但有周期性的、无规律的延迟尖峰,查看性能数据时发现CPU在内存分配路径上卡了很久。
先看当前THP状态:
cat /sys/kernel/mm/transparent_hugepage/enabled如果输出是[always] madvise never,说明THP全局开启。那么内核可能在进程生命周期中自动合并相邻4KB页为2MB大页,这个过程由khugepaged内核线程做,会扫描进程地址空间并加锁复制数据。这段时间虽然很短,对低延迟业务来说就是灾难。
临时测试可以改回never,观察尖峰是否消失。如果确认是THP引起的,生产环境建议通过内核参数或者sysfs配置为never或madvise(只对显式使用madvise的区域开启),并同步修改systemd unit或启动脚本,防止重启后配置丢失。
5.4 OOM杀进程前的征兆与排查
现象:某个进程莫名其妙被kill,shell显示Killed,或者业务容器直接重启。
先看dmesg:
dmesg | tail -50如果看到Out of memory: Kill process,后面跟着进程名和oom_score,那基本就是OOM Killer动了手。日志里还会给出当时的内存统计信息,比如Mem-Info、Node 0 DMA32 free等,这些信息能帮你判断是系统整体内存耗尽,还是某个cgroup的内存限额没控制住。
如果是单个进程内存泄漏导致的全系统OOM,优先定位泄漏源。可以用valgrind、jemalloc的profiling、或者周期记录ps aux的RSS变化曲线来锁定。如果是容器场景,先看cgroup的memory.events,里面有oom_kill事件计数和内存峰值记录,直接告诉你是不是撞了限额:
cat /sys/fs/cgroup/memory.events5.5 快速排查速查表
| 现象 | 主要怀疑对象 | 优先检查命令 |
|---|---|---|
| malloc失败但有内存剩余 | overcommit、cgroup | cat /proc/meminfo、cat /sys/fs/cgroup/memory.events |
| 总内存减少但RSS不高 | 页表、slab、内核内存 | cat /proc/meminfo、cat /proc/slabinfo |
| 延迟尖峰 | THP、内存回收、swap | cat /sys/kernel/mm/transparent_hugepage/enabled、vmstat 1 |
| 随机进程被杀 | OOM Killer | dmesg、cat /sys/fs/cgroup/memory.events |
| 缺页频繁导致性能差 | 程序局部性、文件缓存 | perf stat -e page-faults,minor-faults,major-faults |
| TLB未命中率高 | 页表过大、缺少大页 | perf stat -e dTLB-load-misses、cat /proc/meminfo |
6. 写在最后的一点体会
我从虚拟地址到物理页框这条链路里学到的一个经验是:所有看起来奇怪的内存问题,本质上都能回到“页表映射状态”和“物理页分配状态”这两个层面来解释。一个变量为什么能被访问?因为它的PTE是有效的;一个进程为什么突然被杀?因为它需要的物理页框分配不出来。搞懂这套机制之后,再复杂的线上故障,你至少不会两眼一抹黑。
如果条件允许,建议在一台自己可控的虚拟机上做个小实验:起一个进程,打印某个临时变量的地址,再用前面pagemap脚本查它的物理页框号;然后时不时用vmstat观察内存水位,主动做一次大量内存分配和释放,亲眼看看缺页数量怎么变。等你亲手“抓到”过一次地址翻译的瞬间,页表这个概念就不再是纸面上的理论了。
最后再分享一个调试习惯:排查内存问题的时候,我通常会先看/proc/meminfo里的Available,而不是只看free的第一行,因为Available才是真正能拿来用的内存估算值;紧接着看dmesg有没有OOM记录,再看目标进程的smaps和pagemap。照着这个顺序走,大多数内存诡异问题都能快速缩小范围。页表深不见底,但值得你花时间挖一挖。