最早学习C、C++语言时,它们都是把内存的管理全部交给开发者,这种方式最灵活但是也最容易出问题,对人员要求极高;后来出现的一些高级语言像Java、JavaScript、C#、Go,都有语言自身解决了内存分配和回收问题,降低开发门槛,释放生产力。然而对于想要深入理解原理的同学来说却带来了负担,本篇文章主要从内存分配角度来梳理个人理解,后续文章中会介绍Go的垃圾回收机制。
进程的内存空间
- 程序文件段(.text),包括二进制可执行代码;
- 已初始化数据段(.data),包括静态常量;
- 未初始化数据段(.bss),包括未初始化的静态变量;(bss与data一般作为静态存储区)
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
(以上来自小林coding)
上面是以进程为单位的视图,进程中可能有多个线程,每个线程的栈空间是独立的,但是都位于进程的栈区域中,而进程的堆区这是所有线程共享的,如下图所示
Go语言中的GMP管理机制来说,只有M对应的是操作系统中的线程,所以goroutine中是保留了必要的(rp、bp、pc指针),当goroutine执行时,对应到指定的栈空间地址区中。
说的有点远,回到本文主题。
内存分配一般有三种方式:静态存储区(根对象、静态变量、常量)、栈(函数中的临时局部变量)、堆(malloc、new等);
一般最长讨论的是栈和堆,栈的特点可以认为是线性内存,管理简单,分配比堆上更快,栈上分配的内存一般不需要程序员关心,程序语言都有专门的栈帧来管理(一般来说线程的栈空间是2M有的是8M,不能变化超过会崩溃,Go语言中goroutine是2kb,Go语言来有自己的栈扩容和缩容能力,64位系统超过1G则会崩溃)。(这里说的线性内存其实在真实的机器物理内存中并不一定连续,这是因为操作系统提供了虚拟内存,让各个程序看起来是独占整个物理内存,实际上对程序来说连续的地址空间,在操作系统视角下未必是连续的,可以参考这篇文章)
因为堆区是多个线程共用的,所以就需要一套机制来进行分配(考虑内存碎片、公平性、冲突解决);不同的内存分配管理方式的适用场景都不同。在详细讲解Go内存分配策略之前,我们先来看一个简单的内存分配。
堆内存分配
堆内存在最开始时时连续的,当程序运行过程中大家都去堆中申请自己的使用空间,如果不做任何处理,那么会产生两个主要问题:
第一个内存碎片问题:
假设堆有100M,线程A申请500M,线程B申请200M,线程C申请300M,此时堆空间为A(500)B(200)C(300),然后A和C把空间释放了,空间变为 空闲区(500m)线程B空间(200M)空闲区(300M) 这时候线程D需要留600M就会发现此时没有完成的一块空间给线程D;
所以一些高级语言中堆空间分配以类似操作系统的页式分配的方式进行管理,分割出一个个小块,一个小块中包含一些元数据(如用户数据大小、是否空闲、头指针、尾指针)、用户数据区、对齐padding区;
因为现代操作系统一个页的区域一般是4kb,所以每次分配堆内存块也会把用户数据区设置为4kb的倍数,同时因为还需要额外的区域来存储元数据信息,但是元数据大小未必是4字节的倍数(像C++中可以设置4字节对齐 https://blog.csdn.net/sinat_2... ),在加上要考虑到CPU的伪共享缓存带来的性能问题,所以需要一些额外的空闲空间来做补充(这就是对齐字节的意义)。
那么如果只用链表形式来管理堆内存,看起来就像是下面这样:
第二个则是并发冲突问题
因为多个线程在同时向堆内存中申请资源,如果没有控制必然会出现冲突和覆写问题,所以常见的方案是使用锁,但是锁则不可避免的带来性能问题;所以有各种各样的方案兼顾性能和碎片化以及预分配的策略来进行内存分配。
一个简单的内存分配器
我们先按照上面的介绍来实现一个简单的内存分配器,即实现一个malloc、free方法。
在这里我们我们把data、bss、heap三个区域统称为“data segment”,datasegment的结尾由一个指向此处的指针brk(program break)确定。如果想在heap上分配更多的空间,只需要请求系统由低像高移动brk指针,并把对应的内存首地址返回,释放内存时,只需要向下移动brk指针即可。
在Linux和unix系统中,我们这里就调用sbrk()方法来操纵brk指针:
- sbrk(0)获取当前brk的地址
- 调用sbrk(x),x为正数时,请求分配x bytes的内存空间,x为负数时,请求释放x bytes的内存空间
现在写一个简易版本的malloc:
void *malloc(size_t size) { void *block; block = sbrk(size); if (block == (void *) -1) { return NULL; } return block; }
现在问题是我们可以申请内存,但是如何释放呢?因为释放内存需要sbrk来移动brk指针向下缩减,但是我们目前没有记录这个区域的尺寸信息;
还有另外一个问题,假设我们现在申请了两块内存,A\B,B在A的后面,如果这时候用户想将A释放,这时候brk指针在B的末尾处,那么如果简单的移动brk指针,就会对B进行破坏,所以对于A区域,我们不能直接还给操作系统,而是等B也同时被是释放时再还给操作系统,同时也可以把A作为一个缓存,等下次有小于等于A区域的内存需要申请时,可以直接使用A内存,也可以将AB进行合并来统一分配(当然会存在内存碎片问题,这里我就先不考虑)。
所以现在我们将内存按照块的结构来进行划分,为了简单起见,我们使用链表的方式来管理;那么除了本身用户申请的内存区域外,还需要一些额外的信息来记录块的大小、下一个块的位置,当前块是否在使用。整个结构如下:
typedef char ALIGN[16]; // padding字节对齐使用 union header { struct { size_t size; // 块大小 unsigned is_free; // 是否有在使用 union header *next; // 下一个块的地址 } s; ALIGN stub; }; typedef union header header_t;
这里将一个结构体与一个16字节的数组封装进一个union,这就保证了这个header始终会指向一个对齐16字节的地址(union的尺寸等于成员中最大的尺寸)。而header的尾部是实际给用户的内存的起始位置,所以这里给用户的内存也是一个16字节对齐的(字节对齐目的为了提升缓存命中率和批处理能力提升系统效率)。
现在的内存结构如下图所示:
现在我们使用head和tail来使用这个链表
header_t *head, *tail
为了支持多线程并发访问内存,我们这里简单的使用全局锁。
pthread_mutex_t global_malloc_lock;
我们的malloc现在是这样:
void *malloc(size_t size) { size_t total_size; void *block; header_t *header; if (!size) // 如果size为0或者NULL直接返回null return NULL; pthread_mutex_lock(&global_malloc_lock); // 全局加锁 header = get_free_block(size); // 先从已空闲区域找一块合适大小的内存 if (header) { // 如果能找到就直接使用,无需每次向操作系统申请 header->s.is_free = 0; // 标志这块区域非空闲 pthread_mutex_unlock(&global_malloc_lock); // 解锁 // 这个header对外部应该是完全隐藏的,真正用户需要的内存在header尾部的下一个位置 return (void*)(header + 1); } // 如果空闲区域没有则向操作系统申请一块内存,因为我们需要header存储一些元数据 // 所以这里要申请的内存实际是元数据区+用户实际需要的大小 total_size = sizeof(header_t) + size; block = sbrk(total_size); if (block == (void*) -1) { // 获取失败解锁、返回NULL pthread_mutex_unlock(&global_malloc_lock); return NULL; } // 申请成功设置元数据信息 header = block; header->s.size = size; header->s.is_free = 0; header->s.next = NULL; // 更新链表对应指针 if (!head) head = header; if (tail) tail->s.next = header; tail = header; // 解锁返回给用户内存 pthread_mutex_unlock(&global_malloc_lock); return (void*)(header + 1); } // 这个函数从链表中已有的内存块进行判断是否存在空闲的,并且能够容得下申请区域的内存块 // 有则返回,每次都从头遍历,暂不考虑性能和内存碎片问题。 header_t *get_free_block(size_t size) { header_t *curr = head; while(curr) { if (curr->s.is_free && curr->s.size >= size) return curr; curr = curr->s.next; } return NULL; }
可以看下现在我们的内存分配具有的基本能力:
- 通过加锁保证线程安全
- 通过链表的方式管理内存块,并解决内存复用问题。
接下来我们来写free函数,首先要看下需要释放的内存是否在brk的位置,如果是,则直接还给操作系统,如果不是,标记为空闲,以后复用。
void free(void *block) { header_t *header, *tmp; void *programbreak; if (!block) return; pthread_mutex_lock(&global_malloc_lock); // 全局加锁 header = (header_t*)block - 1; // block转变为header_t为单位的结构,并向前移动一个单位,也就是拿到了这个块的元数据的起始地址 programbreak = sbrk(0); // 获取当前brk指针的位置 if ((char*)block + header->s.size == programbreak) { // 如果当前内存块的末尾位置(即tail块)刚好是brk指针位置就把它还给操作系统 if (head == tail) { // 只有一个块,直接将链表设置为空 head = tail = NULL; } else