相比于栈内存而言,堆这片内存的管理模式更为复杂,因为程序可能随时发出请求,并且申请一段内存,申请内存的大小不固定,释放的时间也不确定。栈在面向过程的程序设计中远远不够,因为栈上的数据在函数返回时就会被释放掉,所以无法将数据传递至函数外部,而类似于全局变量或模块间共享对象,这是要在编程序编译阶段就要存在的,以及一些规模超大的数据对象,这些数据存在栈上显然是不合理的。
针对程序内部最为常见的动态分配内存的情况,常用malloc
申请一块内存块:
char *p = (char*) malloc(1000);
malloc空间分配是怎么实现的?一种做法是将进程内内存申请直接下移交给kernel,因为系统内核本来就管理着“虚拟内存—实际内存”映射表和内存占用调度表,现在交给内核内存申请的任务也合情合理,但是user-thread切换到kernel thread性能开销是很大的,直接下移到kernel管理内存申请,对程序性能影响较大。另一种比较好的做法是运行库批发零售内存块,程序直接向操作系统申请一块适当大小的堆空间,然后在user-thread层进行管理调度,管理堆空间的一般是程序的runtime运行库。Runtime运行库向OS批发一块较大的堆空间,然后零售给程序用,这便引出了堆的管理和分配算法。
Linux系统有两种方式可以创建堆
1.
int brk(void * end_data_segment)
brk()
是系统调用,实际是设置进程数据段的结束地址,将数据段的结束地址向高地址移动,那么扩大的那部分空间便可以拿来作为堆空间使用;
2.
mmap()
向操作系统申请一段虚拟地址空间(一般是用来映射到某个文件),当不用这段空间来映射到某个文件时,这块空间则称为匿名空间,可以用来作为堆空间
void *mmap( void* start, size_t length, int prot, int flags, int fd, off_t offset);
参数解释:前两个参数用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0,那么
linux
系统会自动挑选合适的起始地址,
prot/flags
这两个参数用于设置申请的空间的权限(可读,可写,可执行)以及映射类型(文件映射、匿名空间),最后两个参数用于文件映射时指定文件描述和文件偏移。
Linux下的glibc运行库中的malloc
函数是这样处理用户的空间请求的:<128 KB的请求,会在现有的堆空间中按照堆分配算法为它分配一块空间并返回;>128 KB的请求,使用mmap()
函数为它分配一块匿名空间,如下
void *malloc(size_t nbytes)
{
void* ret = mmap(0, nbytes, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, 0, 0);
if (ret == MAP_FAILED)
return 0;
return ret;
}
上述只是演示,mmap()
函数是系统虚拟空间申请函数,它申请的空间起始地址和大小都必须是系统页的大小的整数倍(物理最小单位,至于映射那是可以再处理的),所以如果对于字节数很小的请求也使用mmap
的话,容易造成浪费。
Linux系统并无进程和线程的区分,统一将其看作任务,故而Linux下的程序虚拟空间VM看起来要整齐不少,而Windows出于支持多线程的小粒度管理视角,一个进程可能有多个线程,每个线程的栈都是独立的,故而Windows下的程序VM有多处栈,每个线程默认的栈大小都是1MB,在线程启动时,系统会为它在进程的虚拟空间中分配对应的空间作为该线程的专属栈,栈的大小由创建线程时
CreateThread
参数指定。这些零碎的栈一旦分配之后,就已经将Windows下的进程空间弄得零碎不堪,再需要申请堆空间,得靠
VirtualAlloc
系统函数向系统批发后(4KB整数倍起批),然后再由
user-thread
层,一般是运行库集中分配给程序,这个算法位于堆管理器
Heap Manager
。它提供了一系列API用于管理堆
1.HeapCreate() 创建一个堆
2.HeapAlloc() 在堆中分配内存
3.HeapFree() 释放已经分配的内存
4.HeapDestroy() 摧毁一个堆
事实上,运行库提供的malloc
函数便是对于Heapxxx
系列函数的封装。当然也正是因为Windows系统的折中多线程栈插入导致的Windows下的一次性最大堆空间为1.5G左右,小于Linux下的一次性最大可分配堆空间 3G多的空间。
Fig.1 Windows下进程虚拟空间分布(摘自《程序员的自我修养》)
Windows堆管理存放在两个位置,一个NTDLL.DLL
,负责Windows子系统DLL与Windows内核之间的接口,所有用户程序、运行时库和子系统的堆分配都是使用这部分代码,在Windows内核Ntoskrnl.exe
还存在一份类似的堆管理器,内核堆空间分配和用户堆不是同一个,Windows内核、内核组件和驱动程序使用的堆都是使用内核堆,内核堆接口都是RtlHeap
开头。
1. 空闲链表法
前面说过进程
malloc
通过系统调用
brk()
,
mmap()
向操作系统批发了一大堆内存,现在如何管理这一大堆内存空间,零售给程序需求的算法便是堆分配算法。(操作系统内存的页映射机制已经做了一层从物理离散到虚拟连续的封装)
而在虚拟空间中连续的堆空间如何零售给不同的程序需求,显然需要二次封装。首先我们需要一个数据结构来登记堆空间里所有的空闲空间,这样才能知道程序请求空间的时候分配给它哪一块内存,这样的结构有很多种,这里介绍最简单的—-空闲链表
//堆的实现
/*在遵循Mini CRT的原则下,我们将Mini CRT堆的实现归纳为以下几条
1.实现一个以空闲链表算法为基础的堆空间分配算法;
2.为了简单起见,堆空间大小固定为32MB,初始化后空间不再扩展或缩小;
3.在Windows平台下不适用HeapAlloc等堆分配算法,采用VirtualAlloc 向系统直接申请32MB空间,由我们自己的堆分配算法实现malloc
4.在Linux平台下,使用brk将数据段结束地址向后调整32MB,将这块空间作为堆空间
*/
/*
brk系统调用可以设置进程的数据段.data边界,而sbrk可以移动进程的数据段边界,显然如果将数据段边界后移,就相当于分配了一定量的内存。但是这段内存初始只是分配了虚拟空间,这些空间的申请一开始是不会提交的(即不会分配物理页面),当进程师徒访问一个地址的时候,操作系统会检测到页缺少异常,从而会为被访问的地址所在的页分配物理页面。
故而这种被动的物理分配,又被称为按践踏分配,即不打不动。
*/
#include "minicrt.h"
typedef struct _heap_header
{
enum{
HEAP_BLOCK_FREE = 0xABABABAB, //空闲块的魔数
HEAP_BLOCK_USED = 0xCDCDCDCD, //占用快的魔数
}type;
unsigned size; //块的尺寸包括块的信息头
struct _heap_header* next;
struct _heap_header* prev;
}heap_header;
#define ADDR_ADD(a,o) ( ((char*)a ) + o)
#define HEADER_SIZE (sizeof(heap_header))
static heap_header* list_head = NULL;
void free(void* ptr)
{
heap_header* header = (heap_header*) ADDR_ADD(ptr, -HEADER_SIZE);
if(header->type != HEAP_BLOCK_USED)
return;
header->type = HEAP_BLOCK_FREE;
if(header->prev != NULL && header->prev->type == HEAP_BLOCK_FREE) {
//释放块的前一个块也正好为空
header->prev->next = header->next;
if(header->next != NULL)
header->next->prev = header->prev;
header->prev->size += header->size;
header = header->prev;
}
if(header->next != NULL && header->next->type == HEAP_BLOCK_FREE) {
//释放块的后一个块也是空块
header->size += header->next->size;
header->next = header->next->next;
}
}
void* malloc(unsigned size )
{
heap_header* header;
//printf("the needed sie %d\n", size);
//printf("the heap_block_header size %d\n", HEADER_SIZE);
if(size == 0)
{
return NULL;
}
//printf("enter ----malloc-------\n");
header = list_head;
while(header != 0)
{
if (header->type == HEAP_BLOCK_USED) {
header = header->next;
continue;
}
//刚好碰到一个空闲快,且其块的大小大于所需size加上一个信息头尺寸,但是小于所需size加上两个信息头尺寸,即剩余的内部碎片就算分离出来,也没有利用价值了,直接整个块都分配给used,等待整体释放
if (header->size > size + HEADER_SIZE && header->size <= size + HEADER_SIZE*2)
{
header->type = HEAP_BLOCK_USED;
return ADDR_ADD(header, HEADER_SIZE);
}
//空闲块空间足够,且剩余的内部碎片分离出来还可以再使用
if (header->size > size + HEADER_SIZE * 2) {
//split
heap_header* next = (heap_header*) ADDR_ADD(header, size+HEADER_SIZE);
next->prev = header;
next->next = header->next;
next->type = HEAP_BLOCK_FREE;
next->size = header->size - (size + HEADER_SIZE); //此处有误吧
if(header->next) //如果当前块存在下一块
{
header->next->prev = next;
}
header->next = next;
header->size = size + HEADER_SIZE;
header->type = HEAP_BLOCK_USED;
return ADDR_ADD(header, HEADER_SIZE);
};
header = header->next;
}
return NULL;
}
#ifndef WIN32
//Linux brk system call
static int brk(void* end_data_segment) {
int ret = 0;
//brk system call number:45
//in /usr/include/asm-i386/unistd.h:
//#define __NR_brk 45
asm("movl $45, %%eax \n\t"
"movl %1, %%ebx \n\t"
"int $0x80 \n\t"
"movl %%eax, %0 \n\t"
:"=r"(ret):"m"(end_data_segment) );
}
#endif
#ifdef WIN32
#include <Windows.h>
#endif
int mini_crt_heap_init()
{
void* base = NULL;
heap_header* header = NULL;
//32MB heap size
unsigned heap_size = 1024*1024*32;
//以base为起点分配32MB的内存空间
#ifdef WIN32
base = VirtualAlloc(0, heap_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (base == NULL)
return 0;
#else
base = (void*)brk(0);
void* end = ADDR_ADD(base, heap_size);
end = (void*)brk(end);
if(!end)
return 0;
#endif
header = (heap_header*) base;
header->size = heap_size;
header->type = HEAP_BLOCK_FREE;
header->next = NULL;
header->prev = NULL;
list_head = header;
// printf("heap init compeleted\n");
return 1;
}
2. 位图
将堆划分成大量块进行整数倍零售,利用Bitmap来完成状态指示。只有三种状态头/主体/空闲,故而只需要2位便可以表示块状态。关于位图的优点显然很明显:
速度快且稳定性好:为了避免用户越界读写破坏数据,只需要简单备份一下位图即可,而且即使部分数据被破坏,也不会导致整个堆无法工作。块不需要额外信息,易于管理。
缺点也是很明显的:容易产生内部碎片。块的大小设置是个学问,太大会导致内部碎片,太小会导致位图规模很大。
3. 对象池
对象池的思路很简单,如果每一次分配的空间大小都一样,那么可以按照这个每次请求分配的大小作为单位,把整个堆空间划分大量的小块,每次请求时只需要给出一块就可以,无需搜索整个链表或者位图数组来寻找合适的连续区间,故而速度很快。对象池的具体实现可以采用链表也可以采用位图,之所以和上面两种技术作区分,最大的不同是对象池假设每次申请的空间大小是一致的,故而常用于游戏地图场景渲染加载(渲染速度和规模是预先可以确定的)。而关于对象池的实现,可以直接参考boost::pool
的代码实现。
实际应用时,堆的分配算法是采取多种算法混合而成的。比如Linux下的glibc运行库: <64 B,采用类似于对象池的方法;> 512 B,则采用空闲链表法依次遍历分配空间最适合的空闲块; 64 B< n <512 B,则采用最佳折中策略;而对于> 128 KB的请求,则会使用mmap
系统调用直接向操作系统申请一块同等规模的空闲块。