图 1:本文封面,两种碎片的位置对比——一个在块内,一个在块间。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「讲讲内部碎片、外部碎片,如何解决」是操作系统面试的常驻题,真正考的不是两个名词背诵,而是你能不能说清各自的成因,以及分页、伙伴系统、slab 这些机制分别在对付哪一种。
一句话结论:内部碎片是分出去的块里用不满的部分,外部碎片是块与块之间凑不出连续大块的空洞;前者看块内利用率,后者看最大连续空闲。
内部碎片:分配粒度不小于需求。
只要分配器按固定粒度给空间,粒度减需求的部分就浪费在块内部。典型来源三个:分页按整页分配,进程最后一页用不满;slab 这类定长对象缓存,对象比槽位小;还有内存对齐的填充字节。教科书上的经典结论是:分页系统中每个进程平均浪费半页(最后一页),页越小内部碎片越少,但页表越大——这是个权衡。
外部碎片:变长分配与释放交错,把空闲区打散。
空闲内存的总量足够,但被已用块隔成一个个小洞,没有任何一个洞装得下新的连续分配。它只在「需要连续物理空间」的分配方式里存在——分段、变长分区、堆上按字节分配都是高发区。
两种碎片不是嘴上说说的,用一个模拟器直接演示(Clang 17,g++ -std=c++17 frag.cpp && ./a.out)。外部碎片部分用首次适配算法管理一段 540 单元的条带:先分配 6 块占满,再释放第 2、4、6 块制造三个互不相邻的空洞;内部碎片部分按 4 KB 整页给请求分配,统计页内闲置:
// 内存碎片演示:外部碎片用首次适配模拟器,内部碎片用分页统计
#include <cstdio>
#include <vector>
using namespace std;
struct Block { int start, size; bool used; };
vector<Block> mem = {{0, 540, false}};
int firstFit(int size) { // 首次适配:找第一个装得下的空闲块
for (size_t i = 0; i < mem.size(); i++) {
if (!mem[i].used && mem[i].size >= size) {
Block b = mem[i];
mem[i] = {b.start, size, true};
if (b.size > size) mem.insert(mem.begin() + i + 1, {b.start + size, b.size - size, false});
return b.start;
}
}
return -1; // 没有任何连续空闲块装得下
}
void freeAt(int start) {
for (auto &b : mem) if (b.start == start) b.used = false;
for (size_t i = 0; i + 1 < mem.size(); ) { // 合并相邻空闲块
if (!mem[i].used && !mem[i+1].used) {
mem[i].size += mem[i+1].size;
mem.erase(mem.begin() + i + 1);
} else i++;
}
}
int main() {
int a = firstFit(140), b = firstFit(50), c = firstFit(160);
int d = firstFit(40), e = firstFit(90), f = firstFit(60); // 六块把 540 单元条带占满
printf("[外部碎片] 六块分配起点: %d %d %d %d %d %d(条带占满)\n", a, b, c, d, e, f);
freeAt(b); freeAt(d); freeAt(f); // 释放第 2、4、6 块:三个互不相邻的空洞
int freeTotal = 0, largest = 0;
for (auto &blk : mem) if (!blk.used) { freeTotal += blk.size; largest = max(largest, blk.size); }
printf("[外部碎片] 空闲总量 %d 单元,最大连续空闲 %d 单元\n", freeTotal, largest);
printf("[外部碎片] 申请 100 连续单元: %s\n",
firstFit(100) >= 0 ? "成功" : "失败(总量够,但凑不出连续大块)");
// 内部碎片:请求按 4 KB 整页向上取整,累加 页数*4096 - 请求大小
const int PAGE = 4096;
vector<int> reqs = {3000, 5000, 8200, 100, 4096, 7000};
long waste = 0, alloc = 0;
for (int r : reqs) {
int pages = (r + PAGE - 1) / PAGE;
waste += pages * PAGE - r; alloc += pages * PAGE;
}
printf("[内部碎片] 6 个请求按整页分配,共占 %ld 字节,页内闲置 %ld 字节(%.1f%%)\n",
alloc, waste, 100.0 * waste / alloc);
long wsum = 0; unsigned seed = 42; // 10 万次随机请求,验证「末页平均浪费约半页」
for (int i = 0; i < 100000; i++) {
seed = seed * 1103515245 + 12345;
wsum += (PAGE - (int)(seed % PAGE)) % PAGE;
}
printf("[内部碎片] 10 万次随机请求末页平均闲置 %.0f 字节(页大小 %d,约半页)\n",
(double)wsum / 100000, PAGE);
}
实测输出:
[外部碎片] 六块分配起点: 0 140 190 350 390 480(条带占满)
[外部碎片] 空闲总量 150 单元,最大连续空闲 60 单元
[外部碎片] 申请 100 连续单元: 失败(总量够,但凑不出连续大块)
[内部碎片] 6 个请求按整页分配,共占 40960 字节,页内闲置 13564 字节(33.1%)
[内部碎片] 10 万次随机请求末页平均闲置 2048 字节(页大小 4096,约半页)
外部碎片的反直觉处看得很清楚:空闲 150、只申请 100,照样失败,因为三个洞 50、40、60 互不相邻。内部碎片部分,10 万次随机请求的末页平均闲置落在 2048 字节——恰好半页,教科书结论被直接复现。
机制对照:每个方案治一种碎片。
分页治外部碎片。虚拟地址离散映射到物理页框,进程看到的连续地址不再需要连续的物理内存,外部碎片直接消灭;代价是把碎片挪进每个进程的最后一页,从外部变成内部。伙伴系统治外部碎片。空闲块按 2 的 k 次幂管理,分配时分裂、释放时找「伙伴」合并,小洞有机会重新拼成大块,Linux 内核的页级分配就用它。slab 治内部碎片。内核里频繁分配释放的对象(如进程描述符)大小固定,为同型对象建定长缓存,对象恰好填满槽位,块内浪费趋近于零,还顺带省了重复初始化的开销。紧凑拼接(compaction)也能治外部碎片——搬运已用块把洞合并,但要能移动数据且代价高,一般作为兜底手段。分段和分页的对照是经典考点:分段是变长分配所以有外部碎片,分页是定长分配所以只剩内部碎片。
图 2:两种碎片的成因对照与四种机制的治理分工。
判据与答题结构。
判断手头的问题是哪种碎片,看两个指标:块内利用率低是内部碎片,最大连续空闲不足是外部碎片。面试答题按「两个定义加成因 → 一个演示或例子 → 四个机制各治哪种 → 分段分页对照收尾」推进,答到「分页消灭外部碎片的代价是把碎片挪进最后一页」这层,就说明你理解的是设计权衡而不是名词。你在实际项目里遇到过碎片问题吗?是堆分配器的,还是页缓存层面的?欢迎聊聊。