想象城市里有一座巨型智能仓库,地面被画成了无数相同大小的格子,像一块整齐的巧克力板。仓库管理员每天要接待形形色色的顾客:有人要存一个皮箱,有人要存一辆汽车,还有人要搬进来一整个办公室的家具。可问题是,所有格子都一样大,怎么用统一规格的储物格满足大小完全不同的需求? 管理员想出了一个聪明的办法:把相邻的格子动态拼接成更大的柜子——两格拼双格,双格拼四格,四格拼八格,这样就能组合出大小按 2 的幂增长的各种储物柜。当大柜子不再需要时,再拆回小格。更精妙的是,管理员在合并时只认准那些“从小一起拆开、如今又恰好都空着”的兄弟格——它们就是伙伴。 这套规则,就是 Linux 内核中最经典的物理内存分配算法——Buddy 伙伴系统。它把内存管理变成了一场优雅的“格子拼图游戏”。然而,频繁拆拼也会让空闲格子像跳棋一样散布各处,形成所谓的内存碎片;内核还需要定期“大扫除”来把碎片重新聚拢。本文就从仓库管理员的一天开始,拆解 Buddy 系统的每一步。
Buddy 系统是理解操作系统物理内存分配的基石,掌握它你就能顺畅读懂 Linux 内核内存管理源码,并在云原生、虚拟化等高性能场景中精准定位内存瓶颈,也是系统工程师面试中的高频考点。
可视化演示
展示Buddy系统页框分裂与合并、内存碎片整理compaction过程与OOM killer/kswapd三级回收策略,帮助理解操作系统内存管理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+captions.buddy+'
基础概念
我们继续用仓库管理员的故事开场。管理员把仓库地面划分成无数标准尺寸的储物格,每组相邻格子都有唯一编号。为了满足不同大小的储物需求,他从不一个个格子地操作,而是把相邻格子拼成更大的“块”。最小的块就是 1 个储物格,稍大一点是 2 格,再往上 4 格、8 格、16 格……块的大小永远按照 2 的倍数增长。
在操作系统术语中,这个最小储物格叫“页框”(page frame),是内核管理物理内存的最小单位。多个连续页框组成的区域叫“块”。为了快速描述块的大小,内核引入了“阶数”(order):0 阶块包含 1 个页框,1 阶块包含 2 个页框,k 阶块包含 2^k 个页框。阶数就像储物格的“层架编号”,管理员看到编号就能立刻知道柜子能装多少东西,不用去数格子。
在 Linux 内核里,struct page 是页框的描述符,内核用 mem_map 数组把物理内存中所有页框描述符串联起来。Buddy 系统则维护一组按阶数分层的空闲块链表,叫作 free_area。每个阶数都有专属链表,挂着该尺寸的空闲块。初始化时,内核会把整个物理内存拆成多个大块挂入最高阶链表,之后所有分配与回收都从这个源头开始。
有了阶数这个“编号”,仓库管理员管理起来轻松多了——他不需要逐格记忆,只需要知道“几阶块”对应多大空间。后面所有的拆分、合并,都建立在这个阶数框架之上。
- 阶数阶数是描述块大小的核心参数,在 0 到 MAX_ORDER 之间取值,每增加 1,块内页框数翻倍。
- 2 的阶数次方表示该阶块包含的连续页框个数,例如 3 阶块的页框数等于 8。
- 页框大小每个页框的固定容量,在 x86-64 平台上页框大小通常为 4KB,是分配和回收页时的基本计量单位。
- 乘积运算把页框个数乘以页框大小,即可换算成以字节为单位的块大小,例如 3 阶块是 32KB。
分配策略
顾客来了,管理员怎么快速找到合适的柜子?他站在“分格索引台”前,台面贴着标签:0 阶、1 阶、2 阶……每个标签下面有一个抽屉,抽屉里放着当前所有空闲柜子的编号。管理员先把顾客的需求换算成最小合适的阶数,然后从对应抽屉开始翻找。
如果目标阶数的抽屉是空的怎么办?管理员会顺手打开更大的抽屉,取出一块大柜子,当场拆成两半:一半交给顾客,另一半放回低一阶的抽屉。这个过程就是 Buddy 系统分配内存的核心思路:“从上往下借,借了立刻拆”。
具体到内核,alloc_pages(order) 首先从 free_area[order] 链表取块;如果该链表为空,便向 free_area[order+1] 借块,并把借来的大块拆成两个 order-1 的伙伴块,一个挂回低阶链表,一个继续使用;若高一级也空,就继续向上查找,直到找到非空链表。这个“向上索引”的过程在实现上通常配合位图或快速查找表来加速。
这个策略保证了柜子尺寸“刚好够用,又不会大太多”,同时查找过程像翻抽屉一样快捷,分配均摊复杂度接近 O(1)。
- 请求大小用户申请的内存字节数,比如一个临时数据对象需要 5KB 的内存空间。
- 页框个数换算将请求大小除以页框大小,得到需要的页框个数;如果无法整除,结果就是一个小数。
- 对数运算对页框个数求以 2 为底的对数,得到“需要用 2 的几次幂来覆盖”的数值。
- 向上取整Buddy 只能按 2 的幂次分配页框,所以把 0.32 向上取整为 1,最终分配阶数 1,即 2 个页框共 8KB。
伙伴分裂
把大柜子拆成两半时,管理员会在本子上把两个半柜用括号连在一起,注明它们是“双胞胎”。只有双胞胎才能彼此认亲——以后释放时,管理员只在双胞胎之间考虑合并,绝不随便把两个不相干的空闲块拼起来。这个“双胞胎”就是伙伴。
在 Buddy 系统中,分裂是分配过程的必经之路:当需要的块小于当前可用的最大块时,内核会把这个大块递归二分。每拆一次,就得到两个同阶且物理地址相邻的伙伴块。Linux 内核中的分配位图(bitmap)会记录各块状态,帮助系统快速判断某块的伙伴是否空闲。
伙伴地址的计算是整个算法最精妙的一环。给定块大小为 2^k 个页框,其伙伴地址只需一次异或运算即可得到:伙伴地址 = 块地址 XOR 块大小。因为块地址对齐到自身大小,低 k 位是 0,而块大小的二进制在第 k 位上是 1,两者异或恰恰只翻转第 k 位——正好找到同一大块的另一半。
从仓库管理员的视角看,这就像在格子地图上涂掉一个坐标,立刻就能读出隔壁格子的编号。伙伴发现能力,正是 Buddy 系统合并速度的底气所在。
- 块地址当前块在物理地址空间中的起始地址,由于块按自身大小对齐,它的低 k 位二进制位都是 0。
- 块大小当前块占据的字节数,其二进制表示中第 k 位为 1,其余低位为 0。
- 异或运算按位异或只翻转对应位为 1 的位,因此异或操作恰好翻转地址的第 k 位,其他位保持不变。
- 伙伴地址翻转后的地址正是相邻伙伴块的起始地址,整个识别过程只需要一个时钟周期,极快且无分支。
伙伴合并
当顾客取走货物、归还柜子时,管理员的第一件事不是把柜子硬塞回抽屉,而是查看旁边那格:它的“双胞胎兄弟”是不是也在闲着?如果兄弟也空置,就把两个半格拼成一个完整的双格柜;如果拼好的双格柜还能和另一个双格柜继续拼,就再拼一次,直到找不到空闲的兄弟为止。
这正对应 Buddy 系统释放内存时的伙伴合并流程。释放一个 order=k 的块后,内核先用伙伴地址公式找到它的伙伴;若伙伴同样空闲,则将两者从各自链表摘下,合并成一个 order=k+1 的块,并继续尝试向上合并。合并过程可以一直递归到 MAX_ORDER,也可能因为伙伴忙碌而提前停止。
合并是分裂的逆操作,也是消除外部碎片最直接的手段。需要注意,合并必须保证两个块是“合法伙伴”:来自同一个父块、物理地址相邻、阶数相同。如果只是大小相同但地址不相邻,绝不能合并。内核常通过 struct page 的 private 字段与 PG_buddy 标志位来维护伙伴关系。
管理员把两个本就相邻的空柜子拼在一起,既腾出了大块空间,又让柜子编号依然规律。伙伴合并,正是 Buddy 系统对抗碎片最重要的武器。
- 原阶数释放的块当前处于第 k 阶,表示它包含 2^k 个页框。
- 原块大小释放块的大小由页框个数乘以页框大小得到。
- 合并翻倍两个伙伴块合并时,页框个数从 2^k 变成 2^(k+1) 个,也就是乘上一个系数 2。
- 合并后阶数新块的阶数是原阶数加一,即 k+1,对应的大小正好是新阶数下的块大小。
碎片问题
仓库的麻烦也随之而来。管理员勤勉地拆拼柜子,但顾客来来往往,空间总会被“弄花”:某个区域里,大小不一的柜子像跳棋一样交错排列,中间散落着许多零星空格。明明空闲格子总数不少,却找不到一个大到能放下整台冰箱的连续格子——这就是外部碎片。
与外部碎片相对的是内部碎片:当请求 5KB 时,Buddy 分配器给了 8KB 的块,多出的 3KB 虽然在块内,却无法被任何其他请求利用。内部碎片是 Buddy 按 2 的幂次分配导致的固有代价,而外部碎片则是伙伴系统合并机制未能完全回收的结果,多由小块的分配、释放序列造成。
我们可以用碎片率来量化碎片化程度:统计空闲页框中最大的连续块有多大,再除以总空闲内存。碎片率越接近 1,表示虽然空闲块总数多,但无法满足大块请求;越接近 0,表示空闲内存在很大程度上是连续的。
就像仓库里能用的格子总数不少,却总差那么一块“整地方”,碎片率就是管理员判断何时需要大扫除的仪表盘。
- 最大连续空闲块大小在物理地址上相邻且全部空闲的页框累计起来的最大字节数。
- 总空闲内存大小整个系统中所有空闲页框的总字节数,包括每一块零散空闲区域。
- 连续占比计算最大连续空闲块在总空闲内存中的比例,代表“能用来满足大请求”的可用程度。
- 补数计算用 1 减去占比得到碎片率;占比越高,碎片率越低,内存质量越好。
碎片整理
当碎片率严重影响到大客户,管理员会启动“深夜大扫除”:把仓库里的货柜挪来挪去,将所有已占用的柜子逐步搬到同一侧,把散落的空格聚拢成一整片区域。搬移过程虽然消耗人力,但能让仓库重新拥有“超大储物区”。
Linux 内核的对应招数是内存规整(memory compaction)。它扫描可迁移页框,把正在使用的页块搬到一起,尽量让空闲页框形成连续区间。页块迁移的关键是页表更新和数据拷贝;而反碎片技术则从源头下手:按页框的可移动性(不可移动、可回收、可移动)分组存放不同类型的页,避免碎片从分配阶段就聚集起来。
任何整理都要付出代价。移动一个页框需要拷贝数据、修改页表并刷新 TLB;迁移的页数越多,开销越大。因此内核只有在碎片率较高或大块分配失败时,才会触发迁移与规整。这正是“碎片收益与开销的取舍”——频繁迁移反而会拖垮整体性能。
管理员不会天天大扫除,只在碎片影响大局时才动手。Buddy 系统负责“日常分配与合并”,内存规整负责“紧急疏导”,两者合力,才能让内存长期维持较高的连续可用空间。
- 迁移页数需要搬移的页框总数,它由当前碎片化范围和规整策略决定。
- 单页迁移成本每移动一个页框都要执行数据拷贝、页表更新和 TLB 刷新操作。
- 总开销计算将迁移页数与单页成本相乘,得到整个内存规整操作的总开销。
- 触发决策只有当碎片收益大于迁移开销时,内核才执行规整,否则继续保持现状。
AI 应用场景
云虚拟化平台的虚拟机内存分配
阶段:基础设施层资源编排
解决的问题:云平台上 KVM 等虚拟化技术需要为每台虚拟机分配连续物理内存。传统固定大小的分配方式容易造成浪费或无法满足大内存需求;Buddy 系统将物理内存组织成 2 的幂次块,既能快速分配、释放页面,又能通过伙伴合并避免碎片累积。
具体案例:某云厂商的宿主机承载大量容器和微虚拟机,内存请求从 order=0 到 order=9 不等。当多台轻量级容器释放内存后,伙伴合并把零散页块重新拼成 2MB 大块,使后续 AI 推理实例成功分配到 HugeTLB 大页,减少 TLB miss 带来的性能损耗。
长期运行数据库的内存碎片自检
阶段:数据库性能优化与稳定性保障
解决的问题:MySQL、PostgreSQL 等数据库进程长期运行后内存碎片率升高,造成大块分配失败,即使总内存仍然富余。通过监控 /proc/buddyinfo 观察各 order 空闲块数量,及时触发内存规整,把可迁移页块聚拢,降低碎片率,恢复“仓库大扫除”后的连续空间。
具体案例:某电商平台大促前,DBA 团队发现 order=5 以上空闲块急剧减少,立即触发内存规整。2 小时后碎片率从 0.41 降至 0.13,OLAP 查询临时表成功获得 16MB 连续内存,避免了大查询退化。
AI 训练框架的大页显存池
阶段:AI 模型训练与推理平台资源管理
解决的问题:PyTorch 等深度学习框架需要频繁分配 GPU 显存和 CPU 内存池,直接将 Buddy 思想应用于显存池,能有效降低分配冲突和碎片;配合 2MB/1GB 大页还可以减少 TLB miss,提升访存效率。
具体案例:某公司训练大语言模型时,用类 Buddy 算法管理 GPU 内存池,将显存预划分为 128MB、256MB、512MB 等 2 的幂次块。张量释放时执行伙伴合并,显存利用率提升约 37%,训练启动时间缩短 22%。
Python 演示
class BuddyAllocator: # 简化版Buddy系统 以页框为基本单位 def __init__(self, max_order=5): self.max_order = max_order self.total_pages = 1 << max_order self.free_lists = {o: [] for o in range(max_order + 1)} self.free_lists[max_order].append(0) self.busy = {} self.flags = [0] * self.total_pages def buddy_addr(self, addr, order): return addr ^ (1 << order) def alloc(self, order): if order > self.max_order: return None cur = order while cur <= self.max_order and not self.free_lists[cur]: cur += 1 if cur > self.max_order: return None addr = self.free_lists[cur].pop() while cur > order: cur -= 1 buddy = self.buddy_addr(addr, cur) self.free_lists[cur].append(buddy) self.busy[addr] = order for i in range(addr, addr + (1 << order)): self.flags[i] = 1 return addr def free(self, addr): if addr not in self.busy: raise ValueError('unknown address') order = self.busy.pop(addr) for i in range(addr, addr + (1 << order)): self.flags[i] = 0 while order < self.max_order: buddy = self.buddy_addr(addr, order) if buddy in self.free_lists[order]: self.free_lists[order].remove(buddy) addr = min(addr, buddy) order += 1 else: break self.free_lists[order].append(addr) return order def frag_rate(self): max_cont = 0 cnt = 0 for i in range(self.total_pages + 1): if i < self.total_pages and self.flags[i] == 0: cnt += 1 else: max_cont = max(max_cont, cnt) cnt = 0 total_free = self.flags.count(0) return 0.0 if total_free == 0 else 1.0 - max_cont / total_free def main(): b = BuddyAllocator(max_order=5) # 32个页框 print('free list before:', {o: len(b.free_lists[o]) for o in range(6)}) a1 = b.alloc(3) print('alloc(3) -> addr =', a1) a2 = b.alloc(2) print('alloc(2) -> addr =', a2) print('free list middle:', {o: len(b.free_lists[o]) for o in range(6)}) print('frag rate: %.3f' % b.frag_rate()) b.free(a1) print('after free(a1) :', {o: len(b.free_lists[o]) for o in range(6)}) b.free(a2) print('after free(a2) :', {o: len(b.free_lists[o]) for o in range(6)}) print('frag rate end : %.3f' % b.frag_rate()) if __name__ == '__main__': main()
self.free_lists = {o: [] for o in range(max_order + 1)}为每个阶数准备一条空闲链表,对应内核的 free_area 数组return addr ^ (1 << order)用异或运算一步定位伙伴地址,是 Buddy 算法最核心的位运算while cur <= self.max_order and not self.free_lists[cur]: cur += 1顺着阶数向上查找非空链表,找不到则分配失败buddy = self.buddy_addr(addr, cur); self.free_lists[cur].append(buddy)拆分大块时,把多余的一半挂回低阶链表备用if buddy in self.free_lists[order]释放时先确认伙伴确实空闲,不满足条件绝不合并addr = min(addr, buddy)合并后块地址取两个伙伴中较小的起始地址,保证新的块往低地址对齐return 1.0 - max_cont / total_free最大连续空闲块占比越低,碎片率越高,直接量化内存质量
常见误区
误区“Buddy 系统有拆分又有合并,应该能把内存碎片全部消化掉,所以系统里不会再有碎片问题了吧?”
正解伙伴系统主要管理的是外部碎片的合并,却无法避免内部碎片。比如请求 5KB 时,系统必须按 2 的幂给 8KB 块,多出的 3KB 就是内部碎片。同时,当大量大小不一的空闲块交错分布时,合并能力有限,外部碎片依然存在。正因如此,内核还需要内存规整(compaction)等额外机制来收拾残局。
为何常见:初学者看到“拆分 → 合并”的巧妙对称性,容易把伙伴系统理想化,忽略了它只负责“块之间的合并”,并不能移动已经被占用的页面。
误区“只要释放一个块,系统就一定会立刻和相邻块合并,所以内存总能保持最大连续。”
正解合并的前提是伙伴块同时处于空闲状态。如果伙伴正被其他进程占用,释放块就只能留在当前阶数的空闲链表中,等待未来伙伴释放后再合并。最坏情况下,即使两个块物理相邻,也会因为使用者不同而长期不能合并。
为何常见:教材示意图通常假设伙伴空闲,让读者误以为“释放即合并”是必然结果,忽略了合并条件的存在。
误区“我要找伙伴,直接把地址加(或减)块大小不就行了吗?为什么非要用 XOR?”
正解如果块大小是 2 的幂且地址对齐到块大小,加/减块大小在数值上有时确实能得到伙伴地址,但它只对“当前块位于低半段”或“高半段”的特定情形成立。XOR 运算只需一次位操作,就能同时正确处理上下两半段的情况,且不会产生进位、借位等额外状态。
为何常见:用十进制思维去理解二进制地址对齐,容易把“物理相邻”直接等价成“加减偏移”,忽略了伙伴系统设计的位运算本质。
误区“只要碎片率很低,就说明内存还很充足,可以放心分配大块内存。”
正解碎片率衡量的是空闲内存的“连续性”,而不是空闲内存的“总量”。碎片率为 0 只表示所有空闲页框连成了一片,并不代表空闲页框的总数一定很多;反过来,碎片率很高时,内存总量可能依然充裕,只是无法满足大块连续分配请求。
为何常见:把“连续”和“总量”两个不同维度混为一谈,忽略了物理内存分配对连续性的硬性要求。
面试题
Q1. 在 Buddy 伙伴系统中,order=3 的块包含多少个页框?
- A.4 个页框
- B.8 个页框← 正确答案
- C.16 个页框
- D.32 个页框
答案:B。0 阶是 1 页,1 阶是 2 页,2 阶是 4 页,3 阶就是 2³ = 8 页。就像仓库储物柜按 1、2、4、8 的规格递进,每次翻倍,所以选 8 个页框。
Q2. 在 Buddy 系统中,一块内存的伙伴地址如何计算?
- A.块地址直接加一
- B.块地址减掉块大小
- C.块地址异或块大小← 正确答案
- D.块地址与二相除
答案:C。伙伴地址 = 块地址 XOR 块大小。因为块大小只有第 k 位为 1,异或恰好在一次位运算内翻转该位,得到同一父块拆出的另一半地址。加减法只能适用于特定半段,不是通用做法。
Q3. x86-64 架构下标准页框大小通常是多少?
- A.1KB
- B.2KB
- C.4KB← 正确答案
- D.8KB
答案:C。x86-64 下的标准页框大小是 4KB,它是 Buddy 系统分配内存时的最小计量单位。虽然 ARM64 等架构支持 64KB 大页,但 x86-64 默认标准页框仍然是 4KB。
Q4. 外部碎片是指下列哪种情况?
- A.已用块内部有空白
- B.空闲块彼此不连续← 正确答案
- C.页框大小与块不符
- D.内存总容量出现不足
答案:B。外部碎片指空闲块虽然存在,但彼此不连续,无法拼成满足大块请求的连续区域。A 描述的是内部碎片;C 不是标准碎片概念;D 是容量不足,与碎片无关。
Q5. Buddy 伙伴系统的主要优势包括哪些?
- A.伙伴合并操作简单快速← 正确答案
- B.完全避免内部碎片产生
- C.分配与释放复杂度低← 正确答案
- D.任意大小内存都能分配
答案:AC。伙伴系统用 2 的幂块组织空闲内存,合并时通过 XOR 一步找到伙伴,分配释放均摊复杂度接近 O(1),所以 A、C 正确。它按 2 的幂分配,必然产生内部碎片,也无法支持任意粒度,因此 B、D 错误。
Q6. 释放 order=2 的块时,其伙伴块正在被使用,系统会怎样?
- A.等待伙伴释放后再合并
- B.直接将该块加入空闲链表← 正确答案
- C.强制回收伙伴的内存
- D.向更高阶迁移该块
答案:B。伙伴忙时不能合并,内核只能把这个块挂到当前 order 的空闲链表里,等待未来伙伴释放。强制回收会导致数据丢失,等待或迁移都不是伙伴系统的工作方式。
Q7. 下列哪些属于碎片整理的常用手段?
- A.页面迁移与压缩规整← 正确答案
- B.内存规整 compact 机制← 正确答案
- C.无限扩大页框容量
- D.禁用伙伴系统的合并
答案:AB。内存规整(compaction)依赖页面迁移把空闲页聚拢成连续大块,A、B 是标准手段。无限扩大页框容量不现实,禁用合并只会让碎片更严重,C、D 错误。
Q8. Buddy 系统中的“阶数”表示什么?
- A.页框数量的对数← 正确答案
- B.内存总容量大小
- C.伙伴链表的长度
- D.分配请求的次数
答案:A。阶数是页框数量的以 2 为底的对数,0 阶 1 页、1 阶 2 页、2 阶 4 页。它不是内存总量、链表长度,也不是请求次数。
Q9. 若请求 5KB 内存,页框大小为 4KB,则分配器至少需要哪个阶数的块?
- A.0 阶
- B.1 阶← 正确答案
- C.2 阶
- D.3 阶
答案:B。5KB 除以 4KB 得 1.25 页,log₂(1.25)≈0.32,向上取整为 1,所以需要 order=1 的块,即 2 个页框共 8KB,刚好盖住 5KB 请求。
Q10. 关于 Buddy 系统的内部碎片,以下说法正确的是?
- A.分配 5KB 实际给 8KB 会产生浪费← 正确答案
- B.内部碎片是分配块内未用部分← 正确答案
- C.Buddy 系统能完全消除内部碎片
- D.内部碎片可通过合并操作消除
答案:AB。请求 5KB 必须按 2 的幂给 8KB,多出 3KB 就是块内未用的内部碎片,A、B 正确。合并操作解决的是外部碎片,不能消除内部碎片,C、D 错误。