资讯动态

Linux内核内存管理三支柱:虚拟地址空间、伙伴系统与SLUB分配器

发布时间:2026/10/21 21:22:15 来源:尧图企业网站定制
你有没有遇到过这样的场景一台 Linux 服务器跑得好好的进程内存占用也不算夸张free命令看过去还有几个 GB 空闲但内核日志里却冷不丁冒出一条 “page allocation failure某个模块要分配几十 MB 内存失败了。更别说我们还经常在社区里看到kmalloc分配失败、order-4分配不出来的报错。这些问题背后藏着 Linux 内核内存管理的三根柱子虚拟地址空间、伙伴系统Buddy System和块分配器Slab/SLUB。搞懂这三层你再看内存相关的告警和内核日志会清楚很多。这篇文章顺着我自己踩过坑的经历把这三层机制串起来讲一遍。内容不追求教科书式面面俱到也不会给你抄一堆源码片段让你睡着。重点放在“为什么这么设计”和“实际出问题时怎么查”上适合正在做内核开发、性能排查或者对底层内存机制一直似懂非懂的读者。看完你可能没法手写一个伙伴系统但至少能看懂/proc/buddyinfo和/proc/slabinfo里那些数字在说什么也能在下次内存报错时快速定位方向。1. 虚拟地址空间先把“内存地图”看清楚1.1 为什么进程必须活在虚拟地址里在理解内存管理之前必须先把“虚拟地址空间”这个概念摆正。每个用户态进程一启动就会拿到一个独立的虚拟地址空间它就像给每个进程发了一张“专属地图”。进程在这个地图里写地址、做运算CPU 的 MMUMemory Management Unit再把这套虚拟地址翻译成物理内存地址。换句话说进程自己感觉拥有一整块连续的大内存但实际上它可能是分散在物理内存的各个角落里的 4KB 小页甚至很多地址背后暂时没有分配任何物理页。为什么要绕这么大一圈两个核心原因。第一是隔离和保护A 进程没法直接访问 B 进程的地址操作系统通过页表权限位来关闭这种跨界访问两个进程就算虚拟地址一样映射到的物理页也完全不同。第二是灵活和按需加载进程可以“超额申请”内存真正碰这些地址的时候内核才把物理页补上。这就像你租了一栋楼里的一百个房间但钥匙和打扫都是等你要住的那一刻才安排平时只用交一份租约就行。我在实际项目里见过不少人绕不过弯的地方总把进程 RSS 大小当成它真正“占用”的内存。其实 RSS 只是已经映射并驻留物理内存的部分而虚拟地址空间里还有大量未触碰的堆区、保留区。所以看内存问题第一步就是分清“进程眼里的世界”和“物理内存的实际库存”。1.2 x86-64 下的用户态与内核态地址布局以最常见的 x86-64 架构为例Linux 的虚拟地址空间把 64 位空间切成了两大块。用户空间通常占比是低地址部分一共 128TB内核空间在高地址部分也占 128TB。别以为 2^64 是个天文数字就能随便用目前硬件地址线只做到 48 位甚至 57 位内核能管理的实际虚拟空间远小于理论值但对付日常的几千 GB 物理内存绰绰有余。用户态地址空间常见布局是text 段代码、数据段从低地址开始堆heap向上增长通常由brk管理还有一个专门用于大块分配的mmap区栈向下增长映射区域由内核自动管理还有各种共享库、vdso 等映射区域。内核空间则更讲究它不能像用户态那样随意散落否则地址翻译效率会很低。x86-64 下内核空间有几个典型区域直接映射区也叫线性映射区PAGE_OFFSET起把物理内存直接映射到一段连续的虚拟地址空间里内核访问物理页只需要做一次固定偏移不需要维护复杂页表。这是内核推进许多操作的基础。vmalloc 区域用于内核需要虚拟地址连续但物理地址不必连续的场景比如加载内核模块、分配某些外设缓冲区。因为要单独建立页表映射所以比直接映射慢但胜在灵活性。fixmap 区域和 cpu_entry 区域用于固定的、特殊用途的映射比如早期启动过程中的 map、中断入口栈等。这块在实际排障中的意义是当你看到某个内核地址在内核日志里出现大致就能猜到它是直接映射区的物理内存还是 vmalloc 区的非连续映射。比如fixmap相关的崩溃多半和特定外设中断有关vmalloc相关的 OOM 往往和模块/设备驱动申请空间失败有关这些都是排查时的线索。1.3 页表也是内存四级页表和 TLB 的代价有了虚拟地址和物理地址连接它们的就是页表。x86-64 默认页面大小是 4KBLinux 使用四级页表PGD页全局目录、PUD页上级目录、PMD页中间目录、PTE页表项。每一级页表本身也是存放在内存里的页只是这些页的内容全是地址映射项。所以页表也是一层“用内存来管理内存”的机制一个映射了大量虚拟内存的进程页表本身可能就要占几十 MB 物理内存。页表层级带来的最大麻烦是 TLB块表缓存命中率。CPU 访问内存前必须先把虚拟地址翻译成物理地址如果每次都走四级页表性能立刻就崩了。所以 CPU 有 TLB 缓存最近的地址翻译结果而 TLB 条目数是有限的。这也是为什么大页Huge Page那么有价值2MB 大页只需更少的页表层级1GB 大页甚至可以直接跳过好几层。本世纪初的数据库调优喜欢用 HugeTLB现在透明大页THP能自动处理但 THP 也有自己的坑后面讲演练时再提。这里还要顺手记住一句话页表页也是通过伙伴系统分配的。所以内存管理真正最底层的物理页来源是固定的无论你上面走的是什么路径最终缺物理页时都要找伙伴系统这是一条贯穿整个内核的生命线。2. 伙伴系统物理页框分配的中枢2.1 设计动机既要快又要减少碎片如果说虚拟地址空间解决的是“谁在看内存”的问题那么伙伴系统解决的就是“内存物理箱子里怎么装箱拆箱”的问题。物理内存本质上是连续一长条 4KB 页框组成的数组内核需要给各种模块分配物理页有的要 1 页有的要连续 8 页有的要连续 128 页。如果不做任何组织最简单的办法就是维护一个空闲页链表要多少页就从头摘多少页。但这样会带来严重的外碎片频繁分配和释放后物理内存被割成很多小块虽然总空闲量够但找不到一块足够大的连续区域。比如你留了 4GB 的空闲但每一块都只有 4KB那给一个需要 2MB 连续物理页的 DMA 驱动时照样失败。伙伴系统用一种非常优雅的拆分合并策略解决这个问题。它把所有空闲页按大小分类每类大小是 2 的幂次order。order 0 表示 1 个页框4KBorder 1 表示 2 个页框8KBorder 2 表示 4 个页框16KB依此类推最大一般到 order 101024 页4MB。分配时从最小的足够大的桶里取一块如果这块太大就拆成两个半块一个给请求者另一个放回小一号的桶里备用。释放时反过来如果两个相邻的半块都空闲就合并成大块再往上尝试继续合并。因为相邻半块的地址有天然关系所以叫“伙伴”。这个设计最漂亮的地方在于它用一棵“隐式树”把空闲块组织起来操作效率极高。理论上只需要按 order 遍历空闲链表不需要复杂索引。拆分的代价是分配时会带来小的内碎片比如请求 3 页实际给 4 页并可能频繁触发合并但这些都比外碎片可控得多。2.2 数据结构zone、free_area 与迁移类型伙伴系统不是一个孤立的数组它是按“管理区 zone”来组织的。物理内存按用途划分为ZONE_DMA、ZONE_NORMAL、ZONE_MOVABLE等区域。每个 zone 内部维护自己的空闲页块数组结构是free_area[order]。不同区域服务于不同的地址约束需求比如 DMA 设备只能访问低端内存所以需要单独的ZONE_DMA。每个 zone 还要维护一套水印watermarkmin、low、high。这三个值是内存压力下拦截不同路径的关键空闲页高于high一切正常低于low内核唤醒kswapd后台回收线程异步回收页面低于min普通分配直接失败只有带ALLOC_HARDER标志的分配如高级中断上下文才有机会继续。我把这三个水印看成“红绿灯”。实际调优时有个常见误区为了应对突发内存压力把min_free_kbytes调得很大导致内核过早进入回收状态反而让系统频繁卡顿。合理设置需要结合机器物理内存和业务特征不能拍脑袋。另一个关键机制是迁移类型migratetype它把空闲页按是否可以迁移分成MIGRATE_UNMOVABLE、MIGRATE_RECLAIMABLE、MIGRATE_MOVABLE等若干组。为什么要分迁移类型这得从碎片化说起。如果不可移动的页面比如内核数据散落在整个地址空间里即使周围有大量空闲页也很难通过合并产出大块连续内存。把可移动页面比如用户态页和不可移动页面分开管理之后可以通过内存规整compaction把可移动页集中挪走腾出连续大块区域。你去看/proc/pagetypeinfo时就能看到这个矩阵。2.3 分配与释放路径以及一个容易忽略的细节常规内存分配的入口可以简化为alloc_pages - __alloc_pages - get_page_from_freelist - rmqueue其中rmqueue是做实际摘块操作的。为了减少锁竞争每个 CPU 还有一个“每 CPU 页框缓存PCP”它缓存少量 order 0 的空闲页。分配单页时优先从 PCP 取只有 PCP 不够才走 zone 级别的 free_area 链表。这个设计背后是对锁竞争和缓存的极致追求内核里绝大多数物理页分配都是单页如果每次都要抢 zone 锁多核扩展性会差得没法看。关于释放路径有两个我经常要给别人强调的细节。第一释放页块时的合并条件是“相邻两个块都空闲、并且 order 相同”还需要确认伙伴页的标志位PageBuddy存在。所以释放操作在拿到 buddy 页的地址后还要检查它是不是真的在空闲链表中。第二释放操作要小心与分配并发Linux 通过 zone - lock 保证安全但这也意味着频繁单页释放会竞争锁所以才需要 PCP 承接大量释放操作。这里藏着一个很实际的问题如果你在中断上下文里调用alloc_pages必须选择合适的 GFP 标志比如GFP_ATOMIC否则可能直接触发睡眠和等待。我之前帮人排查一个网卡驱动崩溃就是因为在收到中断包时用了带__GFP_WAIT的分配结果自旋锁上下文直接报出 “BUG: sleeping function called from invalid context”。这类问题排查起来很依赖对分配路径的理解。3. 块分配器SLUB/Slab小对象的高效工厂3.1 为什么伙伴系统搞不定小对象伙伴系统管理的最小单位是一个 4KB 页框这粒度对内核绝大多数对象来说都太大了。进程控制块task_struct不到 4KB但你要是每个进程都直接占一个物理页那 4GB 内存只够跑一百万个进程显然不行。更要命的是内核会频繁创建和销毁这些小对象比如连接被 accept 后创建 socket 结构完成后立刻释放文件打开时创建 inode、dentry关闭后释放。如果每次创建对象都走伙伴系统开销会巨大既浪费物理页又要频繁清零和复制页。于是 Linux 在伙伴系统之上加了一个“块分配器”历史上叫 Slab Allocator现在默认实现是 SLUB。它的核心思路是为每种对象类型建立一个专门的缓存池cache。每个 cache 由若干个 slab 组成一个 slab 就是一页或几页连续物理内存被划分成很多大小相等的 object对象槽。分配对象时从缓存池里拿一个空闲 object释放时把 object 放回池里完全绕过伙伴系统除非整个 slab 层面需要扩充或收缩。这很像你在批发市场伙伴系统和小区门口的便利店slab cache的关系。日常买瓶水去便利店拿就好只有便利店库存大幅波动时才去批发市场进一大箱货回来。3.2 SLUB 的核心结构cache、slab、object 与 freelistSLUB 的关键数据结构是struct kmem_cache。每个 cache 会管理一组 slab这些 slab 分布在内存节点node上每个 node 维护 partial slab 链表slab 里有一些对象已分配还有一些空闲和 full slab 链表。此外还有更细的 per-CPU 结构struct kmem_cache_cpu它缓存了当前 CPU 上最热的 freelist分配对象时优先从这里面取速度极快。SLUB 的设计比传统 SLAB 更简洁。传统 Slab 分配器会为每个 slab 单独分配一个 metadata 结构描述空闲对象SLUB 则把空闲对象通过一个内置的 freelist 串起来第一个字节就存着下一个空闲对象的地址或者特殊标志。它去掉了复杂处理器缓存着色逻辑把灵活性留给了调试和调优参数。默认内核用 SLUB原因就是结构更干净、性能更好。这里我要展开讲一个看似不起眼但非常重要的机制slab 中的对象释放后并不会立即归还给伙伴系统。每个 CPU 维护一个 partial 对象列表释放对象时只是把它塞进这个列表。只有当这个 CPU 的 partial 列表太长或者整个 node 的 partial slab 太多时才批量归还 slab 给伙伴系统。这个设计本质上是在“分配速度和内存占用”间做权衡。如果每次释放都立刻退还物理页那一个短暂峰值请求会反复折腾伙伴系统性能会很差。但这也导致一个现象系统里大量内核对象处于空闲缓存状态但 RSS 统计里看不到它们占用的物理页看/proc/slabinfo才能发现。3.3 两个主要入口kmalloc 与 kmem_cache_create使用 slab 层最常用的接口有两个kmalloc和kmem_cache_alloc。kmalloc走的是通用的尺寸缓存内核预置了一组kmalloc-8、kmalloc-16、kmalloc-32一直到kmalloc-8192等通用缓存。你请求分配 200 字节就走kmalloc-256这个 cache 拿一个 256 字节的对象。这样的好处是不同模块用同尺寸对象时可以共享缓存减少内存碎片。代价是分配一个 200 字节对象实际占了 256 字节多出来的 56 字节就是内碎片。如果你要频繁分配一块特定结构体并且希望带上构造函数那就应该创建专用 cache。内核模块里常见做法是struct my_conn { u32 state; u64 last_active; }; static struct kmem_cache *conn_cachep; static int __init module_init_fn(void) { conn_cachep kmem_cache_create(my_conn_cache, sizeof(struct my_conn), 0, 0, NULL); if (!conn_cachep) return -ENOMEM; return 0; } static struct my_conn *alloc_conn(void) { struct my_conn *c; c kmem_cache_alloc(conn_cachep, GFP_KERNEL); if (!c) return NULL; memset(c, 0, sizeof(*c)); return c; }注意这里我用了memset清零而不是依赖构造函数。kmem_cache_create的第五个参数可以传ctor构造函数它会在对象首次被创建进 slab 时调用但之后对象被反复分配复用不会再调用。所以很多模块不依赖构造函数而是每次分配后自己初始化。这个区别在调试对象状态泄漏时特别重要——你以为重新分配出来的是“干净”对象实际上里面可能残留上一个使用者的残留数据。kmem_cache_free释放时非常简单它只是把对象放回 freelist。不需要清零不需要调用析构函数。所以 slab 层本质上是一个“半初始化的对象池”对象从分配出去到释放回来经历了半复用式循环这在性能上是极致的节约。3.4 调试与调优slub_debug 和 sysfs 接口SLUB 的调试能力是一大亮点。内核编译时打开CONFIG_SLUB_DEBUG后可以给kmem_cache打上调试标志包括redzone在对象边界加红色区域标记溢出时会报错poison在释放对象时填上固定字节 0x6b分配前清成 0x0帮助定位 use-after-free 和 uninitializedtrack记录最近的 alloc/free 调用栈。通过内核启动参数slub_debugP清 poison、slub_debugUtrack 用户态调用栈、slub_debugT等组合可以精确追踪问题对象。我在调一个反复 crash 的内核模块时就是打开slub_debugFZPU包括从 trace 匹配 call chain找到某个 cache 里被越界写坏的真实调用点。这个流程在普通文档里很少被展开讲遇到内存损坏类 bug 直接提供核心方法。调优方面sysfs 里/sys/kernel/slab/cache-name/下可以实时查看objects、slabs、cpu_partial、order等参数。如果你想提高一个 hot cache 的批量分配效率可以调cpu_partial让每个 CPU 多缓存一些 partial 对象。但注意不要无脑调大否则会产生大量不可回收的“假占用”内存。4. 一条分配链路走到底三者如何协作4.1 用户态 malloc 到物理页的完整旅程把三个机制放在一起看内存分配这件事就很立体了。先看用户态场景。你写了个 C 程序malloc(1024 * 1024 * 50)glibc 根据请求大小决定用堆扩展brk还是匿名映射mmap。请求 50MB通常会走mmap创建一块新的虚拟地址区间VMA。注意这一步只建立了“虚拟内存地图”还没有分配任何物理页。接下来程序第一次往这块内存里写数据比如memsetCPU 遇到缺页异常page fault内核进入do_page_fault。它会先查 VMA 是否合法、权限是否匹配然后走匿名页缺页路径调用伙伴系统分配一个 order 0 物理页建立 PTE 映射返回用户态。之后程序对这块地址的读写才真正打到物理内存上。这也是为什么你会看到“内存申请成功但 RSS 没涨”的现象——实际上只是虚拟地址空间扩大了物理页是等你触碰那一刻才逐个补上的。内核术语叫“按需换页”demand paging。在排查 OOM 的时候别只统计进程虚存大更要看进程真正触过了多少页面。4.2 内核对象从 slab 到伙伴系统的归还路径再看内核对象以网络收包为例。网卡把数据包 DMA 到内存内核为每个包创建sk_buff结构分配时走kmem_cache_alloc(skbuff_head_cache)。如果这个 cache 里没有足够空闲对象SLUB 就会向伙伴系统申请新页把它做成新的 slab划成几个 sk_buff 对象供使用。数据包处理完skb_free把对象还给 skbuff_head_cache 的 freelist并不会立即触发物理页归还。这套分层协作的价值在于“热路径极快”。收包、协议处理、释放这些操作每秒可能发生几十万次如果每次都从伙伴系统拿页、建页表、清零页CPU 直接就卡成瓶颈了。slab 层把高频小对象分配从低频物理页分配中彻底解耦伙伴系统只在 slab 扩容和收缩时才被打扰。这就是为什么一个连接密集型的服务内存看起来始终偏高大量 net、sock 相关对象停留在 caches 里等待复用真正空闲的物理页反而很少。我理解的整个链条是虚拟地址空间解决“映射关系”伙伴系统解决“物理页框的装箱方案”SLUB 在中间增加了一层面向对象的缓存层。三级各司其职缺一个性能都会出问题。4.3 内存压缩与迁移如何参与协作前面提到迁移类型时说过MIGRATE_MOVABLE页面。现代内核在内存紧张时还会做内存规整compaction把可移动页面迁移到内存另一端的空闲区腾出一整块连续物理内存用来满足高阶order 大于 0分配请求。这个过程中用户态匿名页和 page cache 页都会被移动但内核数据页不可移动。这带来一个有意思的权衡如果系统中大多数可回收内存是 page cache内核可能一边回收 page cache一边做规整如果很多页面是匿名页那规整就会非常吃力。某些大页THP分配失败时系统会尝试从 buddy 的高 order 区域直接分配如果失败可能会触发内存压缩和直接回收direct reclaim。这也是为什么 THP 在现代内核里通常要求配合开启khugepaged以及合理设置水位的原因。实际排查场景里如果dmesg出现 “page allocation failure: order:4, mode:0xcc0”同时/proc/buddyinfo显示 order4 以上空闲页几乎为零那问题的本质通常是物理碎片化而不是真内存不足。此时主线是触发 compaction 或者调整水印而不是盲目加物理内存。5. 实际场景中的观察手段与问题排查实录5.1 先学会看三张“体检表”如果你经常处理内存问题这三个/proc文件一定要能看懂。/proc/buddyinfo每行代表一个 zone列数对应 order 0 到 order 10数字表示当前空闲的对应 order 页块数量。例如Node 0, zone Normal 12815 5345 2345 890 123 32 8 4 1 0 1这表示 Normal 区里 order0 空闲页块 12815 块约 50MBorder1 空闲 5345 块约 40MBorder3 只有 890 块约 13MB。如果你要从 buddy 里直接分配 order 532 页 128KB需要看 order5 及更高 order 的剩余。如果高阶数字全是 0 或非常小说明大块连续内存紧张。/proc/pagetypeinfo比 buddyinfo 更细它展示了每个 zone、每个迁移类型下各 order 的页块数。这个表能帮你判断碎片是集中在不可移动页还是可移动页。如果Unmovable列大量占据内存片段说明可能有驱动或内核模块在到处散布小对象规整起来会很费劲。/proc/slabinfo则是 slab 层的账本。每行是一个 cache前几列依次是对象总数、活跃对象数、每个对象大小、每个 slab 的页数等。例如skbuff_head_cache 10240 9834 256 128 16 4 : tunables ...如果你想确认一个“内存泄漏”是发生在内核对象层可以连续抓几组/proc/slabinfo对比某个 cache 的活跃对象数是否只增不减。加上 tracepoint 或slub_debug就能定位到调用栈。5.2 三个典型问题碎片、slab 泄漏与慢速分配第一个典型问题大块连续内存分配失败。症状是应用或驱动报类似 “Cannot allocate memory” 但系统整体内存并未耗尽。排查先从dmesg看分配参数再对照/proc/buddyinfo的高阶空闲情况确认是碎片问题。处理手段通常是把相关业务改成使用大页或者定期执行echo 1 /proc/sys/vm/compact_memory这会触发一次全系统内存规整把可移动页聚拢释放大块连续内存。注意它不是银弹如果不可移动页太多效果有限。生产环境我更建议配合水印设置和 THP 策略来兜底。第二个典型问题slab 缓存异常增长。常见于某个内核模块在特定路径上没有正确释放对象。排查方法是先定位到增长最快的 cache看它的活跃对象数和内存占用。如果怀疑是通用kmalloc泄漏可以用slub_debugU,object_only等参数配合 tracepoint 抓分配点。实际案例里一些网络驱动在 CPU 离线或队列重置时会累积sk_buff_head_cache通过工具确认后加一个释放逻辑就解决了。这类问题表面上是内存占用高实则是资源生命周期管理错误。第三个典型问题频繁的直接回收direct reclaim导致卡顿。当分配路径发现水位低于 min它会放弃后台回收直接在进程上下文里强制扫 page cache、压缩内存。这会让业务进程陷入长时间内核态。可以用perf跟踪mm_vmscan_direct_reclaim事件数量如果数量很高多半是min_free_kbytes设置不合适或系统长期处于低水位运行。我调整过一台内存 64GB 的虚拟机把vm.min_free_kbytes从默认 8MB 提到 64MB约物理内存的 0.1%并配合vm.watermark_scale_factordirect reclaim 的发生频率立刻降下来。5.3 值得收藏的调优参数与注意事项内存调优没有万能公式系统性地处理问题是核心。几个我常用到的参数vm.min_free_kbytes保留给紧急分配的空闲内存最低值。设置过高会导致系统提前进入回收过低则可能导致高阶分配失败。经验上取物理内存的 0.1% 到 1% 左右需结合压力场景实测。vm.watermark_scale_factor控制水印之间的跨度影响 kswapd 的激进程度。值越大越早开始后台回收。vm.vfs_cache_pressure控制 dentry 和 inode 缓存回收倾向。值越高越积极回收设为 0 表示不主动回收容易积累不可回收内存不推荐。vm.compact_unevictable_allowed控制不可回收页面在规整时是否允许移动判断需要默认值不能盲目打开。vm.zone_reclaim_mode控制 NUMA 节点内回收行为多路服务器上尤其要谨慎。调参有一个原则一次只改一个参数改完观察至少一个业务周期的内存分配失败率和direct reclaim计数最后形成基线。不要同时把水位、vfs_cache_pressure、THP 策略全改一遍否则出了新问题你根本不知道是谁干的。调试 slab 层也可以临时开启统计echo 1 /sys/kernel/slab/cache-name/trace这会开启该缓存分配的 tracepoint配合perf record -e kmem:kmalloc,kmem:kmem_cache_alloc抓取调用关系比直接在代码里打日志高效得多。结语这三层机制像一台精密机器的三个传动轴虚拟地址空间决定了进程和内核各自看到的地图伙伴系统管物理页框的打包拆包SLUB 则在前二者之间插入了一层面向对象的快速通道。日常排查内存问题我最深刻的体会是不要被“内存还很多”的表象骗了多看看 buddyinfo 的高阶页块、slabinfo 的缓存积压和 watermark 的状态三者结合才能判断系统到底是真缺内存还是只是碎片、缓存策略或水位配置出了问题。如果你也是第一次系统性接触这些建议先从/proc的几个文件开始逛看着看着就容易把它们和源码里的分配路径挂上钩。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑