去年帮客户排查一台机器的内存问题,free 一看 available 只剩 15%,但 top 里所有进程的 RSS 加起来都解释不了这些内存去哪了。后来用 slabtop 一拉,真相大白:某个内核对象的 cache 占了将近 2GB,num_objs 涨到了几百万。从那以后,我对 Linux 内核这套 SLAB/SLUB 专用内存缓存池就特别上心。
这篇文章我打算把 SLAB 和 SLUB 讲透,包括它们到底解决什么问题、各自的经典设计、为什么 SLUB 能取代 SLAB 成为默认分配器,以及实际排查内存问题时怎么用 /proc/slabinfo 和 slabtop 定位问题。如果你是写内核模块、驱动,或者做嵌入式 Linux,又或者正在准备内核相关的面试,这篇文章应该能帮你把很多零散的知识串起来。我尽量用大白话讲,也会带上一些我实际踩过的坑。
1. 为什么内核必须自己搞一套对象级缓存池
先说一个最基础的问题:内核里到处都是几十字节、几百字节的小对象,比如文件系统的 dentry、inode,进程的 task_struct,网络连接的 socket 结构,它们被频繁创建和销毁。如果每次都直接找伙伴系统(Buddy Allocator)要内存,会怎样?
1.1 伙伴系统解决不了的对象级分配问题
伙伴系统管理的是物理页,最小粒度是 4KB。一个 dentry 结构体通常只有 192 字节,为了它单独分配一页 4KB 内存,内部碎片极其严重。更麻烦的是,每次分配都要经过复杂的页块拆分,释放时又要合并回大块,这个过程的开销和锁竞争非常可观。
你可以把伙伴系统想象成一个只按“整箱”出货的仓库。你只是要一根笔,它也得给你一箱。用完再还回去,仓库还要拆开箱子检查、重新归类。如果内核里所有对象都这么干,CPU 时间全耗在页表的拆拆合合上了。
1.2 缓存池要解决的三个核心问题
所以内核需要一套机制,把同一类大小的对象集中管理起来,这就是缓存池(Cache)。SLAB 分配器最早就是干这个的,SLUB 是它的改良版。一个合格的缓存池至少要解决三件事:
- 对象复用:对象释放后不还给伙伴系统,而是留在池子里,下次分配直接拿回来用。很多对象只需要重置少量字段,不用从零初始化,省了大量开销。
- 内部碎片控制:把一个或多个物理页切成若干大小相等的对象,按需分配,避免“一页用几个字节”的浪费。
- 无锁快速路径:利用 per-CPU 缓存,让同一个 CPU 上的分配和释放在绝大多数情况下不碰全局锁,这是性能的关键。
在 Linux 2.6.23 之前,默认分配器是 SLAB;从 2.6.23 开始,SLUB 成为默认。但内核代码里至今还保留着 SLAB 的实现,很多老驱动和嵌入式 BSP 也还在用 SLAB。所以两者都得懂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SLAB 的经典设计:着色、per-CPU 缓存与三链表
SLAB 的命名就是它的设计核心:“slab” 这个词指的是把一页或多页物理内存分成一组对象。理解 SLAB,可以先从它的组织单位开始。
2.1 slab 页组与对象划分
假设你有一个 cache,对象大小是 256 字节。如果每个 slab 占一页(4KB),那么这一个 slab 就能切成 16 个对象,objperslab = 16,pagesperslab = 1。如果对象很大,比如 4KB,那一个 slab 可能就需要多页,pagesperslab 会大于 1。
每个 cache 有一个名字,比如 kmalloc-256、dentry、filp。kmem_cache_create() 创建 cache 时传进去的 name 就是 /proc/slabinfo 里看到的名字。这个细节很重要,后面排查内存问题时你会靠它认人。
2.2 着色(coloring)解决什么问题
SLAB 里有一个很经典的设计叫 cache coloring,通常翻译成“着色”,但跟颜色没关系。它的动机是:不同 slab 中编号相同的对象,如果它们的起始地址相对页边界的位置完全一样,那么这些对象会被映射到 CPU 硬件 cache 的同一组 cache line 上。当内核频繁交替访问不同 slab 里的同编号对象时,就会互相“踢掉”对方的缓存行,造成 cache thrashing。
解决办法是在每个 slab 开头加一个不同的偏移量(颜色),让对象在内存里的起始位置尽量错开。你可以理解成停车场里每辆车错开一个车位停,大家都好进出。
不过在现代 CPU 大 L2/L3 cache、多路组相联的背景下,着色的收益已经很小了,而且它让 SLAB 的元数据管理和计算变得复杂。SLUB 基本砍掉了这个功能,换来的是更简单的代码路径。
2.3 SLAB 的三链表与 per-CPU array_cache
SLAB 为每个 cache 维护了三张 slab 链表:
slabs_full:所有对象都被占用的 slab。slabs_partial:有一部分对象空闲的 slab。slabs_free:所有对象都空闲的 slab。
分配对象时,优先从 slabs_partial 里找空闲对象,因为它还有剩余;如果 partial 也没了,就从 slabs_free 里取一个 slab 重新初始化并转去 partial。释放对象时,如果对象所在 slab 变完全空闲,就把它挂回 slabs_free,必要时还可以把整块 slab 还给伙伴系统。
除了这三张链表,SLAB 还专门为每个 CPU 准备了一个 array_cache,也就是 per-CPU 对象缓存。你可以把它理解成一个“加速队列”:大部分分配和释放只需要在 CPU 本地这个队列里进出,完全不需要碰 slab 链表上的全局锁。只有当本地队列满了,或者空了,才会去和 slab 链表批量交换对象。这种批量换入换出的设计,就是为了减少锁操作和链表遍历。
3. SLUB 的减法设计:快路径、freelist 与 struct page 复用
SLUB 全称是 “SLOB? Unqueued?”,其实官方文档里说它是 SLAB 的简化版,名字没有特别含义。它的整体思路是做减法:砍掉 SLAB 里复杂的 queue、array_cache、着色等机制,用更直白的数据结构实现同样的目标。
3.1 SLUB 砍掉了什么
SLUB 去掉了 SLAB 的 array_cache 和三层 slab 链表,改为每个 CPU 维护一个 kmem_cache_cpu,每个 NUMA node 维护一个 kmem_cache_node。它不再把 slab 分成 full/partial/free 三张链表,而是只维护一个 partial 链表,里面放“部分空闲”的 slab。
它还去掉了复杂的着色,不再为每个 slab 计算颜色偏移。理论上这会造成一些硬件 cache 冲突,但现代 CPU 的 cache 设计已经大大缓解了这个问题,换来的却是内核代码更短、更少的分支判断。
3.2 核心结构:kmem_cache_cpu 与 kmem_cache_node
kmem_cache_cpu 是每个 CPU 上的热路径,关键字段有:
freelist:当前 CPU 可用的空闲对象链表头。page:当前 CPU 正在使用的 slab 页。partial:当前 CPU 本地的部分空闲 slab 链表。
分配对象的快速路径非常短:从 freelist 头取下第一个对象,更新 freelist 指向下一个空闲对象,完事。整个过程不碰任何锁。
当 freelist 为空,或者当前 slab 用完了,就进入慢速路径:去 node 的 partial 链表里取一个 slab 作为当前 CPU 的 slab,重新建立 freelist。如果 partial 也空了,就调用伙伴系统分配新页组成新 slab。
释放对象同样有快慢两条路径。大多数情况下,释放的对象正好属于当前 CPU 正在用的 slab,直接把它头插到 freelist 即可,也不需要锁。如果释放的对象属于其他 slab,那么要去更新那个 slab 的 inuse 计数,必要时把它挂回 partial 链表。
3.3 复用 struct page 字段减少元数据开销
SLUB 一个非常聪明的设计是:它把 slab 的管理信息直接塞进了 struct page 里已有的字段,而不是像 SLAB 那样在 slab 头部额外放一个独立的描述符。struct page 里的 freelist、inuse、frozen、slab_cache 等字段,在普通页分配器眼里可能没意义,但对 SLUB 来说,这些字段足够描述一个 slab 的状态了。
这样做的好处是元数据不占额外内存,也没有 slab descriptor 的分配和释放。对于一个动辄几百万对象的内核来说,省下的那些内存是实打实的。
3.4 SLAB vs SLUB 对比
我在实际工作中经常被问到 SLAB 和 SLUB 的区别,这里整理一个表格,方便直接对照:
| 维度 | SLAB | SLUB |
|---|---|---|
| 设计复杂度 | 高,有 array_cache、三链表、着色等 | 低,代码路径短,逻辑直接 |
| 每 CPU 快速路径 | array_cache 批量缓存 | 直接 freelist 头插头取 |
| slab 管理信息 | slab 头部额外描述符 | 复用 struct page 字段 |
| 调试能力 | 有 red zone、poison,但功能较弱 | slub_debug 丰富,支持对象追踪、红区、毒药、栈记录 |
| 内存开销 | 元数据多,内存占用偏高 | 省内存,空 slab 归还更积极 |
| 默认状态 | 2.6.23 之前默认 | 2.6.23 之后默认 |
| 适用场景 | 老代码、特殊嵌入式 BSP | 现代内核、绝大多数服务器和嵌入式 |
我自己的体会是,SLUB 的代码读起来远没有 SLAB 那么绕。它能在大多数负载下表现更好,核心原因是 fast path 太短了,一个分配操作往往只有几条指令。
4. 通过 /proc/slabinfo 和 slabtop 定位真实内存问题
理论讲完,来点实际的。排查内核内存问题,/proc/slabinfo 是首先要看的地方。
4.1 字段逐列解读
/proc/slabinfo 的格式是固定的,我用一个简化片段举例:
code复制slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
dentry 204800 262144 192 21 1 : tunables 0 0 0 : slabdata 12488 12488 0
kmalloc-64 524288 524288 64 64 1 : tunables 0 0 0 : slabdata 8192 8192 0
关键字段解释:
name:cache 名称。active_objs:当前正被使用的对象数量。num_objs:当前 cache 管理的对象总数,包括空闲对象。objsize:每个对象大小(字节)。objperslab:每个 slab 里的对象数。pagesperslab:每个 slab 占用的页数。- 后面的
tunables在 SLUB 下基本是 0,因为 SLUB 不用 SLAB 那套 tunable 机制。 slabdata里active_slabs是非空 slab 数量,num_slabs是总 slab 数量。
排查时我最关心的是 active_objs 和 num_objs 的差距。如果某个 cache 的 num_objs 巨大且 active_objs 也很高,说明真的有很多对象在使用中;如果 active_objs 很低但 num_objs 很高,说明大量空闲对象滞留在池子里没有归还。
4.2 一次 slabtop 实战定位过程
回到文章开头那个案例。我先执行 cat /proc/meminfo | grep Slab,看到 Slab 字段高达 1.8GB,立刻确认是内核 slab 占用异常。然后执行 slabtop -o s 按 size 排序,看到 dentry 这一行 num_objs 是 480 万,active_objs 也是 470 万,基本可以断定是 dentry 缓存爆了。
这种场景通常是因为某个进程疯狂创建和删除文件,或者文件句柄泄漏,导致 dentry 数量暴增。我当时的处理是先用 sync 然后 echo 2 > /proc/sys/vm/drop_caches 手动回收一部分 dentry 缓存,验证系统是否恢复。结果确实回落了,再从业务侧排查是哪个应用在疯狂操作文件系统。
一个很重要的经验:slab 占用高不等于内存泄漏。它可能是正常的缓存膨胀,尤其文件系统缓存越多,说明系统越“努力”缓存读过的路径。判断是不是问题,要看 active_objs 是否异常增长,以及是否影响到了业务可用内存。
4.3 嵌入式环境里 cache 占用过大的排查思路
嵌入式 Linux 内存动辄只有 256MB 或 512MB,SLAB/SLUB 占用经常达到几十 MB,这时候更要会查。
我排查嵌入式设备时通常会做这几步:
- 确认内核是否开启了
CONFIG_SLUB_DEBUG。这个选项默认开启,但运行时如果slub_debug有值会显著增加内存占用。生产环境建议在 cmdline 里写slub_debug=-强制关闭。 - 用
slabtop或者cat /proc/slabinfo看前几名的 cache。嵌入式设备上经常出现kmalloc-128、kmalloc-256这类通用缓存占用很大的情况,这可能意味着某个驱动在用kmalloc频繁申请小块内存,比较难快速定位到具体驱动。 - 检查
CONFIG_SLAB_FREELIST_RANDOM是否打开。这个选项是安全加固用的,会让空闲对象链表乱序,好处是防攻击,坏处是 SLUB 难以把空 slab 快速合并回收,内存占用会变高。如果设备对安全性要求没那么高,关掉它也能省一点内存。 - 关注
slub_max_order。嵌入式系统物理内存碎片多,如果slub_max_order默认值为 3(8页),那么 SLUB 可能尝试分配 32KB 的连续页组成 slab,失败率高且占用大块内存。有些团队会调成 0,让每个 slab 只用一页。
嵌入式设备还有一个坑:如果开启了 memcg,但部分驱动没有用 SLAB_ACCOUNT 标志,你在 cgroup 里看不到这个驱动的内存占用,但它确实在 slab 里占着内存。排查时建议先看全局 Slab,再逐个 cache 对账。
5. 驱动里用 kmem_cache 的接口、调试开关与踩坑记录
对写内核模块和驱动的人来说,SLAB/SLUB 不只是“读一读”的知识,而是要直接用 kmem_cache 系列 API 管理自己的对象。
5.1 从创建到释放:kmem_cache 常用 API
先看一个典型用法:
c复制struct my_obj {
u32 id;
void *ptr;
unsigned long flags;
};
static struct kmem_cache *my_obj_cache;
static int __init my_driver_init(void)
{
my_obj_cache = kmem_cache_create("my_obj_cache",
sizeof(struct my_obj),
0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
NULL);
return 0;
}
static struct my_obj *my_obj_alloc(gfp_t gfp)
{
struct my_obj *obj = kmem_cache_alloc(my_obj_cache, gfp);
if (obj)
memset(obj, 0, sizeof(*obj));
return obj;
}
static void my_obj_free(struct my_obj *obj)
{
kmem_cache_free(my_obj_cache, obj);
}
这里有几个要点:
kmem_cache_create的第二个参数是对象大小,第三个参数是对齐(0 表示自然对齐),第四个参数是 flags。SLAB_HWCACHE_ALIGN会按硬件 cache line 对齐对象起始地址,适合那些会被频繁并发访问的结构体,能避免伪共享。SLAB_PANIC的意思是创建失败直接 panic。驱动的__init函数里一般没有很好的错误处理路径,与其层层返回错误,不如直接 panic,方便尽早暴露问题。kmem_cache_alloc分配出来的对象不会清零,所以需要手动memset或者用__GFP_ZERO。这和kzalloc不一样,容易踩坑。- 模块卸载时记得
kmem_cache_destroy(my_obj_cache),否则会泄漏。
5.2 SLUB 调试开关怎么开
SLUB 提供了很强大的调试机制,内核启动参数里用 slub_debug 控制。常见的开关字母:
F:Sanity check,检查 freelist 一致性。Z:Red zoning,在对象前后放红区,检测越界写。P:Poisoning,对象释放后填充 0x6b 等毒药值,检测 use-after-free 和未初始化访问。U:User tracking,记录对象的分配和释放调用栈。T:Trace,打印分配和释放事件。O:Oops on error,出错时触发 oops。
我调试驱动时常用的组合是 slub_debug=FZPU。它可以同时检测越界写、释放后访问、重复释放等问题。比如你怀疑某个 cache 有问题,可以只对这一个 cache 开启调试:
code复制slub_debug=FZPU,my_obj_cache
这个语法只调试名为 my_obj_cache 的 cache,不影响整机性能。我强烈建议驱动开发者上生产之前,至少开启 FZPU 跑一遍压力测试,能把很多隐藏的内存问题提前暴露出来。
5.3 我踩过的坑:对象越界、UAF 在 SLUB 下如何暴露
说一个我早期的真实经历。当时写一个网络驱动,定义了一个缓冲区结构体,日志里偶尔出现内存校验错误,但整个系统很稳定,几个月都不宕机。我一开始完全没头绪,后来回滚代码发现有一段逻辑会往结构体尾部多写 8 字节。
在普通 SLUB 配置下,多写 8 字节可能刚好落在空闲对象区,没人发现。但开了 slub_debug=Z 之后,释放对象时内核立刻打印出红区被写坏的信息,日志长这样(简化版):
code复制=============================================================================
BUG my_obj_cache (Tainted: G O): Redzone overwritten
-----------------------------------------------------------------------------
...
INFO: 0x00000000abcdef10-0x00000000abcdef17 @offset=1088. First byte 0x0 instead of 0xcc
看到这一行你就知道,有代码越界写到了对象后面的红区。配合地址 0xabcdef10,你可以算出对象地址,再查是谁分配的。如果没有红区,这种越界可能要等到几十万次分配后某一刻才偶然崩溃,那时候定位成本高得多。
5.4 性能观察项:slub_cpu_partial 与 per-CPU 部分空 slab
SLUB 里有个参数叫 slub_cpu_partial,它限制每个 CPU 本地可以缓存多少个部分空闲(partial)的 slab。默认值通常是 30 或者根据内存动态调整。
这个参数的意义在于:当当前 CPU 的 slab 用完后,它不必立刻去 node 的全局 partial 链表拿锁取 slab,而是可以从本地 partial 里直接拿一个。本地 partial 的数量越多,锁竞争就越少,但每个 CPU 上残留的空闲对象就越多,内存占用会上升。
我在 NUMA 机器上跑过高并发网络转发测试,发现默认 slub_cpu_partial 值偏保守,个别 CPU 上分配路径锁竞争明显。后来调大这个参数,PPS 性能提升了一截。但在嵌入式设备上我不敢这么调,因为每个 CPU 多缓存几个 slab,几个核加起来就是好几 MB 内存,小内存设备扛不住。
6. SLUB 调优参数、碎片化误区和面试高频考点
这一章聊点进阶的:调优参数、以及对“碎片化”的常见误解。这些内容在面试中也很容易被拿来考验基础。
6.1 内核启动参数:slub_min_objects、slub_max_order、slub_min_order
SLUB 在创建新 slab 时,要决定用几页组成一个 slab。这个决策由几个启动参数控制:
slub_min_objects:每个 slab 至少要包含多少个对象。默认值通常根据 CPU 数和内存大小计算。slub_max_order:单个 slab 最多占 2 的多少次方页。默认是 3,也就是最多 8 页(32KB)。slub_min_order:单个 slab 最少占 2 的多少次方页。
它们的关系是:优先满足 slub_min_objects,但 order 不能超过 slub_max_order。如果对象很小,为了凑够 slub_min_objects,SLUB 会自动调大 order;如果对象很大,一个对象就占几页,那么一个 slab 可能就只放一个对象。
调优经验:大多数服务器环境不用动这些参数。嵌入式环境如果内存碎片严重,可以设 slub_max_order=0,强制每个 slab 只占一页,避免申请大块连续内存失败。代价是对象数变少,slab 管理开销增加,但换来的是分配成功率更稳定。
6.2 “碎片化”在 SLAB/SLUB 语境下到底指什么
很多人一看到 Slab 内存占用高就说“内存碎片化”,其实概念是错的。伙伴系统关心的是外部碎片——物理页之间出现无法利用的空洞。SLAB/SLUB 关心的是对象层面的问题:大量对象被分配又释放后,slab 里出现很多空洞,导致 slab 无法被整体归还。
在 SLUB 里,空 slab 会挂在 slab_free 链或者直接归还伙伴系统。真正麻烦的是 partial slab 太多:每个 slab 只用了几个对象,剩下的空闲对象没法给别的用途用,内存看起来就被“吃”掉了。这种情况在 SLUB 里叫 partial 链表膨胀。
排查技巧:看 slabtop 时,如果某个 cache 的 num_slabs 很高,但 active_objs / num_objs 比例很低,说明大量 slab 只有零星几个活跃对象,内存利用率很差。这时候可以去查是不是有驱动握着一堆对象不释放,或者某个业务线程反复创建销毁同一类对象。
6.3 面试高频考点与易错点
结合我面试候选人和被面试的经验,SLAB/SLUB 常见考点就那几个:
- SLAB 和 SLUB 的设计区别:重点说 SLUB 砍掉了 array_cache 和着色,复用了 struct page,fast path 更简单。
- SLUB 为什么成为默认:代码路径短、调试功能强、内存占用低,综合更好。
- kmalloc 和 kmem_cache 的关系:kmalloc 底层走的是通用 kmalloc-* cache,本质也是 SLAB/SLUB 管理。直接说 kmalloc 走伙伴系统是不对的。
/proc/slabinfo字段含义:至少能说出active_objs、num_objs、objsize是什么意思。- per-CPU 缓存如何减少锁竞争:用本地 freelist 头插头取,避免全局锁。
易错点有两个。第一,以为 SLUB 一定比 SLAB 快,这不一定,极端工作负载下 SLAB 的着色和 array_cache 可能反而有优势,只是现代内核默认 SLUB 综合更好。第二,以为堆内存问题都归 SLAB/SLUB 管,其实内核里还有 vmalloc、CMA、页表、ioremap 等多条内存路径,排查时要先确认内存究竟消耗在哪条路径上,不要一上来就盯 slab。
我自己在实际操作中的体会是:不管你是写驱动、做嵌入式,还是只是被线上内存问题折腾过,先把 SLAB/SLUB 的设计逻辑和 /proc/slabinfo 读熟,能省掉大量排查时间。最后再分享一个小技巧:如果你怀疑某个对象池有内存泄漏,不要只盯着 active_objs,要配合 slabtop 连续采样几次,看它是否持续增长。如果 num_objs 不断向上走,而业务量没有同步上涨,那基本可以实锤了,这时候就该翻代码或者上 slub_debug=U 看分配栈了。
