操作系统内功 · 第 3 篇|内存分配内幕:伙伴系统、SLUB 与 glibc 碎片连环事故

LinkedIn 的 Venice 团队遇到过一桩悬案:RocksDB 服务的 RSS 单调上涨,JVM 与 native 堆剖析都查不到泄漏,最终进程被 OOM killer 击毙。换上 jemalloc 后,问题凭空消失。Percona 在 MySQL 上、OpenJDK 在 JDK-8193521 号缺陷里记录了几乎同款的剧本——这不是内存泄漏,是 glibc 分配器的碎片化。要理解这类事故,得先看清内存分配的完整链路:内核怎么发内存、内核自己怎么用内存、用户态分配器怎么二次批发。本篇沿这条链走一遍,最后讲清 OOM killer 的判决书是怎么写的。

一、内核怎么发内存:伙伴系统

内核管理物理页的算法叫伙伴系统(buddy system):所有空闲内存按 2 的幂组织成块,从最小 4KB(order-0)到经典的单次最大 4MB(order-10,1024 页;以 x86-64 经典内核为例,MAX_ORDER=11)。申请 64KB 就去找 order-4 的块;没有就到 order-5 拆一块,一半给你、另一半挂回 order-4 的链表——拆出来的两块互为「伙伴」,将来归还时若伙伴也空闲就合并回去,防止外部碎片越积越碎。这个「拆一半、还一半」的对称设计是它名字的由来。实现的骨架是十几条链表按阶挂空闲块,分配释放都是 O(1) 量级的链表操作,快,但「2 的幂」意味着申请 5KB 也得给 8KB——页粒度管理的天然内部碎片税。这笔税加上每次进内核的系统调用成本,正是用户态分配器存在的两个理由。

伙伴系统对付不了的是另一种碎片:内存够、但不连续。系统跑了很久,空闲页散落各处,想拿一块连续的 2MB 就难了——这正是 THP 最需要的形状。内核的两道补丁:一是 migratetype 分区,把可移动(页缓存、匿名页可迁移)、不可移动(内核关键结构)、可回收的页分开安置,别让不可移动的页把大块内存「钉碎」(2005 年起合入的 placement policy);二是 内存规整(compaction),由 kcompactd 后台线程把可移动页往一端搬、腾出连续大块——上一篇讲 THP 分配失败会同步触发规整导致停顿,指的就是它。

二、内核自己的对象商店:SLUB

内核里海量的小对象——task_struct、inode、各种 sock——分配释放频率极高,直接走伙伴系统按 4KB 起步太浪费。解法是 slab 分配器(思想源自 Sun 工程师 Jeff Bonwick 1994 年的论文):把伙伴系统的页切成固定尺寸的对象池,每种类型一个池,释放的对象不归还而是留在池里复用,还顺手做 CPU 缓存友好布局。Linux 长期存在三个实现:SLAB(原始版)、SLUB(简化高效版)、SLOB(嵌入式极简版),SLUB 自 2.6.23 起是默认,SLAB 与 SLOB 已先后被清理出内核(2023 年前后的移除补丁),如今 SLUB 是唯一实现。它的关键设计是 per-CPU 活跃 slab:每个 CPU 有自己的一份对象快取,分配释放几乎无锁——和用户态分配器的 thread cache 思路殊途同归,这个概念马上就会再次出现。

三、用户态分配器三国志

malloc 不直接向内核要内存(brk/mmap 的系统调用成本太高),而是批发一大块再零售。先补一点零售常识:ptmalloc 的堆从 brk 起步,小块在堆里按 chunk 切分;超过 mmap 阈值(默认 128KB 且会动态调整)的大块直接 mmap 单独映射、释放即还内核——这条隔离通道让大对象不拖累堆的碎片。三大分配器在这套常识之上的分野,集中在「谁来缓存、怎么分档、怎么还债」:

分配器 核心设计 强项 软肋与备注
ptmalloc2(glibc 默认) 主 arena + 多个子 arena(各 64MB 虚拟区,默认上限约 8×核数),arena 内锁竞争 零配置、通用 多线程锁争用与碎片化 RSS 膨胀,经典事故源
tcmalloc(Google) 线程本地缓存 + 中央空闲链表,小对象几乎无锁 高并发小对象吞吐 官方文档自述:线程本地缓存让小对象分配近乎零锁争用
jemalloc(Jason Evans) 多 arena + size class 分档,FreeBSD 默认 抗碎片、元数据开销小于总内存 2%(Facebook 2011 实测口径) 需显式链接替换,调优项较多

ptmalloc 的问题机制值得单独说:多线程抢同一个 arena 时锁竞争激烈,glibc 的解法是开更多 arena——但 arena 一多,同一进程的空闲内存被摊薄在各 arena 里,谁都凑不出整块还给别人,RSS 虚高而内部明明有空闲。这就是 Venice、MySQL、JDK-8193521 三案共同的病理。缓解手段从轻到重:MALLOC_ARENA_MAX 限死 arena 数、malloc_trim() 手工催还、或者干脆换 jemalloc/tcmalloc。Cloudflare 给 RocksDB 换 TCMalloc 后 RSS 显著下降,他们的评价一针见血:ptmalloc 是为单线程设计、后来打补丁改造的,TCMalloc 从第一天就是为多线程而生的。

三家的策略差异可以再浓缩成三句话。ptmalloc 把缓存挂在 arena 上,arena 多则缓存散、谁都凑不出整块;tcmalloc 按尺寸分成上百个 size class,每线程本地缓存各档的 free list,攒多了批量上交中央链表;jemalloc 同样按 size class 分档,但缓存归属 arena,并用 decay 策略让脏页在数秒内渐次归还内核——「晚还但稳还」,这正是它抗碎片的底牌。Facebook 2011 年的迁移文还点名了两者的亲缘:jemalloc 的 arena 设计吸收了 tcmalloc 线程缓存的思路,它们是互相借鉴的近亲,选型时不必迷信「谁碾压谁」,基准要拿自己的负载跑。这套「线程本地批发」的思想还血脉相承进了语言运行时:Go 的内存分配器在官方文档里明言借鉴 TCMalloc 的分级缓存设计,Java 的 TLAB(线程本地分配缓冲)同属这一家族——理解了分配器,等于顺手理解了运行时的内存行为。

四、OOM killer 与 overcommit:判决书怎么写

为什么内核要赌?因为承诺与实际使用的差距巨大:绝大多数进程申请的内存只触碰一小部分,fork 更是最大受益者——如果 fork 要立刻复制全部物理内存,大型进程根本起不来。超售是虚拟内存红利的另一面,代价则是把局部的分配失败推迟成了全局的杀进程时刻。

Linux 默认允许内存超售:malloc 承诺的比物理内存多得多,赌的是进程不会全用。赌输的那一刻,内核启动 OOM killer 挑选牺牲者:badness 评分主体约为任务 RSS + swap + 页表,谁占得多杀谁;评分可用 /proc/<pid>/oom_score_adj 人工干预,范围 -1000 到 +1000,设为 -1000 即免死金牌(数据库主进程常见的保命配置,旧接口 oom_adj 已被取代)。

超售的松紧由 vm.overcommit_memory 控制,官方文档定义三档:0 启发式(默认,拒绝明显离谱的承诺)、1 永远答应、2 永不超售——承诺总额被限制在 swap + 物理内存 × overcommit_ratio(ratio 默认 50)以内。注意一个常见误区:mode 2 并不能让进程对 OOM killer 免疫,它只是让系统尽量别走到那一步;真正免死还是靠 oom_score_adj。

事故现场的标准证据在 dmesg 里:一行 Out of memory: Killed process … 连同当时的内存快照与各进程评分表,是唯一权威的判决记录。两个旋钮值得认识:vm.panic_on_oom=1 让内核在 OOM 时直接 panic——配合高可用重启,适合「宁可全节点重启也不能死错进程」的场合;vm.oom_kill_allocating_task=1 则让内核免于逐个算分、直接杀掉申请者,省时间但粗糙。swap 还会改变判决的节奏:有 swap 的机器先经历颠簸(thrashing),si/so 飙升、延迟先劣化再死亡,监控上有预警窗口;无 swap 的容器节点则从「健康」直接跳到「被杀」。这套机制与容器的联动(cgroup 的 memory.max 触发的 OOM 是另一套记账)留到第 8 篇展开。

排查手段也是配套的:ptmalloc 有 malloc_stats() 与 mallinfo 家族,tcmalloc 支持 pprof 堆快照,jemalloc 有 mallctl 与 stats 接口——「看不见才靠猜,看得见就能修」。Cloudflare 那篇 RocksDB 文能把结论落定在 TCMalloc 上,靠的正是分配器自带的剖析数据;只盯 JVM 堆或业务指标,碎片这个嫌疑人永远不会自首。

五、工程落地清单

  1. 排查「内存泄漏」先看分配器。RSS 涨、堆剖析干净、释放一直发生——八成是碎片。MALLOC_ARENA_MAX=4 起步尝试。
  2. 高并发服务显式选型。多线程大服务(RocksDB、代理网关、JVM native 侧)换 jemalloc 或 tcmalloc,常有两位数 RSS 收益。
  3. 给关键进程发免死金牌。oom_score_adj=-1000 保护数据库主进程,同时确保它真不会失控——免死金牌是双刃剑。
  4. 内存大页与碎片是连体问题。禁 THP 之外,长期运行的节点安排 compaction 压测,确认 THP 关闭后高阶分配不再抖动。
  5. 关注内核升级的分配器变化。SLUB 与用户态分配器都在持续演进,升级内核或 glibc 后重跑内存基线。
  6. 换分配器是低风险高收益的运维动作。LD_PRELOAD 链接 jemalloc/tcmalloc 不用改一行代码,灰度观测 RSS 与 P99 两个指标即可验证。
  7. 无 swap 的容器节点要更灵敏的内存告警。那里没有 thrashing 的预警窗口,内存曲线到位就是突然的 OOM。

结语

内存分配是一条批发零售链:伙伴系统批发页、SLUB 批发内核对象、用户态分配器批发堆,每一层都在跟碎片作斗争。事故的规律也贯穿始终:碎片不会让 malloc 失败,它只会让 RSS 悄悄膨胀,直到 OOM killer 替你做决定。看清这条链,那些「查不到泄漏的内存上涨」就有了标准排查路径。下一篇转向持久侧:文件系统、Page Cache,以及那句著名的「fsync 到底向你承诺了什么」。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?