项目源码:https://gitee.com/kkkred/thread-caching-malloc
在计算机程序运行时,内存是最珍贵的资源之一。程序需要动态申请内存(如创建对象、加载文件),并在使用完毕后释放。但内存分配并非简单的「给一块空间」,而是需要解决两个核心问题:
早期的内存管理方式(如「空闲链表」)虽然简单,但面对频繁的分配/释放操作时,效率极低。例如,当程序需要申请一个2KB的内存块时,空闲链表可能需要遍历所有节点才能找到合适的空间,时间复杂度高达O(n)。
1963年,曼彻斯特大学的计算机科学家 K. J. Thurber 在论文中首次提出了「伙伴系统」的原型。其核心思想源于「二分法」:将内存按2的幂次方(2^0, 2^1, 2^2...)分割成大小相等的块,每个块称为一个「伙伴」(Buddy)。这种设计天然解决了碎片问题——因为所有空闲块的大小都是2的幂次,合并时只需将相邻的同大小块合并为更大的块(父节点),形成一棵「伙伴树」。
关键洞察:通过限制内存块大小为2的幂次,伙伴系统将内存分配的复杂度从O(n)降低到O(log n),同时极大减少了外部碎片。
伙伴系统将整个内存区域视为一个大的「根节点」,其大小为2^n(n为整数)。当程序申请内存时,系统会沿着伙伴树的层级向下分割,直到找到大小等于或略大于申请容量的「叶子节点」(即具体的内存块)。
举个例子:假设物理内存总大小为2^10=1024KB(根节点),当程序申请513KB内存时:
数学规律:对于任意大小S的内存申请,系统会找到最小的k,使得2^k ≥ S,然后将内存分割为2^k大小的块。
伙伴系统的分配流程可以用「查找-分割-标记」三步概括:
释放内存时,伙伴系统需要将相邻的同大小「伙伴块」合并为更大的块,以减少碎片。合并规则如下:
反例警示:如果释放的块与兄弟块不连续(中间有其他已用块),则无法合并,只能作为独立块保留在空闲列表中。
伙伴系统的核心是维护不同大小的空闲块列表。通常,系统会为每个可能的块大小(2^0, 2^1...2^n)维护一个空闲链表(Free List)。例如,在Linux内核中,空闲列表用数组表示,数组下标对应块大小的指数(如index=k对应2^k大小的块)。
// 简化的伙伴系统空闲列表结构(C语言示例)
#define MAX_ORDER 10 // 最大块大小为2^10=1024KB
struct free_list {
struct page *head; // 空闲块的链表头
};
struct free_list free_area[MAX_ORDER];
buddy_alloc
)// 申请大小为size的内存块(假设size是2的幂次)
struct page *buddy_alloc(size_t size) {
int order = log2(size); // 计算块大小的指数(如512KB→order=9)
if (order > MAX_ORDER) return NULL; // 超过最大支持大小
// 从对应order的空闲列表查找块
struct free_list *fl = &free_area[order];
if (fl->head != NULL) {
struct page *page = fl->head;
fl->head = page->next; // 从空闲列表移除
return page;
}
// 无可用块,尝试从父节点分割
for (int i = order; i < MAX_ORDER; i++) {
struct free_list *parent_fl = &free_area[i];
if (parent_fl->head != NULL) {
struct page *parent_page = parent_fl->head;
parent_fl->head = parent_page->next; // 分割父节点
// 将父节点拆分为两个子节点(伙伴)
struct page *buddy1 = parent_page + (1 << (i-1));
struct page *buddy2 = parent_page;
// 初始化子节点的next指针
buddy1->next = free_area[i-1].head;
free_area[i-1].head = buddy1;
buddy2->next = free_area[i-1].head;
free_area[i-1].head = buddy2;
// 返回较小的子节点(或任意一个)
return buddy1;
}
}
return NULL; // 内存耗尽
}
buddy_free
)// 释放大小为size的内存块(需传入块的起始地址和order)
void buddy_free(struct page *page, int order) {
struct free_list *fl = &free_area[order];
page->next = fl->head;
fl->head = page; // 加入空闲列表
// 尝试合并兄弟块
while (order < MAX_ORDER - 1) {
int buddy_order = order + 1;
struct page *buddy = get_buddy(page, order); // 获取兄弟块地址
// 检查兄弟块是否空闲且在同一父节点
if (buddy && is_buddy_free(buddy, buddy_order)) {
// 从空闲列表中移除兄弟块
remove_from_freelist(buddy, buddy_order);
// 合并为父节点(order+1)
page = merge_pages(page, buddy, order);
order = buddy_order; // 继续向上合并
} else {
break; // 无法合并,退出循环
}
}
}
get_buddy(page, order)
:计算当前块的兄弟块地址。由于兄弟块在同一父节点中,地址差为2^(order)(例如,order=2时,块大小为4KB,兄弟块地址相差4KB)。is_buddy_free(buddy, order)
:检查兄弟块是否空闲(存在于对应order的空闲列表中)。merge_pages(page1, page2, order)
:将两个同大小的空闲块合并为一个更大的块,并更新父节点的空闲列表。Linux内核的内存管理(如物理页框分配)广泛使用伙伴系统。例如,buddy_alloc
函数用于分配连续的物理页,而zone->free_area
数组则维护不同大小的空闲块列表。这种设计保证了内核在处理进程内存申请时的高效性。
Java虚拟机(JVM)的堆内存管理中,虽然主要使用分代收集(Young/Old Generation),但在某些场景(如大对象直接分配)仍会借鉴伙伴系统的思想。例如,G1收集器的「Humongous Region」设计,本质上是对伙伴系统的改进,用于高效管理大对象。
在嵌入式设备(如单片机)中,内存资源极其有限,伙伴系统因其低开销和高可靠性成为首选。例如,STM32微控制器的内存分配库(如malloc
实现)常基于伙伴系统,确保在几百KB内存中高效运行。
尽管伙伴系统经典,但现代技术对其进行了多维度改进:
Linux内核中,伙伴系统负责分配大内存块(≥4KB),而小对象(如进程描述符、文件句柄)则由Slab分配器管理。Slab分配器为每种对象预分配一组内存块(称为Slab),进一步减少分配延迟。
传统伙伴系统强制块大小为2的幂次,但现代变种(如Windows的「低碎片堆」)允许块大小为任意值,通过更复杂的合并策略(如允许不同大小的伙伴合并)降低内部碎片。
在虚拟内存环境下,伙伴系统可以管理虚拟地址空间(而非物理内存),通过页表映射将虚拟块映射到物理页框。这种设计允许程序使用比物理内存更大的地址空间,而碎片问题由操作系统通过换页机制解决。
从1963年K. J. Thurber的论文到今天,伙伴系统已在内存管理领域深耕超过半个世纪。它用「二分法」的数学之美,解决了动态内存分配的核心矛盾;用「合并术」的巧妙设计,平衡了效率与碎片的矛盾。尽管面临Slab分配器、B+树索引等新技术的挑战,伙伴系统依然是操作系统、嵌入式开发中最可靠的「内存管家」。
下一次当你编写程序并调用malloc
或new
时,不妨想想:在内存的深处,可能正有一个伙伴系统在默默工作,用二进制的智慧,为你的代码分配着最合适的「拼图块」。