ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux内核内存管理核心:虚拟地址空间、伙伴系统与Slab分配器

Linux内核内存管理核心:虚拟地址空间、伙伴系统与Slab分配器

1. 从一次内存问题排查说起

搞 Linux 内核和底层开发的朋友,大概率都经历过这种场景:一个跑了好几个月的服务,内存占用突然毫无征兆地飙升,free -g一看还剩不少,但进程就是不给你痛快用;或者系统负载不高,可dmesg里刷满了 order-3 及以上分配失败的报错。我去年排查的一个模拟项目X就是典型例子——每小时的批量任务一启动,内核日志就会出现page allocation failure: order:4,但物理内存明明还有几十 GB 空闲。当时领导的第一反应是“内存泄漏”,我查了一圈用户态进程的 RSS 和 VMS,数据都很正常,问题其实出在内核内存管理的分配路径上。后来定位到是碎片化导致高阶物理块分配不出来,跟虚拟地址空间的布局也有千丝万缕的关系。

这篇文章我想系统梳理一下 Linux 内核内存管理这条主线,围绕**虚拟地址空间、伙伴系统(Buddy System)和块分配器(Slab Allocator)**这三个核心模块展开。无论你是做嵌入式、写驱动、搞性能优化,还是单纯对内核感兴趣,搞清楚这条链路,很多线上疑难杂症都能找到方向。文章后面还会附上我实际排查问题时的经验、常用命令和避坑技巧,希望能给你省点时间。

2. 虚拟地址空间:进程眼中的“内存幻觉”

你写 C 语言程序时,指针指向的地址是不是真实的物理内存地址?答案是否定的。每个用户态进程都活在一个虚拟的世界里——虚拟地址空间。这不是什么故弄玄虚的设计,而是现代操作系统保证稳定性、隔离性和易用性的基石。

2.1 64 位下的地址布局拆解

以 x86-64 架构为例,硬件支持 64 位虚拟地址,但 CPU 实际上只使用低 48 位,也就是 256 TB 的虚拟地址空间。内核把这 256 TB 一分为二:低半部分留给用户态,高半部分留给内核态,中间隔着一条不可访问的禁区。用cat /proc/self/maps你能看到进程的地址分布,但那个输出比较抽象,我习惯看内核源码里的Documentation/x86/x86_64/mm.rst,里面画得很清楚。

从低地址到高地址,一个典型的 64 位进程虚拟空间大概长这样:

  • 代码段(text):程序机器码所在位置,只读可执行。
  • 数据段(data / bss):已初始化与未初始化的全局变量。
  • 堆(heap):通过malloc/brk向上增长的内存区域。
  • 内存映射段(mmap):共享库、mmap大文件、匿名映射都在这,一般向下增长。
  • 栈(stack):向下增长,每个线程有自己的栈。
  • vDSO / vsyscall 区域:内核映射到用户态的小块代码,用于加速系统调用。

注意,这只是逻辑上的“标准布局”,内核还引入了mmap_min_addr、ASLR、vm.legacy_va_layout等机制,实际排布会动态变化,但分段的基本原则不变。

为什么一定要虚拟地址?三个核心原因:

  1. 隔离性:每个进程都觉得自己独占整个地址空间,A 进程不可能直接访问 B 进程的内存,因为映射不到。
  2. 简化链接与加载:程序编译时不需要知道物理内存放在哪,所有地址都在虚拟层面统一处理。
  3. 按需分配与共享:物理页可以推迟到真正访问时才分配(缺页异常),多个进程也能通过映射共享同一份物理页(共享库)。

没有虚拟地址,你根本没法同时跑几十个进程还要保证互不干扰——两个程序都以为自己从 0x400000 开始执行,物理内存却可以错开,全靠页表做翻译。

2.2 页表与 TLB:地址翻译的“查字典”过程

虚拟地址要变成物理地址,CPU 会通过 MMU(Memory Management Unit)查页表。在 x86-64 下,页表通常是 4 级结构:PGD(Page Global Directory)→ PUD(Page Upper Directory)→ PMD(Page Middle Directory)→ PTE(Page Table Entry)。

每次内存访问都完整走 4 级页表代价极高,所以 CPU 里有一个专门的缓存:TLB(Translation Lookaside Buffer)。TLB 命中就是纳秒级,miss 就得去内存查页表,可能拖到上百纳秒。这也是为什么大页(HugePage)能显著提升性能——页越大,覆盖范围越大,TLB 的条目数需求越少,miss 率自然下降。

查看 TLB 状态可以用perf stat -e dTLB-load-misses,dTLB-store-misses,我经常在性能压测时先跑一把,如果 miss 比例高,第一反应就是关掉 THP 或者显式配 HugePage 看看有没有改善。

页表不是一次性建好的。内核采用惰性分配策略:mmap只是建立 VMA(Virtual Memory Area)结构,不会立刻分配物理页并填页表。只有 CPU 真正访问到某个虚拟地址、发现对应的 PTE 是空的,才会触发缺页异常(Page Fault),由内核现场分配物理页、建立映射、返回用户态重新执行那条指令。这个机制叫Demand Paging(按需调页),也是为什么程序刚启动时 RSS 很小、随着运行逐渐增大的原因之一。

2.3 vmalloc 和 vmap:内核空间的连续地址陷阱

用户态地址空间已经够复杂,内核态还有一个独立的虚拟地址映射体系。这里面最容易踩坑的是vmalloc。

物理内存分配出来常常是不连续的——尤其是系统运行一段时间后,很难找到一块大的连续物理内存。但有些内核模块(比如加载内核模块、分配较大的 DMA 缓冲)就是想要连续的虚拟地址。vmalloc 做的事情就是:在内核虚拟地址空间里找一段连续区域,再把一个个不连续的物理页映射进去。虚拟地址连续,物理地址不一定连续。

对应的还有kmap(32 位时代的旧物)和ioremap(把设备 MMIO 地址映射到内核空间)。

实际开发中,驱动代码尤其要注意:vmalloc 分配的内存不能直接在原子上下文用,因为 vmalloc 可能睡眠;而且 vmalloc 的 TLB 效率通常不如直接 kmalloc 的线性映射区域。能用kmalloc就别用vmalloc,这是内核开发的基本修养。

能看到这,你对虚拟地址空间就有了一个完整画面:用户态通过进程的页表映射物理内存,内核态有自己的映射窗口,地址翻译由 MMU + TLB 加速,物理内存只有真正被访问时才分配。接下来要讨论的问题就变成了:这一个个物理页,内核到底怎么管理?

3. 伙伴系统:物理页面的“大管家”

物理内存管理的核心数据结构就是伙伴系统(Buddy System)。它管理的最小单位是物理页帧(page frame),即 4KB(默认 PAGE_SIZE)。整个系统启动时,内核把物理内存划分为若干个node(对应 NUMA 节点),每个 node 下面又有若干zone(ZONE_DMA、ZONE_DMA32、ZONE_NORMAL、ZONE_MOVABLE 等),每个 zone 再用伙伴系统维护空闲页。

3.1 伙伴算法到底怎么运作

伙伴系统的灵魂思想是把空闲页按 2 的幂次分级维护。假设一个 zone 里维护了 11 个链表,链表下标对应 order 0 到 order 10,每个链表里的块包含 2^order 个连续物理页:

  • order 0:1 页(4KB)
  • order 1:2 页(8KB)
  • order 2:4 页(16KB)
  • ...
  • order 10:1024 页(4MB)

你需要分配 3 个连续页(order 2),系统会这么做:

  1. 先去 order 2 的链表里找有没有空闲块,有就直接拿走。
  2. 没有,就升到 order 3 找 8 页的空闲块。
  3. 找到后,把 8 页拆成两个 4 页的“伙伴”,一个拿出去用,另一个挂回 order 2 链表。
  4. 如果 order 3 也没有,继续往 order 4 找,一路拆下去。

释放时执行反向操作:页面归还时,系统检查它的“伙伴”(buddy)是否也空闲,如果空闲就合并成更大的块,再继续向上一级尝试合并,直到不能合为止。这个“向上合并、向下拆分”的过程,就是伙伴系统名字的由来。

[\text{请求大小为 } 2^{order} \text{ 页的连续块,先从 freelist[order] 分配;若无则从 freelist[order+1] 分裂:取低半部分返回,高半部分挂回 } freelist[order]]

释放时合并的思路用伪代码表达:

free_page(p): while order < MAX_ORDER: buddy = find_buddy(p, order) // p 的伙伴块地址 if buddy is not free: break remove buddy from freelist[order] p = merge(p, buddy) // 合并成 order+1 大小的块 order++ add p to freelist[order]

这套算法的优势就是分配和释放都是 O(1)级别的操作,并且天然抗外部碎片——因为每次都是拆最大的块,释放时尽量合并成更大的连续区域。代价是内部碎片:比如请求 3 页,实际会给 4 页(order 2),浪费 1 页。

3.2 每 CPU 页列表与 PCP:一个容易忽略的性能细节

伙伴系统的全局链表是有锁保护的,每次分配都要拿 zone 的锁。如果所有 CPU 抢同一把锁,性能会惨不忍睹。内核于是引入PCP(Per-CPU Pages)机制:每个 CPU 维护一个小型页缓存池,分配 order 0(或 1、2)的小页面时优先从本 CPU 的 PCP 里拿,释放时也先放回 PCP。只有 PCP 空了或满了,才和全局伙伴系统打交道。

这个机制对数据库、缓存类应用影响很大。我记得有次压测,把vm.zone_reclaim_mode调了一下,外加调整 PCP 的 batch 大小,吞吐就上去了几个百分点。调优时可以关注/proc/sys/vm/per_cpu_pages相关参数,不过现在内核版本一般不需要手动干预,默认值已经比较合理。

3.3 碎片化:order 越高越难分配

伙伴系统看着很美好,实际运行中会遇到一个经典问题——内存碎片化。随机分配、释放各种大小不一的页面后,系统可能空闲页总数很多,但都是东一块西一块,凑不出连续的 order 3、order 4 块。内核日志里出现page allocation failure,往往就是这种场景。

为了缓解碎片,内核做了几件事:

  1. ZONE_MOVABLE:把可移动页(用户态数据页)与不可移动页(内核分配的页)分开,后者集中在一个区域,方便迁移腾出连续空间。
  2. 页面迁移(migration)与规整(compaction):/proc/sys/vm/compact_memory可以触发手动规整,把碎片化页面聚合到一起。
  3. 分配 flag 中的__GFP_RECLAIMABLE和__GFP_MOVABLE:让伙伴系统知道这类页能否迁移,从而在偷页(fallback)时更聪明。

我遇到过一个很典型的嵌入式场景:内核启动后分配了大量 order 4(64KB)的 DMA 缓冲,运行久了之后低 order 的小块不断分配释放,把小碎片留在低端内存,导致后面驱动加载时怎么都分配不出连续 64KB。当时最直接的临时方案是加memmap=nn[KMG]预留区域,长期方案则是检查哪些内核路径在频繁分配不可移动页,尽量改成 CMA 或大页池。

3.4 CI 与 CMA:连续内存分配器的补充

另一个和伙伴系统密切相关的机制是CMA(Contiguous Memory Allocator)。它在系统启动时预留一块区域,平时这部分内存可以被伙伴系统当作普通可移动页面使用;一旦某驱动需要连续大块的 DMA 缓冲区,CMA 会通过页面迁移把这块区域里的可移动页请走,腾出连续的物理块。

我自己的经验是:如果硬件需要连续内存,优先考虑 CMA 而不是直接预留。直接预留浪费内存,CMA 能做到“按需占用、平时共享”,代价是页面迁移会引入短暂延迟。设备树或内核参数里配置cma=256M时,注意预留大小和系统总内存的比例,通常在 5%-10% 左右,设太大反而会影响普通内存的可用量。

4. 块分配器:slab、slub 与对象缓存

伙伴系统管理的最小单位是页,但对内核自身来说,驱动、文件系统、网络协议栈经常需要分配大量的“小对象”——比如一个struct file、一个struct inode、一个 socket 缓冲。要是每次都通过伙伴系统分配一整页,那内部碎片会让人崩溃。所以内核在伙伴系统之上盖了一层块分配器(Slab Allocator)。

历史上出现过三种实现:SLAB、SLOB、SLUB。目前主流内核默认是SLUB。SLOB 是极小内存嵌入式用的,基本可以忽略;SLAB 是老牌实现,代码老化但仍有大量文档参考价值。现在的发行版内核都把CONFIG_SLUB作为默认选项,性能好、调试方便,所以下面我主要以 SLUB 为主线聊。

4.1 slab 概念与对象池思想

slab 分配器的思想非常直白:与其每次从伙伴系统分配一页然后切成一个个对象,不如提前分配若干页,切好固定大小的对象,放在一个池子里。分配对象时直接从池子里拿一个,释放时放回去。这就是“对象缓存(object cache)”。

从/proc/slabinfo你可以看到系统里所有缓存的情况:

# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> ... kmalloc-512 1234 2048 512 8 1 inode_cache 5678 6000 640 12 2 dentry 12987 15000 192 21 1

解释一下各列——active_objs当前正在使用的对象数,num_objs该缓存池里总共创建的对象数,objsize每个对象的大小,objperslab每 slab 页块里能放几个对象,pagesperslab每个 slab 占几页。如果active_objs长时间接近num_objs,说明该缓存一直在满负荷运转,可能需要调大缓存上限或排查泄漏。

内核代码里创建缓存一行搞定:

static struct kmem_cache *my_cachep; my_cachep = kmem_cache_create("my_object_cache", sizeof(struct my_object), 0, // 对齐,0 表示默认 SLAB_HWCACHE_ALIGN | SLAB_PANIC, NULL);

分配和释放对象:

struct my_object *obj = kmem_cache_alloc(my_cachep, GFP_KERNEL); // 使用 obj ... kmem_cache_free(my_cachep, obj);

这种模式比直接kmalloc好在哪?一是分配同样大小的对象零元数据开销,二是对象复用率高时 cache 命中率好,三是可以kmem_cache_shrink或kmem_cache_reap在内存紧张时回收空闲对象。

4.2 SLUB 的运作机制

SLUB 和 SLAB 最大的差异在于元数据放哪、如何管理空闲对象。

SLAB 每个 slab 有一个管理结构,记录空闲对象链表,并且不同 slab 分为 partial(部分使用)和 full(全满)两个状态,维护相对复杂。SLUB 则简化了很多:

  • 每个缓存维护一个kmem_cache_cpu结构,记录本 CPU 当前正在使用的 freelist 和所属 slab。
  • CPU 分配对象时,直接从当前 freelist 取第一个。
  • freelist 空了,就从 partial 列表拉一个新的 slab 过来。
  • partial 列表也空了,就去伙伴系统alloc_pages申请新页块作为 slab。
  • 释放对象时,优先把对象还给当前 CPU 的 freelist;如果释放的 slab 从 full 变成 partial,就塞回 partial 列表。

这种设计的最大优势是分配路径极短:热点路径上几乎不需要加锁,只要操作 per-cpu 的 freelist 指针就行,大部分情况一次原子操作就完事。你可以在perf top里看到,普通系统上kmem_cache_alloc通常只占很小的 CPU 比例,就是这个原因。

SLUB 还引入了node 间 partial 列表,配合 NUMA 支持。节点本地内存不足时,可以跨节点转移 slab。默认情况下,SLUB 会把对象优先分配在当前节点,避免跨 NUMA 访问造成的高延迟。

这里有个实用的调优点:/sys/kernel/slab/<cache_name>/目录下有大量可调节参数,比如cpu_partial、min_partial、order。如果是高频分配释放的内核对象,可以增大cpu_partial,让更多空闲对象留在当前 CPU 的缓存里,减少锁竞争。

4.3 kmalloc 与 kmem_cache 的关系

很多人以为kmalloc是直接从伙伴系统拿页的,其实不对。kmalloc也是走 slab 分配器,只是它用的是内核预先建好的一组通用缓存(kmalloc-8, kmalloc-16, ..., kmalloc-8192, kmalloc-64k 等)。

在/proc/slabinfo里看到的kmalloc-*系列,就是按 2 的幂次对齐的通用对象池。比如kmalloc(100, GFP_KERNEL),SLUB 会去找合适大小的 kmalloc 缓存(通常是 kmalloc-128,因为要 8 字节或 16 字节对齐),从那个池子里摘一个对象给你。所以请求 100 字节实际占用 128 字节,这 28 字节就是内部碎片。

什么时候用 kmalloc、什么时候自定义 kmem_cache?我的判断标准是:分配的频率足够高,而且对象大小比较固定(比如驱动里的请求描述符、消息队列节点),就值得为它建一个专属缓存;如果只是偶尔分配一次,或者大小变化很大,直接用 kmalloc 就好。自定义缓存还能顺手通过 slabinfo 监控对象数量,调试内存泄漏特别方便。

4.4 从 slabinfo 定位可疑内存增长

线上排查内存问题,我最先做的事之一就是cat /proc/slabinfo和slabtop。slabtop交互界面能直接按内存占用大小排序列出缓存,一眼看到哪个缓存占了几个 GB。

有次排查某网卡驱动,我发现kmalloc-4096占用异常高,对应到代码里就是驱动给每个队列分配的 DMA 描述符数组。进一步追踪发现,队列数量在某新固件下翻倍了,描述符的dma_map_single申请太频繁且没及时释放,最终问题浮出水面。这比盲目猜测“用户态泄漏”高效得多。

# 查看当前 slab 缓存占用 top slabtop -o | head -20 # 检查某个缓存详细状态(以 dentry 为例) cat /sys/kernel/slab/dentry/objects cat /sys/kernel/slab/dentry/objs_per_slab cat /sys/kernel/slab/dentry/order

如果怀疑内核对象泄漏,可以配合监控脚本定时打点/proc/slabinfo,对比不同时间点对象数量的增长曲线。对象数量只增不减,基本可以确定有路径没做kmem_cache_free。

5. 三者的协同与调优实践

5.1 分配路径全景图:一个 malloc 引发的连锁反应

现在我们把三条主线拼起来,看一次典型的内存分配请求会经历什么。

假设一个用户态程序调用malloc(4096)然后写入数据:

  1. malloc可能走brk或mmap扩展进程的堆 / 映射段,这只是修改进程的 VMA,没有真正分配内存。
  2. 程序第一次写入某个虚拟页时,CPU 触发缺页异常。
  3. 内核进入缺页处理,调用handle_mm_fault→do_anonymous_page。
  4. 内核需要分配一个物理页,最终到达伙伴系统,请求 order 0 页面。
  5. 伙伴系统优先查当前 CPU 的 PCP,有就直接返回;没有则从 zone 的 freelist[0] 拿;再没有就往高一阶拆分。
  6. 拿到物理页后,内核填 PTE,建立虚拟地址到物理页的映射,返回用户态。

如果这次分配发生在内核态,比如驱动要创建一个对象,路径就变成kmem_cache_alloc→ SLUB 的 per-cpu freelist → partial slab → 伙伴系统alloc_pages。伙伴系统始终是最后的“物理内存提供者”,slab 只是上面的一层缓存。

虚拟空间、伙伴系统、slab 三者的关系可以用一句话概括:虚拟地址空间回答“进程能看到什么”,伙伴系统回答“物理页怎么组织”,块分配器回答“小对象怎么复用”。它们各自解决问题的粒度不同,但最终都要落到物理页的管理上。

5.2 与内存回收(reclaim)的接口

内存紧张时,内核不会干等着。伙伴系统和 slab 都要配合内存回收机制:

  • 用户态文件页是首选回收对象:干净页直接丢弃,脏页写回磁盘后丢弃。
  • 匿名页如果配置了 swap,可以被换出。
  • slab 对象也可以通过kmem_cache_reap、shrink_slab回收可回收部分。

/proc/sys/vm/swappiness控制匿名页与文件页回收的倾向,默认 60 在服务器上普遍偏激进。我自己跑数据库类负载时建议调到 10 左右,让内核更倾向于保留匿名页;但对缓存类服务,swappiness 不是越大越好,实际要看 I/O 压力。

如果系统使用了cgroup v2,还要注意 memory.max 的限制,否则 cgroup 里内存达到上限时内核会强制回收,可能引发明显的性能抖动。

5.3 实操:一次碎片化问题的完整排查与缓解

回到文章开头那个模拟项目X的故障,我把排查过程完整还原一下,你可以直接按这个思路来。

现象:

  • 每小时执行的批量任务偶尔失败,dmesg 出现page allocation failure: order:4, mode:0xcc0(GFP_KERNEL), cpuset=/
  • 系统空闲内存(MemFree)仍有 20GB+,但cat /proc/buddyinfo显示 order 4 及以上的空闲块很少

排查步骤:

# 查看各 order 空闲页情况 cat /proc/buddyinfo # 查看碎片指数(碎片化程度) cat /sys/kernel/debug/extfrag/extfrag_index # 查看规整(compaction)统计 cat /proc/vmstat | grep compact

buddyinfo输出类似:

Node 0, zone Normal 12 23 45 67 12 8 0 0 0 0 0

最后几列都是 0,说明 order 6 以上几乎没有连续块。但有趣的是系统还比较空闲,因为碎片化的页都被不可移动的内核对象占据,无法聚合。

触发一下内存规整:

echo 1 > /proc/sys/vm/compact_memory

然后又观察buddyinfo,order 6-7 的块恢复了几个。这验证了问题是碎片化导致的。但临时触发只能缓解一次,根本原因还要找谁在频繁分配不可移动的高阶页。用 tracepoint 抓一下:

perf record -e kmem:mm_page_alloc -e kmem:mm_page_free -a -- sleep 10 perf script | grep "order=4" | head

从栈回溯里发现是某驱动在probe时申请连续 64KB 作为 DMA ring 缓冲区。驱动每 5 分钟重启一次,每次申请一次 64KB、用一段时间释放,但由于释放时伙伴系统无法合并(旁边有其他不可移动页),空隙越来越大。

缓解方案按优先级:

  1. 驱动改用 CMA 区域申请连续内存(需要改代码,但根治)。
  2. 驱动把连续缓冲池化,启动时一次申请、运行中复用(改动后效果显著)。
  3. 系统层面临时调大min_free_kbytes,让内核保留更多紧急预留页,至少不至于直接分配失败。
sysctl vm.min_free_kbytes=524288 # 例:保留 512MB 紧急页

不过min_free_kbytes调太大也会降低可用内存水位,可能提前触发回收,得权衡。

整个排查下来,我的体会是:看到page allocation failure,不要急着怀疑内存泄漏,先看 buddyinfo 的 order 分布。如果总数多但高阶块少,优先查碎片和不可移动页;如果是 object 数量暴涨,再走 slabinfo 方向。

5.4 大页(HugePage / THP)与伙伴系统的关系

大页也是内存管理里绕不开的话题。标准页是 4KB,HugePage 有 2MB、1GB 等规格。内核里分配 HugePage 并不会绕过伙伴系统——它同样通过alloc_pages请求 order 9(2MB)或 order 18(1GB)。只不过内核专门维护了hugetlb池,池里的页从伙伴系统一次性“搬”出来之后就固定归属大页池,不再回到普通伙伴系统。

/proc/meminfo里的HugePages_Total、HugePages_Free能看到池子状态。配置方式:

echo 64 > /proc/sys/vm/nr_hugepages

或者用sysctl vm.nr_hugepages=64。注意,分配大页需要伙伴系统能凑出连续的 2MB 块,系统刚启动时最容易成功,运行久了之后想动态调大 nr_hugepages 经常失败,表现为写入 sysctl 后实际值不变——这就是碎片化在作祟。

THP(Transparent Huge Page)则是用户态进程透明地受益于 2MB 映射,不需要显式配置。但它也有坑:THP 的 khugepaged 线程会尝试把连续的小页合并成大页,过程中可能引入额外的 CPU 开销和延迟。对于延迟敏感的服务,我一般建议关闭 THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled),改用显式 HugePage 或直接让应用用madvise控制。

关于大页,最经典的一句话:TLB 覆盖范围翻倍,等于地址翻译压力减半。对内存密集型的数据库、搜索引擎和科学计算,收益非常明显。

5.5 参数速查与常用监控命令

为了方便日常排查,我把最常用的一组命令整理了一下:

目的命令输出含义
查看内存整体free -h、cat /proc/meminfoMemFree、SwapCached、Slab、HugePages 等
查看伙伴系统空闲块分布cat /proc/buddyinfo各 node、zone 的各 order 空闲页数
查看 slab 对象占用cat /proc/slabinfo、slabtop各缓存活跃对象数、总内存
查看页表与缺页统计cat /proc/vmstatpgfault、pgmajfault、compact_stall
触发内存规整echo 1 > /proc/sys/vm/compact_memory主动合并碎片
动态配置大页sysctl vm.nr_hugepages=64设置大页池
观察分配事件perf record -e kmem:mm_page_alloc抓取页面分配栈

还有两个内核 debug 选项值得开启:CONFIG_DEBUG_KMEMLEAK和CONFIG_SLUB_DEBUG。前者可以发现内核对象泄漏,后者可以在分配/释放时做完整性检查。生产环境不建议长期开,但排查问题时有奇效。

6. 常见问题与避坑实录

6.1 “为什么 free 显示内存还有很多,却 OOM?”

这是最高频的困惑。free显示 available 很大,但应用突然被杀。可能原因:

  • cgroup 内存限制(比如容器 limit 低于宿主可用内存)
  • 进程RLIMIT_AS或RLIMIT_DATA限制虚拟内存
  • 伙伴系统碎片化导致无法满足高阶分配
  • NUMA 节点内存不均,某个节点的内存耗尽而其他节点还有空余

排查时先看 dmesg 里的 OOM 日志,里面会列出被选中的进程、每个进程的内存占用、当时的 zone 状态。注意 OOM killer 选进程不只按内存大小,还有 oom_score_adj,容器场景经常因为oom_score_adj偏高被误杀。

6.2 SLUB 的 debug 开关导致性能大幅下降

有次我为了排查一个问题,在 kernel cmdline 加了slub_debug=P(对全部缓存开启 poison 检查),结果系统吞吐直接掉了 30%。原因很简单:SLUB debug 打开后,每次分配/释放都要做额外检查,还取消了 per-cpu 的快速路径复用。

正确做法是只针对可疑缓存开启,比如slub_debug=P,my_object_cache,或者先echo 1 > /sys/kernel/slab/my_object_cache/poison动态开启。全量开启仅适合非常小的测试环境。

6.3 vmalloc 与 kmalloc 用错导致的线上问题

很多人写内核模块分不清kmalloc和vmalloc,觉得“都是分配内存,无所谓”。实际区别很大:

  • kmalloc返回的是物理连续内存,地址落在内核线性映射区,访问快,适合小对象和 DMA(配合 dma_map 需要)。
  • vmalloc返回的是虚拟连续、物理不一定连续的内存,地址在 vmalloc 区域,访问可能缺页,效率低,且不能在原子上下文用。

驱动里如果设备需要 DMA,必须用kmalloc或 CMA/大页;如果只是存放一段临时数据,vmalloc没问题。但很多驱动为了保险直接vmalloc,一旦目标设备做 DMA 就会出问题——表现为随机数据损坏,极难排查。

6.4 内存回收导致的性能毛刺

服务器负载不高,但延时曲线每隔几分钟出现毛刺。常见原因之一就是kswapd/direct reclaim在回收内存,尤其是当min_free_kbytes太小、内存水位频繁触发时。

可以观察/proc/vmstat里的pgscan_direct、pgsteal_*,以及/proc/pressure/memory的 PSI(Pressure Stall Information)。如果some或full的数值高,说明内存压力已经在拖慢任务了。这时候要么调整 swappiness,要么加大内存,要么看看是不是有内核对象被错误地大量缓存在 slab 里,白占内存。

6.5 回顾:这条主线为什么值得吃透

走到这里,虚拟地址空间、伙伴系统、块分配器三条线已经完整接起来了。它们分别回答了三个问题:进程怎么看待内存、物理页怎么组织与分配、内核小对象怎么高效复用。期间我们聊了页表与 TLB、buddyinfo、order 拆分合并、碎片化与规整、SLUB 热路径、slabinfo 监控、HugePage 与 CMA,还过了一遍实际问题的排查过程。

我个人这几年实践下来最大的感受是:内存管理不是一个个孤立的知识点,而是一条完整的链路。遇到内存问题,先定位它在链路的哪一环:用户态虚拟空间?物理页组织?还是 slab 对象层?定位准确了,解决方案基本就浮现出来了。没有万能的参数,也没有万能的命令,真正值钱的是对整个机制的熟悉程度,以及动手去perf、slabtop、buddyinfo里找证据的能力。

最后再分享一个小技巧:遇到诡异的内存问题,第一时间先开slabinfo和buddyinfo快照,再复现问题,然后对比两次快照。哪个 order 空了、哪个缓存对象数量暴涨,一目了然。这个习惯帮我省了不知道多少小时的抓头发时间。希望这篇文章能帮你在下一次内存故障面前,少踩几个坑、多一分从容。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进