想象一座运营多年的露天停车场:车进车出,车位渐渐变得零散——东空三格、西空两格,总数虽不少,可当一支 12 辆的大巴团队想停进来时,却怎么也找不到连续的空位。车位就是内存的“页框”,车辆就是“页面数据”,大巴则是操作系统里那些需要大块连续内存的“高阶分配请求”。为了治好这种“空位虽多、却不顶用”的毛病,停车场管理员请来了挪车大师:把能动的车集中挪到一侧,腾出大片连续空位,这套功夫就是“内存规整”(compaction)。挪车时要分清哪些车能挪、哪些“承重墙”碰不得,还要两位工人从两头相向扫描,用一张“碎片指数”仪表盘判断何时动手。真到车位全满时,挪车大师还得和“拖车大队”(kswapd)搭档。这篇文章就用停车场的日常,带你完整看懂操作系统内存碎片整理的门道。
对 AI 从业者而言,掌握内存碎片整理意味着你能理解大模型加载为何失败、推理服务为何越跑越慢,并学会通过配置内核参数和监控碎片指数,让模型服务长期稳定运行。
可视化演示
展示内存碎片化可视化、Compaction迁移扫描器协同工作过程、碎片指数仪表与compaction/kswapd双路径协同策略。
本节配有交互动效(进入视口后自动加载,约 3KB)。
内存碎片类型可视化 Free:空闲页框分散分布 Used:已分配不可迁移页框 Movable:可迁移页面 external_frag = 1 - (max_free_order / total_free) 外部碎片导致高阶(order>0)连续分配失败,伙伴系统无法满足。
碎片类型与成因
观察一座运营三年的露天停车场,你会发现两种让人头疼的浪费。第一种:一辆小轿车大摇大摆地占着两个标准车位,因为管理员只按“标准位”出租,车子只好多占一个——这叫内部浪费。第二种:车位东空一格、西空三格,加起来明明有三十个空位,一支大巴团却因为找不到 12 个连续车位只能走人——这叫外部浪费。这两种浪费,正是内存世界里“内部碎片”与“外部碎片”的翻版。
操作系统把物理内存切成固定大小的页框(通常 4KB),按页分配给进程。进程申请 13KB 内存时,系统分配 4 个页框(16KB),多出的 3KB 再也无法使用,这就是内部碎片。而进程反复加载、退出、扩容后,空闲页框像停车场的空位一样被“稀释”到各处,这就是外部碎片。外部碎片的本质是:空闲内存总量充足,但最大的连续空闲块太小,无法满足高阶分配。
我们可以用一个公式量化碎片化程度:
想象停车场明明还有 30 个空位,最长一排却只有 3 个连位,大巴队就是进不来。内部碎片是“车位太宽、车太瘦”的浪费,外部碎片则是散沙般的空位——它们共同构成了内存长期运行的“慢性病”,而治病的良方,就是后面登场的挪车大师。
- 统计总空闲页数 F_total遍历所有页框,统计处于空闲状态的页框总数,相当于停车场里所有空位的数量。
- 求最大连续空闲块 L_max扫描页框序列,找出最长连续空闲段的长度,相当于停车场里最长的一排连续空位。
- 计算碎片化程度用 1 减去连续系数。比值越接近 1,说明空位越零散;越接近 0,说明空位越集中。
伙伴系统与分配瓶颈
停车场管理员老练地定下一套铁规矩:所有区域只按 1、2、4、8、16 这样的“翻倍”方式划分。你要 3 个车位?给你 4 个;要 6 个?给你 8 个。这套翻倍分配法,就是操作系统里的“伙伴系统”(Buddy System)。规则简单高效,也让管理员的账本一目了然。
Linux 内核的伙伴系统把空闲页框按 order(阶数)挂进不同链表:order 0 是 1 个页框,order n 是 \(2^n\) 个连续页框的块。分配 \(2^n\) 页时,若对应链表为空,就向更高阶拆一半下来;释放时,若相邻的“伙伴”块也空闲,则合并回更高阶。这套机制保证了分配速度,却埋下了隐患:长期运行后,大量小块被分配又释放,大块被拆散很难再合并回来,高阶链表逐渐枯竭。
伙伴系统的翻倍划线法简单高效,却挡不住时间的侵蚀。当一个 8 辆连排的大巴位被反复拆成单人位后,管理员再想划一个 16 位大区就难了。好在,他还有第二件法宝——挪车。
- 阶数 n 的范围伙伴系统用阶数表示块大小级别,Linux 中 order 从 0 到 MAX_ORDER-1 共 11 级。
- 页大小 PAGE_SIZE系统一页通常 4KB,是内存分配的最小粒度,相当于一个标准车位。
- 块大小 S(n)order n 的空闲块包含 \(2^n\) 个连续页框,这是伙伴系统分配与合并的基础。
内存规整核心机制
大巴队又要来了,管理员决定启动“挪车方案”:把散落在角落的小轿车一辆辆挪到指定区域集中停放,腾出另一端大片连续空位。挪车前先给每辆车贴标签:哪些车能挪(私家车),哪些是不能碰的配电房围挡。这套贴标签、集中挪车的流程,就是 Linux 的内存规整(compaction)机制。
内核将物理内存按 pageblock(通常 128 个页框)划分迁移类型:MIGRATE_MOVABLE 用于用户态可移动页(匿名页、文件缓存),MIGRATE_UNMOVABLE 用于内核代码、页表等不可移动页,MIGRATE_RECLAIMABLE 用于可回收但不宜移动的内核页。执行规整时,内核选好迁移源(可移动页所在的 pageblock)和迁移目标(空闲页所在的 pageblock),逐个把源页内容拷贝到目标页,并改写页表映射和反向映射。页面迁移不只是复制数据,还要更新所有引用它的页表项,成本不低。
挪车方案的精髓在于“分清主次”:焊死的围挡绝不动,能动的车全归拢。迁移成本摆在那里,所以管理员从不轻易出手,而是等碎片指数报警,或者大巴真到门口时才动手。
- 被迁移页数 N_migrated迁移过程中挪动了多少个可移动页面,这是挪车付出的“人力成本”。
- 腾出连续块 N_free_block整理后能形成多大的连续空闲块,这是挪车的“收益”。
- 计算迁移效率收益除以成本。比值越大,说明这次挪车越值;若效率太低,宁可不挪。
双扫描器协作
挪车可不是乱搬一气。两位工人从停车场两端出发:A 师傅从东门往西走,专门找贴了“可挪”标签的小轿车;B 师傅从西门往东走,专门找空车位。二人边走边配合——A 找到一辆可挪的车,就把它塞进 B 刚发现的空位。两人越走越近,直到中间碰头,整理结束。
Linux 的 compaction 正是如此。它有两个扫描器:正向扫描器(migration scanner)从区域头部向尾部推进,寻找可迁移页面;反向扫描器(free scanner)从区域尾部向头部推进,寻找空闲页框。每找到一对“源页 + 空闲页”,就把源页迁到空闲位置。两个扫描器各自维护 pfn(页框号)索引:cc->migrate_pfn 和 cc->free_pfn。每轮扫描后把 pfn 缓存在 zone 的 compact_cached_migrate_pfn 里,下次直接从这里继续,避免重复扫描。
两位师傅碰头的那条线,就是停车场新的“秩序线”——一侧是齐整的车阵,另一侧是干净的空地。系统每次扫描都从缓存的 pfn 继续,绝不倒车,把挪车效率拉满。
- 正向扫描器位置 P_migrate迁移扫描器沿 pfn 增大的方向推进,指向下一个待检查页框。
- 反向扫描器位置 P_free空闲扫描器沿 pfn 减小的方向推进,指向下一个待检查页框。
- 相遇停止条件当正向位置大于等于反向位置时,两端扫描区域重叠,说明中间已无“可移页 + 空闲页”配对了。
碎片指数评估
管理员每天上班第一件事,就是看墙上的“停车焦虑指数”仪表盘:指数 0 代表所有空位连成一片,岁月静好;指数 1 代表空位数虽多,但全是碎渣空位,大巴来了只能干瞪眼。这个仪表盘,就是碎片指数(fragmentation index)。
碎片指数衡量的是当前空闲内存中,有多大比例真正能满足分配请求。它由内核在分配路径上动态计算,用来判断此刻执行 compaction 是否值得。计算思路很简单:统计总空闲页数,再统计其中“可组成目标阶数连续块”的页数。若大部分空闲页分散在无法组成大块的位置,指数就接近 1。
碎片指数为系统提供了“动手的底气”:指数高到一定程度,管理员才舍得派挪车小队;指数低时,随便来辆车都能找到位,何必折腾?指数就是一把尺子,量出碎片病到了几分。
- 总空闲页数 P_free区域内所有空闲页框的总数,相当于停车场空位总数。
- 可用空闲页数 P_usable这些空闲页中有多少能被当前目标阶数实际使用,相当于能组成连排空位的数量。
- 指数判定指数接近 1,说明绝大多数空位是分散的,碎片严重;接近 0,说明空位集中,直接分配即可满足。
规整触发时机
挪车小队不能天天上街——每次折腾都有成本。那什么时候动手?管理员定下两条规矩:一是大巴已经堵在门口,死活找不到连位,只能紧急挪车;二是停车场快满员了,提前派拖车队清理僵尸车,顺手把车位理一遍,防患于未然。
内核同样有两条触发路径。直接规整(direct compaction):分配进程在 alloc_pages 里寻求高阶连续块失败后,同步执行 compaction,进程阻塞等待整理结果。异步规整:kswapd 发现内存水位紧张时,唤醒后台线程 kcompactd 执行规整,进程不阻塞。直接规整发生在分配请求带 GFP_COMPACT 且 order 大于 0 时,快速路径找块失败后进入慢速路径,调用 __alloc_pages_direct_compact 救火。
紧急挪车和提前整理,一个治标一个治本。内核的分寸感在于:平时让 kcompactd 在后台静静巡检,危机时刻直接规整当场救火,分工明确,绝不浪费。
- 高阶请求 order > 0只有需要 2 页及以上连续页框的分配,才值得做规整,单页分配不用凑热闹。
- 无空闲块伙伴系统当前找不到满足请求阶数的空闲块,相当于停车场找不到连排车位。
- 触发直接规整两个条件同时满足,分配进程进入同步整理流程,整理完成后重新尝试分配。
与 kswapd 协同
如果停车场已经满到连站的地方都没有,光靠挪车就回天乏术了——这时候得请“拖车大队”出场。拖车大队(kswapd)专门把停了好几个月没动的僵尸车拖走,腾出空位;挪车小队(kcompactd)则负责把剩下的车排整齐。两人一前一后,停车场才真正起死回生。
kswapd 是 Linux 内核的页面回收守护线程,当内存水位低于 low 阈值时被唤醒,扫描 LRU 列表回收不活跃页。compaction 与回收有微妙的分工:回收产生的是“可用内存的数量”,而规整解决的是“连续内存的质量”。数量不足时靠回收,连续性不足时靠规整。
在内核的慢速分配路径中,两者协同作战:kswapd 负责把内存压到 low 水位以上,kcompactd 负责把剩余空闲页做成大块。特别地,当无法迁移足够的可移动页面时,内核还会尝试回收不可移动页面之间的可回收页,把“硬骨头”也啃下来。
拖车队和挪车队从不内讧:回收负责“腾数量”,规整负责“造质量”。当停车场终于迎来一辆大巴时,正是这两位师傅的默契配合,才让老旧满员的停车场依然容得下大块头。
- 低水位阈值每个内存 zone 维护 low/high 水位线,空闲页低于 low 意味着内存随时可能耗尽。
- 唤醒 kswapd回收线程被唤醒,开始扫描 LRU 列表,把冷页面写出到磁盘或直接丢弃。
- 同步唤醒 kcompactd回收的同时启动后台规整,把释放出的页框合并成大块连续区。
AI 应用场景
云上 AI 推理平台的模型加载优化
阶段:模型部署与服务(推理服务生命周期管理)
解决的问题:大模型(如 Llama 3 70B)需要 GB 级大页连续物理内存。业务 Pod 频繁创建销毁会导致物理内存碎片化,模型加载时 HugeTLB 分配失败、启动变慢甚至 OOM。内存规整能迁移可移动页,为模型腾出连续大块内存,就像挪车大师为大巴清出连排车位。
具体案例:某 MLOps 平台在 GPU 服务器上运行 24 小时推理服务,每 30 分钟滚动发布一个新版本模型,版本替换反复申请和释放近 10GB 内存。几周后模型加载时间从 2 分钟劣化到 15 分钟,并频繁报"cannot allocate hugepage"。运维人员通过监控碎片指数并配置内核在业务低峰期主动触发 compaction 后,加载时间恢复稳定,错误清零。
大数据框架的 Executor 长时稳定运行
阶段:数据预处理与特征工程(分布式计算框架的堆外内存管理)
解决的问题:Spark/Flink 的 executor 堆外内存频繁分配大块 Direct Buffer,JVM 与操作系统之间的物理内存碎片会导致 Direct Memory 分配失败。理解碎片整理机制,可帮助正确设置 JVM 参数和内核 vm.compact_memory 策略。
具体案例:某数据团队在 200GB 内存节点上运行 Spark 批量特征工程,频繁出现 "Direct buffer memory" 异常。排查发现系统伙伴系统 order-3 以上分配成功率持续走低,碎片指数长驻 0.8。通过修改内核参数、在业务低峰期主动触发内存规整,任务成功率恢复稳定,Batch 任务耗时下降约 18%。
Python 演示
"""模拟内存碎片化与规整(compaction)过程"""import random class Memory: def __init__(self, size=64, page_size=4): self.size = size # 总页框数 self.pages = [None] * size # None=空闲, 'mov'=可移动, 'fix'=不可移动 self.page_size = page_size def alloc(self, cnt, movable=True): """尝试分配 cnt 个连续页框, 成功返回起始索引""" typ = 'mov' if movable else 'fix' for start in range(self.size - cnt + 1): if all(p is None for p in self.pages[start:start + cnt]): for i in range(start, start + cnt): self.pages[i] = typ return start return -1 def free(self, start, cnt): for i in range(start, start + cnt): self.pages[i] = None def max_free_run(self): best = run = 0 for p in self.pages: run = run + 1 if p is None else 0 best = max(best, run) return best def frag_index(self, need): """碎片指数: 1 - 能满足 need 的空闲比例""" free_total = sum(1 for p in self.pages if p is None) if free_total == 0: return 0.0 usable = self.max_free_run() >= need return 1.0 - (need / free_total if usable else 0.0) def compact(self): """规整: 将所有可移动页往前搬, 腾出尾部连续区""" write = 0 for read in range(self.size): if self.pages[read] == 'mov': if write != read: self.pages[write] = self.pages[read] self.pages[read] = None write += 1 def simulate(): mem = Memory(size=64) random.seed(42) for _ in range(30): cnt = random.randint(1, 8) start = mem.alloc(cnt) if start != -1 and random.random() < 0.5: mem.free(start, cnt) print("碎片化后: 最大连续块 =", mem.max_free_run()) print("碎片指数 (need=16) =", round(mem.frag_index(16), 2)) mem.compact() print("规整后: 最大连续块 =", mem.max_free_run()) print("碎片指数 (need=16) =", round(mem.frag_index(16), 2)) if __name__ == "__main__": simulate()
self.pages = [None] * size用数组模拟物理内存页框,None 表示空闲车位,'mov' 和 'fix' 对应可移动与不可移动页面。if all(p is None for p in self.pages[start:start + cnt])检查从 start 开始是否有 cnt 个连续空闲页框,模拟伙伴系统的连续分配约束。return 1.0 - (need / free_total if usable else 0.0)计算碎片指数:最大连续块能满足 need 时按比例折算,否则视为完全碎片化。if self.pages[read] == 'mov': ... write += 1双指针原地规整:把所有可移动页搬到数组前部,腾出尾部连续区,等价于 compaction 的页面迁移。random.seed(42)固定随机种子,保证每次运行结果可复现,方便对比规整前后指标。
常见误区
误区进程看到的是虚拟地址连续的空间,物理内存有没有碎片无所谓吧?反正应用层根本感知不到物理地址。
正解虚拟地址连续确实给进程提供了“连续假象”,但物理页框不连续时,单页分配没问题,一旦遇到大页(HugeTLB)、DMA 缓冲、设备驱动等需要物理连续内存的场景就会分配失败。好比外卖地址写得再漂亮,车还是得停在真实、连续的物理车位上。
为何常见:现代 CPU 的 MMU 把物理地址隐藏得太好,应用开发者几乎感受不到物理内存布局,容易误以为碎片无足轻重。
误区快没内存了就回收一下、整理一下,不都是一回事吗?反正都是让系统有更多可用内存。
正解回收(kswapd)是把不再使用的页面释放掉,减少已用空间;而规整(compaction)是移动页面位置,不减少已用空间,只改变空闲空间的布局。前者多“腾数量”,后者多“造质量”。移植到停车场语境:回收是拖走僵尸车,规整是给剩下的车重新排位。
为何常见:两者常在同一条分配慢路径中先后执行,都能缓解内存压力,表面上难以区分,实则目标不同。
误区碎片指数都 0.9 了,说明碎片很严重,赶紧把整块内存都规整一遍!
正解碎片指数只衡量碎片严重程度,不代表规整收益。若可移动页极少,或者空闲页集中在低阶块,迁移成本可能远大于收益。内核会综合迁移成本、pageblock 迁移类型和空闲块分布,再决定是否值得整理。指数是温度计,不是处方——温度高不等于一定要做手术。
为何常见:把“度量指标”和“决策指标”混为一谈,是新手常犯的直觉错误。
误区整理内存嘛,把页面全部挪到一边排整齐不就好了?数据搬过去再改一下地址就行。
正解内核代码段、页表、中断描述符、某些驱动分配的 DMA 缓冲区都不可移动。这些页面一旦移动,物理地址变化会直接导致内核崩溃。所以规整只迁移 MIGRATE_MOVABLE 页面,还要小心处理页表映射与反向映射。好比挪车时配电房是绝对碰不得的——那是整座停车场的命脉。
为何常见:从应用层看所有内存都一样,但内核中有大量与物理地址强绑定的页面,普通开发者很难接触到这一层细节。
面试题
Q1. 关于外部碎片,下列说法正确的是?
- A.是分配粒度向上取整造成的空间浪费
- B.是空闲页框分散导致无法分配连续大块← 正确答案
- C.只发生在虚拟内存中
- D.可以通过增大页框尺寸彻底消除
答案:B。外部碎片是空闲内存总量充足但分散成小段,无法满足连续大块分配。A 描述的是内部碎片,C 错在外部碎片发生在物理内存,D 错在增大页框只会加剧内部碎片、无法消除外部碎片。
Q2. 伙伴系统按什么粒度管理空闲页框?
- A.按字节管理
- B.按2的幂次页框块← 正确答案
- C.按固定128KB块
- D.按段管理
答案:B。伙伴系统把空闲页框按 order 分组,order n 对应 \(2^n\) 个连续页框。A 是分配器层面的粒度,C 不对应伙伴系统约定,D 是分段机制的做法。
Q3. 内存规整(compaction)的核心动作是什么?
- A.将不活跃页面换出到磁盘
- B.将可移动页面迁移到另一侧腾出连续空闲区← 正确答案
- C.合并两个相邻的空闲块
- D.把进程虚拟地址映射到新页
答案:B。compaction 是把可移动页面集中迁移,改变空闲页布局而非减少已用内存。A 是回收,C 是伙伴系统释放时的合并,D 只是迁移过程中的一个环节而非核心目标。
Q4. 以下哪些页面在 compaction 中通常不可移动?
- A.用户态匿名页
- B.内核代码段← 正确答案
- C.页表页← 正确答案
- D.文件缓存页
答案:BC。内核代码段和页表页都与物理地址强绑定,移动会导致内核崩溃,属于 MIGRATE_UNMOVABLE。用户态匿名页和文件缓存页属于可移动页面,正是 compaction 的主要迁移对象。
Q5. 关于双扫描器,下列说法正确的有?
- A.正向扫描器从区域头部向尾部寻找可迁移页面← 正确答案
- B.反向扫描器从区域尾部向头部寻找空闲页框← 正确答案
- C.扫描器相遇时 compaction 停止← 正确答案
- D.只有正向扫描器需要更新缓存
答案:ABC。正向扫描器找可迁移页、反向扫描器找空闲页,二者相遇即停止,对应 A、B、C。D 错误:两个扫描器的 pfn 都会缓存在 zone 的 compact_cached 字段中,下次扫描从缓存继续。
Q6. 碎片指数接近 1 意味着什么?
- A.空闲内存充足且连续
- B.空闲内存总量很少
- C.空闲页分散难以满足连续分配← 正确答案
- D.系统即将宕机
答案:C。碎片指数 \(I_{frag}=1-P_{usable}/P_{free}\) 接近 1 说明绝大多数空闲页无法被目标阶数使用,即空位零散。B 是内存总量不足,A 是指数接近 0 的情形,D 过于绝对。
Q7. kswapd 的主要职责是什么?
- A.执行页面迁移
- B.在内存低于水位时回收页面← 正确答案
- C.维护伙伴系统链表
- D.分配页框给进程
答案:B。kswapd 是内核回收守护线程,空闲内存低于 low 水位时被唤醒,扫描 LRU 列表回收不活跃页。A 是 kcompactd 或 compaction 的职责,C 是伙伴系统管理函数,D 是页面分配器的职责。
Q8. 直接规整与异步规整的主要区别是什么?
- A.直接规整不需要迁移页面
- B.直接规整在分配路径中同步阻塞,异步规整由后台线程执行← 正确答案
- C.异步规整不会更新页表
- D.两者没有区别
答案:B。直接规整发生在分配失败后,进程阻塞等待迁移完成;异步规整由 kcompactd 后台执行,不阻塞任何进程。两者都要迁移页面和更新页表,A、C、D 均错误。
Q9. 内存碎片化可能导致哪些后果?
- A.大页(HugeTLB)分配失败← 正确答案
- B.DMA缓冲区分配失败← 正确答案
- C.单页分配数量减少
- D.高阶伙伴块请求失败率上升← 正确答案
答案:ABD。碎片化直接影响需要物理连续内存的场景,如大页、DMA,并推高高阶伙伴块请求失败率。但单页分配只要有任意空闲页就能满足,数量不受碎片影响,故 C 错误。
Q10. 某系统碎片指数高达 0.9,但执行 compaction 后收益仍然很小,最可能的原因是什么?
- A.空闲页过多
- B.可移动页面占比太低,迁移成本高于收益← 正确答案
- C.伙伴系统阶数太高
- D.扫描器方向错误
答案:B。碎片指数只衡量碎片严重程度,不衡量规整收益。若可移动页占比很低,则能迁移的页极少,无论怎么挪都腾不出大块连续区,迁移成本反而白费。A 不直接导致收益小,C 与规整收益无关,D 在内核实现中不会发生。