操作系统内存管理全解析:虚拟内存、页表TLB与OOM排查

发布时间:2026/10/23 3:53:25
操作系统内存管理全解析:虚拟内存、页表TLB与OOM排查 操作系统内存管理这个话题我前后翻过三遍不同的教材真正开窍还是在一台测试机内存曲线诡异上涨、排查了整整两天之后。教科书会告诉你分页、虚拟内存、页面置换算法但不会告诉你为什么/proc/meminfo里 available 还剩 20G 而 free 只有 800M也不会告诉你为什么物理内存看起来够用、一个 2MB 的大页申请却反复失败。这篇内容我把操作系统内存管理从头到尾拆一遍先讲清楚它在管什么、为什么这么管再落到页表、TLB、置换算法这些硬骨头最后给三段能直接跑的验证代码和一张排障速查表。适合正在准备相关面试的同学、刚接手服务端性能问题的后端以及所有被“内存够不够”这个问题折磨过的人。看完你至少能做到两件事看到一个进程的 smaps 能说出它内存花在哪看到一个内存告警能判断是泄漏、碎片还是正常缓存。1. 内存管理到底在管什么从三个绕不开的矛盾说起1.1 隔离、复用、效率三者的拉扯决定了所有设计很多人一上来就背“分页是为了解决外部碎片”这个答案只对了三分之一。操作系统内存管理真正要摆平的是三个互相打架的目标。第一是隔离。进程 A 写坏一个指针绝不能把进程 B 的数据覆盖掉。如果所有进程都用物理地址直接寻址那 A 里一个野指针*(int*)0x1234就可能在 B 的关键结构上写脏数据整个系统的可靠性就无从谈起。隔离是刚需不是优化项。第二是复用。物理内存是有限的而进程对内存的需求是无限的、而且经常是稀疏的。一个进程可能申请了 1GB 的地址空间实际只碰了 30MB。如果按申请量全额分配物理页再多内存都不够用。所以必须允许“承诺出去的空间”大于“真实占用的物理页”这就是按需调页和超额承诺overcommit的由来。第三是效率。隔离和复用都要付出代价地址翻译要花时间缺页处理要陷入内核缺页太多还会引发磁盘 IO。如果翻译一次地址要几十个周期那所有程序都会被拖慢。所以整个过程必须在硬件辅助下做到“绝大多数访问感觉不到翻译的存在”。这三者其实是有优先级和妥协关系的。隔离是底线不能动复用是手段效率是约束。后面你会看到的所有机制——多级页表、TLB、大页、页缓存——本质上都是在“不破坏隔离”的前提下把复用做得更彻底、把效率损失压得更低。比如为什么要有大页因为多级页表在提升隔离粒度灵活性的同时把翻译成本抬高了大页就是一次针对性的补偿。为什么要有共享内存因为同一个物理页映射给两个进程既省了物理内存又避免了拷贝这是复用和效率的双赢。我在排查问题时有个习惯先判断这个现象属于哪一类问题。是隔离层出了问题比如越界写、栈溢出踩到别的映射还是复用层的问题缓存把内存吃满了、overcommit 放得太宽还是效率层的问题TLB 抖动、缺页风暴。三类问题的排查手段完全不同混在一起想很容易绕进死胡同。1.2 虚拟地址到物理地址一次翻译要过几道关卡理解了三个目标再来看机制就顺了。虚拟内存的核心是一条链CPU 发出虚拟地址 → MMU 查页表翻译成物理地址 → 访问物理内存。中间每一步都有可以优化的地方也都有可以出问题的地方。具体过程是这样的CPU 里的页表基址寄存器不同架构叫法不一样x86-64 上是 CR3指向当前进程的顶级页表。MMU 拿到一个虚拟地址后先按页大小把地址切成“页号 页内偏移”两部分。以 4KB 页为例低 12 位是偏移高位是页号。然后逐级查页表找到最终的物理页帧号再把物理页帧号和偏移拼起来得到物理地址。听起来很直白但这里有个关键设计页表本身也存在内存里所以查一次页表就要访问好几次内存。如果只查一级还好多级页表就变成了“访问一次数据要读四次内存”的灾难。这就是 TLB 存在的理由——它是一个专门缓存地址翻译结果的小型高速缓存命中就直接出结果不用走完整流程。我在讲解这部分的时候喜欢用图书馆的类比。物理内存是一排排书架虚拟地址是读者手里的索书号。索书号是连续的同一本书的页码但书在书架上未必连续。页表就是那本“索书号→书架位置”的目录。目录本身太厚所以分成四级先查总目录找到分馆再查分馆目录找到楼层再查楼层目录找到书架最后在书架上定位。TLB 就是读者脑子里记住的“最近查过的几个索书号对应位置”翻书的时候先想一下想不起来再去查目录。真实的地址拆分用代码看最直观。下面这段 Python 演示了一个 48 位虚拟地址在 x86-64 四级页表下是怎么被切开的页大小 4KBPAGE_SHIFT 12 # 4KB 页 IDX_BITS 9 # 每级索引 9 位 MASK (1 IDX_BITS) - 1 va 0x00007f8a2c3d4e50 offset va ((1 PAGE_SHIFT) - 1) # idx[0] 是最底层页表索引PTidx[3] 是顶层PML4 idx [(va (PAGE_SHIFT IDX_BITS * i)) MASK for i in range(4)] print(foffset 0x{offset:03x}) for lvl, name in zip(idx, [PT, PD, PDPT, PML4]): print(f{name:5s} index {lvl:3d} 0b{lvl:09b})跑出来你会看到一个看似普通的 48 位地址被拆成了四段 9 位索引加一个 12 位偏移。这个拆分逻辑不是凭空规定的而是被“每级页表正好占一页”这个约束反推出来的9 位索引意味着每级 512 个表项每项 8 字节正好 4096 字节一页装下。设计上没有任何浪费也没有跨页的多余访存。这种“所有参数互相咬合”的设计在操作系统里非常普遍你以后看到类似的数字512、4096、1024基本都能倒推出同样的约束关系。提示这里说的 48 位只是当前主流实现的有效位宽。实际取指时高位会做符号扩展所以你在调试器里看到的地址经常是0x00007f...这种 64 位形式但真正参与页表索引的只有低 48 位。分析地址时别被高位的ff或00干扰。2. 页表项与 TLB地址翻译的性能账本2.1 页表项里存的不只是物理页号很多人以为页表项就是一个“虚拟页号→物理页号”的映射实际上一个 8 字节的页表项里塞了十几个字段每一位都有用途。理解这些位你才能看懂/proc/pid/smaps里那些奇怪的状态。字段含义典型用途有效位该页是否已装入物理内存为 0 时访问触发缺页异常读写位允许写还是只读写只读页触发保护异常也是写时复制的开关用户/内核位能否在用户态访问防止用户程序读内核数据访问位最近是否被访问过Clock 算法和页面回收的主要依据脏位装入后是否被写过决定换出时要不要写回存储全局位是否跨进程全局有效内核页可以不随进程切换刷新缓存控制位该页是否绕过 CPU 缓存映射设备寄存器时必须设置物理页帧号指向实际物理页翻译的最终结果这张表里最值得说道的是访问位和脏位。它们不是给别人看的是给内核的页面回收器看的。内核要做页面置换时唯一能拿到手的“热度信息”就是这两个硬件自动维护的位。访问位告诉你这一页最近有没有人碰脏位告诉你这一页换出去之前要不要先写回。如果没有这两个位置换算法就只能瞎猜性能会差一个数量级。还有一个经常被忽略的点只读映射加上写保护是实现写时复制的基础。父进程 fork 出子进程后两者的页表项都指向同一批物理页权限被设成只读。任何一方尝试写入MMU 就抛保护异常内核捕获后发现“哦这是共享的只读页”于是复制一份新的物理页给写的那一方权限改回可写。一次拷贝被推迟到真正需要的时候才发生这就是 fork 之后 exec 场景下性能良好的关键原因。2.2 多级页表是怎么把 512GB 压回 4KB 的有人在面试里被问“为什么不用单级页表”标准答案是“单级页表太大”。但这个“大”到底有多大值得算一遍算完你就再也忘不掉了。假设 48 位虚拟地址、4KB 页、每项 8 字节。单级页表需要覆盖 2^(48-12) 2^36 个页表项也就是 687 亿项乘以 8 字节等于512GB。也就是说每创建一个进程光页表就要吃掉 512GB 物理内存——这显然不可能。多级页表用“稀疏映射”解决了这个问题。四级结构下每一级页表都是一页4KB512 项。一个只用了少量地址空间的进程可能只需要 1 个顶级页表 1 个三级 1 个二级 1 个一级总共 4 页 16KB。按需分配是这里的关键词只有真正被访问到的地址区间才会创建对应的下级页表。这跟文件系统的多级索引是同一个思路稀疏数据不需要完整的索引结构。代价是什么呢代价是 TLB 未命中时的访存次数变多了。单级页表未命中查一次就够四级页表理论上要查四次实际上现代处理器有页表遍历缓存 page-walk cache能省掉一部分。所以这是一个明确的交易用“最坏情况下的查询次数”换“平均情况下的内存占用”。在大多数真实负载下地址空间的使用是高度稀疏的这笔交易划得来。我见过有人纠结“那能不能用两级取个中间值”。理论上可以历史上也有过不同级数的实现。但级数和工作集大小是有关系的级数太少单级页表太大级数太多查询路径太长。四级的 9/9/9/9 是在 48 位地址约束下能做到“每级正好一页”的最自然解改动任何一位都会破坏这个整齐性所以主流实现都收敛到了这个结构上。2.3 TLB 命中率掉一个点性能掉多少这一节是我最想讲清楚的部分因为它是“内存管理影响真实性能”最直接的证据也是很多性能问题排查的盲区。先建立一个量化模型。假设 TLB 访问需要 1 个时间单位内存访问需要 100 个时间单位页表四级未命中时需 4 次页表访存 1 次数据访存。TLB 命中1 100 101TLB 未命中1 4×100 100 501按命中率 h 计算平均访存时间T h × 101 (1-h) × 501。TLB 命中率平均访存时间相对命中 99% 的劣化99.9%101.4-99%105.0基准95%121.015.2%90%141.034.3%80%181.072.4%看这个表就知道命中率从 99% 掉到 95%只掉了 4 个百分点平均访存时间却涨了 15%。这不是线性关系因为一次未命中的代价是命中的近 5 倍。所以任何让“工作集的页数超过 TLB 容量”的行为都会带来不成比例的惩罚。什么样的行为会导致 TLB 抖动最典型的是大数组跨步访问。假设你有一个 1GB 的数组按 4KB 步长遍历每一步都落到不同的页上。TLB 最多缓存一两千个页表项遍历到后面开始全部未命中每次访问都要走一遍四级页表。我实际测过一个按页步长扫描大数组的循环比顺序访问慢了三四倍几乎全部损耗在地址翻译上。这就是大页存在的意义。把 4KB 页换成 2MB 页同一个 TLB 表项能覆盖的地址范围放大 512 倍1GB 数组只需要 512 个页表项轻松装进 TLB。代价是内部碎片即使你只用了 2MB 里的 4KB物理上也占用了 2MB。所以大页适合大块连续访问的场景数据库缓冲池、大数组计算、虚拟机内存不适合大量小对象分配的场景。注意不要一看到 TLB 抖动就无脑开大页。大页会放大内存占用、增加内存碎片、让页面回收变粗粒度。我的建议是先测用perf stat看dTLB-load-misses的占比如果未命中率低于 1%开大页基本没收益如果高于 5% 且访问模式是连续大块的再考虑。3. 页面置换与内存分配取舍的艺术3.1 局部性原理所有置换算法的共同赌注物理内存不够的时候必须挑一些页换出去。挑谁这个问题看似简单实际上所有答案都建立在一个假设上程序访问内存是有局部性的。局部性分两种。时间局部性是说刚访问过的地址很可能马上再被访问比如循环里的计数器变量。空间局部性是说访问了地址 X附近的 X4、X8 也很可能被访问比如顺序遍历数组。这两种局部性意味着程序在任意一个时间窗口内真正活跃的页只占它总地址空间的一小部分。这个“一小部分”就是工作集working set。工作集模型是理解一切的钥匙。假设窗口为 Δ 时间工作集 W(t, Δ) 就是这 Δ 时间内被访问过的页的集合。如果物理内存能装下所有进程的工作集之和系统就几乎不会缺页性能接近理想如果装不下就会发生抖动thrashing——进程刚把页换进来马上又被换出去CPU 大量时间耗在换页上吞吐量断崖式下跌。这里有一个反直觉的结论给进程分配的物理页不是越多越好也不是越少越好而是要和它的工作集匹配。分配太少缺页频繁分配太多超出工作集的部分是浪费还会挤占其他进程。操作系统内存管理的核心难题就是动态地估计每个进程的工作集大小并据此分配——而这个估计永远只能是近似的因为它只能通过访问位这样的间接信号去推测。缺页率和分配帧数的关系可以画成一条曲线帧数很少时缺页率随帧数增加急剧下降到了某个点之后曲线趋于平缓甚至不再改善。这个拐点大致就是工作集的大小。实际调优的目标不是“让缺页率为零”那意味着大量内存闲置而是让系统运行在曲线拐点附近。这也是为什么很多服务在内存加了一倍之后性能没提升——它本来就没在工作集的瓶颈上。3.2 FIFO、LRU、Clock 的真实差距理论最优的置换算法是“换出未来最长时间不会被访问的页”但这需要预知未来无法实现。所有实用算法都是在逼近它。下面这张表是我自己整理的核心对比算法判断依据实现代价主要缺陷最优置换未来最远使用不可实现只作为理论下界FIFO进入内存的先后一个队列即可会换出热点页存在 Belady 异常LRU最近一次访问时间每次访问都要维护顺序硬件难以精确实现开销大Clock二次机会访问位 环形扫描一个环 一个指针给所有页一次“复活”机会精度中等改进型 Clock访问位 脏位同上需要优先换出干净页以减少写回工作集时钟访问时间 工作集窗口环形链表 时间戳实现复杂参数敏感Belady 异常是最反直觉的现象页框变多了缺页次数反而增加。这不是理论上的假想用 FIFO 就能稳定复现。引用串1 2 3 4 1 2 5 1 2 3 4 53 个页框跑 FIFO缺页9次4 个页框跑 FIFO缺页10次。多给了一个页框缺页反而更多。原因是 FIFO 的判断依据“进入时间”和“未来是否被用”完全没有关系多一个页框改变了换出顺序恰好把即将要用的页提前踢了出去。LRU 则不会出现这种异常因为它的顺序是有“栈式性质”的N 个页框下的驻留集合永远是 N1 个页框下驻留集合的子集增加页框只会让结果更好不会更差。那为什么现实中不用 LRU因为精确的 LRU 要求每次内存访问都更新一个全局顺序结构这相当于每次访存都要加一次写操作和一个链表移动开销无法接受。所以真实系统用的是近似给每个页一个访问位硬件在访问时自动置 1内核扫描时看到 1 就清零并跳过给第二次机会看到 0 就换出。这就是 Clock 算法最多扫两圈就能找到一个可换出的页。它的精度不如 LRU但开销极低是个务实的折中。改进型 Clock 在上面基础上多看了脏位把页分成四类优先换出“未访问且干净”的页因为干净页换出不需要写回代价最低。这个优化在存储 IO 慢的系统上收益很明显。3.3 内核里的两级分配器伙伴系统与 slab用户态看到的是“页”内核自己也需要分配内存而且需求更细碎一个 100 字节的内核对象不可能给它一整页。所以内核里普遍采用两级结构这一点很多人容易混淆。第一级管页框典型实现是伙伴系统。它把所有空闲页框按 2 的幂次分组order 0 是 1 页order 1 是 2 页一直到 order 10 是 1024 页4MB。申请 4 页时如果只有 order 38 页的块就把它劈成两个 order 2一个用掉另一个挂在 order 2 的空闲链上。释放时检查“伙伴”是否也空闲是就合并回上一阶。这套机制的优点是合并高效、碎片可控缺点是只能按 2 的幂分配内部碎片明显。你可以直接看到每个阶的空闲块数量cat /proc/buddyinfo # Node 0, zone Normal 1024 512 256 128 64 32 16 8 4 2 0左边是高阶右边是低阶不同内核版本输出顺序可能不同看表头理解。如果高阶全为 0、低阶一大堆说明系统虽然总空闲内存不少但凑不出连续的大块这就是典型的碎片化会在申请大页或大块 DMA 缓冲时失败。第二级管小对象典型实现是 slab 分配器。它从伙伴系统批发整页然后切成固定大小的小槽位比如 32 字节、64 字节、128 字节各自一个缓存池。分配一个内核对象时直接从对应尺寸的池里取一个槽位不用每次都走伙伴系统。这解决了两个问题小对象分配的效率以及频繁分配释放造成的碎片。缺点是每种对象尺寸都要维护一个池池里的空闲槽位就是被“预留”的内存不能挪作他用。slabtop能直接看到各缓存池的占用情况排查“内存去哪了”时非常有用——很多时候你发现系统内存被吃了几个 Gfree里看不到进程占用答案就在 slab 里。4. 动手验证把内存行为跑出来看4.1 用 smaps 拆解一个进程的内存构成理论讲完得能落地。排查内存问题我第一个打开的文件永远是/proc/pid/smaps它把一个进程的虚拟内存按区域VMA逐段列出每段都带详细的统计。关键字段如下字段含义排查价值Size该段的虚拟地址空间大小判断是否有大块预留Rss实际驻留物理内存与 Size 差距大说明使用稀疏Pss按共享比例分摊后的驻留量判断真实独占内存Shared_Clean共享且未修改通常是动态库代码段Shared_Dirty共享且已修改共享内存、写时复制页Private_Clean私有且未修改只读数据、未写过的映射Private_Dirty私有且已修改堆内存的主要组成部分Anonymous匿名页大小堆、栈、malloc 出来的内存AnonHugePages其中用了大页的部分判断大页是否真的生效Swap换出到交换区的量判断是否发生了换出这里最容易搞混的是RSS 和 PSS。RSS 是“这个进程映射了多少物理页”如果一个 100MB 的动态库被 10 个进程共享每个进程的 RSS 都会算上这 100MB加起来是 1000MB但实际只占了 100MB。PSS 则是按共享者数量平摊每个进程算 10MB加起来正好 100MB。所以汇总所有进程的内存占用时加 PSS 才有意义加 RSS 会严重高估。常用的命令组合# 按 PSS 从大到小排序看谁真正占内存 sudo awk /^[0-9a-f]/{name$0} /^Pss:/{print $2, name} /proc/12345/smaps | sort -rn | head -20 # 汇总整个进程的 PSS sudo awk /^Pss:/{s$2} END{print s kB} /proc/12345/smaps实际用下来这套方法能回答 80% 的“这个进程内存花在哪”的问题。剩下 20% 是内核态内存slab、页表本身、内核栈在 smaps 里看不到要去/proc/meminfo和slabtop里找。页表本身也是一笔不小的开销一个用了 10GB 内存的进程其页表可能占几十 MB这部分不计入进程 RSS但确实是物理内存的消耗。4.2 写一段代码数缺页下面这段 C 代码用 mmap 申请 256MB 匿名内存然后按页步长逐页写入用 getrusage 统计缺页次数。这是我验证“按需调页”最常用的最小实验#include stdio.h #include stdlib.h #include string.h #include sys/mman.h #include sys/resource.h #include unistd.h int main(void) { size_t len 256UL 20; // 256MB size_t pg sysconf(_SC_PAGESIZE); char *p mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p MAP_FAILED) { perror(mmap); return 1; } struct rusage r1, r2; getrusage(RUSAGE_SELF, r1); // 每页写一个字节触发出第一次访问的缺页 for (size_t i 0; i len; i pg) p[i] 1; getrusage(RUSAGE_SELF, r2); printf(page size %zu\n, pg); printf(pages touched %zu\n, len / pg); printf(minor faults %ld\n, r2.ru_minflt - r1.ru_minflt); printf(major faults %ld\n, r2.ru_majflt - r1.ru_majflt); munmap(p, len); return 0; }编译跑一下你会看到 minor faults 大致等于触碰的页数256MB / 4KB 65536 页。这里的 minor fault 是“页在内存里但页表还没建好”或者“匿名页第一次访问需要清零分配”不涉及磁盘 IO。如果你把MAP_ANONYMOUS换成映射一个真实文件并且先把页缓存清掉就能看到 major fault——那是真的要读盘慢三个数量级。这个实验最有价值的观察是mmap 之后 RSS 几乎不涨写完一遍之后 RSS 才涨到实际触碰的量。所以“虚拟内存占用”和“物理内存占用”是两回事看到 Size 很大不必惊慌看 RSS 和 PSS 才是真的。还可以做一个对比实验把写操作改成长步长跳跃比如每次跨 1MB 写一个字节总触碰页数不变但大页机制可能让阳页数变多你能直观感受到内核预取和透明大页的影响。我在面试里常拿这个实验考人“为什么同样 256MB 数据两种访问模式的 RSS 不一样”能答上来的人基本就真的懂按需调页了。4.3 在隔离环境里复现一次 OOMOOM 这件事光看文档没用必须自己触发一次才记得住现场。一定要在容器、虚拟机或垃圾测试机上做不要把生产机搞挂。用 cgroup v2 给一个进程限制 128MB然后跑一个要分配 256MB 并持续写入的程序# 220M 的程序跑在 128M 的限制里 systemd-run --scope -u memtest-scope \ -p MemoryMax128M -p MemorySwapMax0 \ ./hog 220 # 触发后看内核日志 dmesg -T | grep -i -E oom|killed process | tail -20hog就是循环 malloc 加 memset让每一页都真正落盘。触发之后内核日志里会打印被杀进程的详细信息总虚拟内存、RSS、PSS、各个内存段的用量、以及 oom_score。这个现场信息比任何文档都值钱因为它告诉你内核判定“谁该死”时依据的是什么——主要是实际占用和进程的权重而不是虚拟申请量。我踩过的一个坑是第一次做实验没关 swap进程被换出去一部分迟迟触发不了 OOM跑半天没结果。第二次把MemorySwapMax设成 0 才复现出来。这个细节在真实排障里也很关键——很多“内存没爆但服务变慢”的案例其实是 swap 在兜底用性能换空间把 swap 一关立刻暴露真问题。还有一个更隐蔽的现象值得提一句被 OOM 杀掉的进程未必是“最该杀”的那个。内核的评分只反映快照时刻的占用一个刚启动正在预热、占用还没涨上来的关键进程可能活得好好的而一个老老实实占着大内存的缓存进程被干掉服务立刻崩。所以生产环境里我一般会给关键进程设保护调整权重让其更难被杀而不是依赖内核对“谁重要”的判断。5. 常见问题与排查实录5.1 free 内存少不等于内存不够这是新手问得最多、老手最容易解释不清的一个问题。free -h输出里有一列 available有一列 free两者差得很多很多人一看 free 只剩几百兆就慌了。结论先说看 available不看 free。free 是完全没有被使用的物理内存available 是“在不触发换出的前提下还能拿出来给新进程用的内存”。页缓存、可回收的 slab 都算在 available 里。Linux 的设计哲学是“空闲内存是浪费”所以会把大部分空闲内存拿去做页缓存加速文件访问需要时再腾出来。所以一个健康的、跑着文件密集型服务的机器free 接近 0 是正常的。指标含义健康判断total物理内存总量固定used已用不含缓存相对稳定free完全空闲偏低是正常的buff/cache页缓存和块缓存偏高是好事可回收available真正可用的估计值这个才是关键指标swap used换出的量大量非零且持续增长要警惕判断有没有问题我通常看三个信号available 是否长期低于总量的 10%swap 使用量是否在持续单向增长而不是波动以及是否有 major fault 在持续发生。三者同时出现基本可以确认内存压力真实存在而不是“缓存占着看着难看”。另一个容易被误判的是容器环境。在容器里跑free看到的往往是宿主机的内存不是容器的限额。要判断容器有没有被限死得看 cgroup 的memory.current和memory.max或者看memory.stat里的pgscan扫描回收的页数是否在增长。这个差异坑过不少人我见过有人在容器里盯着 free 看了半天其实和容器的实际限额毫无关系。5.2 内存泄漏和内存碎片怎么区分这两个问题的现象很像——内存占用都在涨服务都可能变慢——但根因和处置方式完全不同搞混了会南辕北辙。内存泄漏的特征是进程的 RSS 或 PSS 持续单调上升且不随负载下降而回落重启进程后恢复。定位手段是看 smaps 里哪个段在涨如果是堆段[heap]或者大量 Anonymous 映射在涨基本就是应用层的分配没释放如果是文件映射在涨看看是不是 mmap 的文件没 unmap。内存碎片的特征是总空闲内存明明够但特定的分配请求持续失败或者 RSS 没怎么涨但性能在下降。定位手段是看/proc/buddyinfo如果高阶块长期为 0 而低阶一大堆碎片就成立了。再有就是看 THP 的分配失败计数在/proc/vmstat里的thp_fault_fallback能反映大页分配失败的情况。现象泄漏碎片RSS 趋势持续单调上升相对平稳或小幅波动重启进程立刻恢复恢复有限大块分配不受影响持续失败buddyinfo 高阶正常长期为 0回收后效果无效部分改善两者的处置也完全不同。泄漏要改代码加监控、做对象生命周期审计必要时上采样工具看分配栈。碎片要靠架构手段把大块分配提前到启动阶段预留、启用页迁移和内存规整、把长期存活的对象和短期对象分开分配、或者直接换用更合适的分配器。我见过最有效的一招是启动时就把大块池子预分配好运行期不再向系统要大块——虽然看起来“浪费”但比运行期随机失败强得多。5.3 常见问题速查表这张表是我这几年攒下来的基本都是现场直接用得上的现象大概率原因第一步动作available 骤降、swap 增长真实内存压力看 smaps 里的 PSS 排行找大户进程 RSS 涨但 PSS 平稳共享页被重复计算换成统计 PSS别被 RSS 吓到大页申请失败碎片化高阶块不足看 buddyinfo 高阶考虑提前预留服务周期性卡顿缺页风暴或回收抖动看 vmstat 的 si/so 和 pgscan容器 OOM 但宿主机内存很空容器限额不是系统问题看 cgroup 的 memory.current/max文件读写慢页缓存被挤掉看 buff/cache 和 major fault 计数内存占用高但进程看不到内核 slab 或页表本身slabtop 计算页表开销一开大页马上 OOM大页放大了实际占用关大页或改成按需启用用这张表的时候有个提醒不要跳过第一步直接跳到结论。我见过太多次“看到内存高就认定泄漏”结果查了半天发现是页缓存白折腾一晚上。先确认指标口径再看趋势最后才定性。5.4 几个踩过的坑最后分享几个我觉得值得单独说的教训都是文档里不会写、但真的会耽误事的。第一个坑是页表本身的开销被严重低估。一个占用 64GB 内存的进程如果内存是零散分布的页表可能吃掉几百 MB 甚至上 G。这部分既不在 smaps 的 Rss 里也不在 slabtop 里查起来很隐蔽。后来我养成了习惯评估大内存进程的容量时额外留 1% 到 2% 给页表。第二个坑是把“虚拟内存占用”当成问题报警。有些程序尤其是带 GC 的运行时和一些数据库会预留极大的虚拟地址空间VMS 能到几十 G但实际 RSS 只有几百 M。早期我们的监控只看虚拟内存天天误报。后来全部改成看 RSS PSS 双指标噪音立刻消失了。第三个坑是忽视了大页的两面性。开大页确实能提升连续访问的性能但如果你的程序大量分配小对象大页会让每个对象实际占用的物理内存从几字节膨胀到 2MB 起步内存占用可能翻好几倍。我的做法是分场景数据库缓冲池、大数组计算这类明确的大块场景开其他场景关并且一定要监控大页区域的占用比例。第四个坑是以为 minor fault 不花钱。单次 minor fault 确实便宜但量大了一样致命。我遇到过一个小服务每秒触发几十万次 minor fault原因是频繁 mmap/munmap 小区域导致每次访问都要重建页表。后来改成复用一块固定缓冲区缺页数直接掉到接近零。这个经验告诉我缺页次数要和分配频率一起看单看总量会漏掉问题。再补一个实操上的小技巧想快速判断一次性能下降是不是内存引起的我会先跑vmstat 1看三列——si/so换入换出、pgscan回收扫描、以及bi/bo块 IO。如果这三者同时抬头几乎可以确定是内存压力导致的连锁反应如果三者都安静那问题多半在 CPU 或者锁竞争上可以直接把内存这条线排除掉省下大量时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询