内存管理(Memory Management)
内存管理是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。
操作系统内存管理
操作系统将RAM空间分成两部分:一部分用于存放内核映像(也就是内核代码和内核静态数据结构);另一部分通常由虚拟内存系统处理,这部分RAM称为动态内存(Dynamic Memory),不仅是进程所需要的宝贵资源,也是内核本身所需的宝贵资源。
虚拟内存
虚拟内存(Virtual Memory)是操作系统提供的一种抽象,作为一种逻辑层,处于应用程序的内存请求与硬件内存管理单元(Memory Management Unit,MMU)之间。进程所用的一组内存地址不同于物理内存地址。当进程使用一个虚拟地址时,内核和MMU协同定位其在内存的实际物理位置。虚拟内存有很多用途和优点:
- 若干个进程可以并发地执行。
- 应用程序所需内存大于可用物理内存时也可以运行。
- 程序只有部分代码装入内存时进程可以执行它。
- 允许每个进程访问可用物理内存的子集。
- 进程可以共享库函数或程序的一个单独内存映像。
- 程序是可以重定位的,也就是可以把程序放在物理内存的任何地方。
- 程序员可以编写与机器无关的代码,因为他们不必关心有关物理内存的组织结构。
内存地址
- 逻辑地址(Logical Address):是程序员所使用的地址,每个逻辑地址都由一个段(Segment)和偏移量(Offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。(只有在Intel实模式下,逻辑地址才和物理地址相等,因为实模式没有分段或分页机制,CPU不进行自动地址转换)
- 线性地址(Linear Address):也称为虚拟地址(Virtual Address),线性地址是一个连续的地址空间,对于32位系统来说,其地址范围从0x0000000到0xFFFFFFF。 通过分段机制将逻辑地址转换为线性地址。
- 物理地址(Physical Address):内存控制器实际用来访问主存芯片上的存储单元的实际地址。在线性地址的基础上,通过分页机制将其再次转换为物理地址。每一个物理地址对应着主存中的唯一位置。
内存管理单元(MMU)通过一种称为分段单元(Segmentation Unit)的硬件电路把一个逻辑地址转换成线性地址;接着,第二个称为分页单元(Paging Unit)的硬件电路把线性地址转换成物理地址。
分段
- 硬件中的分段
一个逻辑地址由段选择符(Segment Selector)和段内偏移量(Offset)组成。段选择符指向一个段描述符(Segment Descriptor),段描述符包含段基址(Base Address),段基址加上偏移量就得到了线性地址。为了快速方便找到段选择符,处理器提供了段寄存器,段寄存器的唯一目的是存放段选择符,有6个段寄存器:CS(Code Segment)、DS(Data Segment)、SS(Stack Segment)、ES(Extra Segment)、FS(Flag Segment)、GS(Global Segment)。每个段由一个8字节的段描述符表示,它描述了段的特征。段描述符存放在GDT(Global Descriptor Table)或LDT(Local Descriptor Table)中。逻辑地址转换为线性地址分段单元执行一下操作:
- 处理器从段寄存器中取出段选择符,根据段选择符的TI字段来确定段描述符是在GDT或LDT中(获取到GDT或LDT的线性基地址)。
- 从段选择符的index字段计算段描述符的地址。
- 从上述段描述符的地址取出段描述符,将段描述符的Base字段的值加上逻辑地址的偏移量得到线性地址。
- 操作系统中的分段
Linux以非常有限的方式使用分段。实际上分段和分页在某种程度上有点多余,因为它们都可以划分进程的物理空间:分段可以给每个一个进程分配不同的线性地址空间;而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux更喜欢使用分页方式,因为当所有进程使用相同的段寄存器值时,内存管理变得更简单,也就是说它们能共享同样的一组线性地址空间。 Linux只有在80x86架构下才需要使用分段,在多处理器系统中每个CPU对应一个GDT,每个GDT中包含18个Linux段的段描述符,主要有用户代码段、用户数据段、内核代码段、内核数据段和局部线程存储(Thread-Local Storage, TLS)段。大多数用户态下的Linux程序不使用LDT。所有的段都是从0x00000000开始,因此Linux下的逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。
分页
- 硬件中的分页
分页单元把线性地址转换成物理地址。其中的一个关键任务是把所有请求的访问类型与线性地址的权限比较,如果这次访问是无效的,就会产生一个缺页异常。为了效率起见,线性地址被分成以固定长度为单位的组,称为页(Page)。页内部连续的线性地址被映射到连续的物理地址中。这样内核可以指定一个页的物理地址和其存取权限,而不用指定页所包含的全部线性地址的存取权限。分页单元把所有的RAM分成固定长度的页框(Page Frame)。每一个页框包含一个页(Page),也就是一个页框的长度与一个页的长度一致。页框是主存的一部分,因此也是一个存储区域。区分一页和一个页框是很重要的,前者只是一个数据块,可以存放在任何页框或磁盘中。把线性地址映射到物理地址空间的数据结构称为页表(Page Table)。页表存放在主存中,并在启用分页单元之前必须由内核对页表进行适当的初始化。 从80386架构开始,Intel处理器的分页单元处理4KB的页。32位的线性地址空间被分成3个域:Directory(目录,最高10位)、Table(页表,中间10位)、Offset(偏移量)。线性地址的转换分两步完成,每一步都基于一种转换表,第一种转换表称为页目录表(Page Directory),第二种转换表称为页表(Page Table)。页目录存放在一个页框中,页表存放在另一个页框中。使用这种二级模式的目的在于减少每个进程页表所需RAM的数量。然后两级分页机制并不适用于64位处理器。同时为了缩小CPU与RAM之前的速度不匹配,CPU引入了硬件高速缓存内存,硬件高速缓存基于局部性原理(Locality Principle)。高速缓存单元插在分页单元和主存之间,它包含一个硬件高速缓存(Hardware Cache Memory)和一个高速缓存控制器(Cache Controller)。高速缓存内存存放内存中真正的行(Line)。
- 操作系统中的分页
Linux采用了一种同时适用于32位和64位系统的普通分页模型。线性地址被分成5个部分:Page Global Directory(页全局目录,PGD)、Page Upper Directory(页上级目录,PUD)、Page Middle Directory(页中间目录,PMD)、Page Table(页表,PT)和Offset(偏移量)。每个部分的大小与具体的计算机体系结构有关。对于没有启用物理地址扩展的32位系统,两级页表已经足够了,Linux通过使页上级目录位和页中间目录位为0,取消了页上级目录和页中间目录。启用了物理地址扩展的32位系统,取消了页上级目录。64位系统使用三级还是四级分页取决硬件对线性地址的划分。
物理内存
在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用。
页框管理
Linux采用4KB页框大小作为标准的内存分配单位。内核必须记录每个页框的当前状态。例如,内核必须能区分哪些页框包含的是属于进程的页,而哪写页框包含的是内核代码或内核数据;以及能够确定动态内存中的页框是否空闲。页框的状态信息保存在一个类型为page的页描述符中。
Linux 2.6支持非一致性内存访问(Non-Uniform Memory Access, NUMA)模型,在这种模型中,给定CPU对不同内存单元的访问时间可能不一样。系统的物理内存被划分为几个节点(Node)。每个节点的物理内存又可以分为3个管理区(Zone):ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM(在64位硬件平台,因为可使用的线性地址空间远大于能安装的RAMRAM大小,这些体系结构的ZONE_HIGHMEM管理区总是空的)。当内核调用一个内存分配函数式,必须指明请求页框所在的管理区。被称为分区页框分配器(Zoned Page Frame Allocator)的内核子系统,处理对连续页框组的内存分配请求。然后在每个管理区内,页框被名为伙伴系统(Buddy System)的部分来处理。使用函数/宏alloc_pages()、__get_free_pages()等请求页框和__free_pages()、free_pages()释放页框。
伙伴系统(Buddy System):内核应该为分配一组连续的页框而建立一种健壮、高效的分配策略。为此必须解决著名的内存管理问题,也就是所谓的外碎片(External Fragmentation)。频繁地请求和释放不同大小的一组连续页框,必然导致在已分配页框的块间分散了许多小块的空闲页框。由此带来的问题是,即使有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就可能无法满足。本质上说,避免外碎片的方法有两种:
- 利用分页单元把一组非连续的空闲页框映射到连续的线性地址空间。
- 开发一种适当的技术来记录现存的空闲连续页框块的情况,以尽量避免为满足对小块的请求而分割大的空闲块。
Linux内核首先采用第二种方法,采用了著名的伙伴系统(Buddy System)算法来解决外碎片问题。把所用的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框。对1024个页框的最大请求对应着4MB大小的连续RAM块。每个块的第一个页框的物理地址是该块大小的整数倍。该算法的工作原理:假设要请求一个256个页框的连续RAM块(即1MB),算法先在256个页框的链表中检查是否有一个空闲块。如果没有这样的块,算法会查找下一个更大的页块,也就是在512个页框的链表中找一个空闲块。如果存在这样的块,内核就把512的页框分成两等份,一半用作满足请求,另一半用插入256个页框的链表中。如果在512个页框的链表中也没找到空闲块,就继续找更大的块1024个页框的块。如果这样的块存在,内核把1024个页框块的256个页框用作请求,然后从剩余的768个页框中拿512个插入到512个页框的链表中,再把最后的256个插入到256个页框的链表中。如果1024个页框的链表还是空的,算法就放弃并发出错误信号。上述过程的逆过程就是页框块的释放过程,也是该算法名字的由来。内核试图把大小为b的一对空闲伙伴合并为一个大小为2b的单独块。满足以下条件的两个块称为伙伴:
- 两个块具有相同的大小,记作b。
- 它们的物理地址是连续的。
- 第一个块的第一个页框的物理地址是2 * b * 2^12的整数倍。
该算法是迭代的,如果它成功合并所释放的块,它会试图合并2b的块,以再次试图形成更大的块。
内存区管理
内存区(Memory Area)是具有连续的物理地址和任意长度的内存单元序列。伙伴系统算法采用页框作为基本内存区,这适合于对大块内存的请求,但如何处理对小内存的请求呢,比如几十或几百个字节?使用一种新的数据结构来描述在同一页框中如何分配小内存区。但这样也引入了一个新问题,即所谓的内碎片(Internal Fragmentation)。内碎片的产生主要是由于请求内存的大小与分配给它的大小不匹配而造成的。
一种更好的内存区分配算法源自于slab分配器模式,该模式最早用于Sun公司的Solaris操作系统中。该算法基于以下前提:
- 所存放数据的类型可以影响内存区的分配方式。
- 内核函数倾向于反复请求同一类型的内存区。
- 对内存区的请求可以根据它们发生的频率来分类。
- 引入的对象大小不是几何分布的情况下,就是说,数据结构的起始物理地址不是2的幂次,这可以借助处理器硬件高速缓存而导致较好的性能。
- 硬件高速缓存的高性能又是尽可能地限制对伙伴系统分配器调用的另一个理由,因为对伙伴系统函数的每次调用都“弄脏”硬件高速缓存,所以增加了对内存的平均访问时间。
slab分配器把对象分组放进高速缓存。每个高速缓存都是同种类型对象的一种“储备”。包含高速缓存的主内存区被划分为多个slab,每个slab由一个或多个页框组成,这些页框中既包含已分配的对象,也包含空闲的对象。高速缓存中的每个slab都有自己的类型为slab的描述符。
非连续内存区管理
上述页框管理和内存区管理都是对连续物理内存区的处理,把内存区映射到一组连续的页框是最好的选择,这样会充分利用高速缓存并获得较低的平均访问时间。不过,如果对内存区的请求不是很频繁,那么通过连续的线性地址来访问非连续的页框这样一种分配模式就会很有意义。这中模式的主要优点是避免了外碎片,而缺点是必须打乱内核页表。
Go内存管理
Golang的内存管理是建立在OS的内存管理之上的,它目的就是尽可能的会发挥操作系统层面的优势,而避开导致低效情况。Golang的内存管理机制受到了TCMalloc内存分配算法库的深刻影响,并在此基础上进行了优化。
TCMalloc
TCMalloc(Thread-Caching Malloc)是Google定制的C语言中malloc()和C++中operator new的实现,用于在我们的C和C++代码中进行内存分配。TCMalloc是一个快速的多线程malloc实现。TCMalloc具有以下特点:
- 对于大多数对象,分配和释放速度快,无竞争。对象根据模式不同,可能会在每个线程或每个逻辑 CPU 上缓存。大多数分配不需要加锁,因此竞争较少,并且对于多线程应用程序具有良好的可扩展性。
- 灵活使用内存,因此释放的内存可以重新用于不同大小的对象,或者返回给操作系统。
- 通过按“页”分配相同大小的对象,降低了单个对象的内存开销,尤其对于小型对象能够实现空间的有效利用。
- 低开销的采样,使开发者能够深入了解应用程序的内存使用情况。
TCMalloc的大致内部结构如下图所示: TCMalloc可以被划分为三个组件:前端、中端和后端。它们的大致职责划分如下:前端是一个缓存系统,负责为应用程序提供快速的内存分配和释放服务。中端负责补充前端缓存的内存资源。后端负责从操作系统获取内存。
- Page
操作系统对内存管理以页为单位,TCMalloc也是这样,TCMalloc将虚拟内存空间划分为多份同等大小的Page,每个Page默认是8KB。
- Span
一个或多个连续的Page称之为是一个Span。TCMalloc是以Span为单位向操作系统申请内存的。每个Span记录了第一个起始Page的编号Start,和一共有多少个连续Page的数量Length。为了管理Span,Span集合是以双向链表管理的。
- Size Class
在256KB以内的小对象,TCMalloc会将这些小对象集合划分成多个内存刻度,同属于一个刻度类别下的内存集合称之为属于一个Size Class。每个Size Class都对应一个大小比如8字节、16字节、32字节等。在申请小对象内存的时候,TCMalloc会根据使用方申请的空间大小就近向上取最接近的一个Size Class的Span内存块返回给使用方。
- ThreadCache
在TCMalloc中每个线程都会有一份单独的缓存,就是ThreadCache。ThreadCache中对于每个Size Class都会有一个对应的FreeList。使用方对于从TCMalloc申请的小对象,会直接从TreadCache获取,实则是从FreeList中返回一个空闲的对象,如果对应的Size Class刻度下已经没有空闲的Span可以被获取了,则ThreadCache会从CentralCache中获取。当使用方使用完内存之后,归还也是直接归还给当前的ThreadCache中对应刻度下的的FreeList中。整个申请和归还的流程是不需要加锁的,因为ThreadCache为当前线程独享,但如果ThreadCache不够用,需要从CentralCache申请内存时,这个动作是需要加锁的。不同Thread之间的ThreadCache是以双向链表的结构进行关联,是为了方便TCMalloc统计和管理。
- CentralCache
CentralCache是所有线程共享的缓存。所以向CentralCache获取内存交互是需要加锁的。CentralCache缓存的Size Class和ThreadCache的一样,这些缓存都被放在CentralFreeList中,当ThreadCache中的某个Size Class刻度下的缓存小对象不够用,就会向CentralCache对应的Size Class刻度的CentralFreeList获取,同样的如果ThreadCache有多余的缓存对象也会退还给相应的CentralFreeList。CentralCache与PageHeap的角色关系与ThreadCache与CentralCache的角色关系相似,当CentralCache出现Span不足时,会从PageHeap申请Span,以及将不再使用的Span退还给PageHeap。
- PageHeap
PageHeap是提供CentralCache的内存来源。PageHeap与CentralCache不同的是CentralCache是与ThreadCache布局一模一样的缓存,主要是起到针对ThreadCache的二级缓存作用,且只支持小对象内存分配。而PageHeap则是针对CentralCache的三级缓存。弥补对于中对象内存和大对象内存的分配,PageHeap也是直接和操作系统虚拟内存衔接的一层缓存,当ThreadCache、CentralCache、PageHeap都找不到合适的Span,PageHeap则会调用操作系统内存申请系统调用函数来从虚拟内存的堆区中取出内存填充到PageHeap当中。PageHeap内部的Span管理,采用两种不同的方式,对于128个Page以内的Span申请,每个Page刻度都会用一个链表形式的缓存来存储。对于128个Page以上内存申请,PageHeap是以有序集合来存放(Large Span Set)。
小对象(0, 256KB]的分配流程:
- Thread用户线程应用逻辑申请内存,当前Thread访问对应的ThreadCache获取内存,此过程不需要加锁。
- ThreadCache的得到申请内存的SizeClass(一般向上取整,大于等于申请的内存大小),通过SizeClass索引去请求自身对应的FreeList。
- 判断得到的FreeList是否为非空。
- 如果FreeList非空,则表示目前有对应内存空间供Thread使用,得到FreeList第一个空闲Span返回给Thread用户逻辑,流程结束。
- 如果FreeList为空,则表示目前没有对应SizeClass的空闲Span可使用,请求CentralCache并告知CentralCache具体的SizeClass。
- CentralCache收到请求后,加锁访问CentralFreeList,根据SizeClass进行索引找到对应的CentralFreeList。
- 判断得到的CentralFreeList是否为非空。
- 如果CentralFreeList非空,则表示目前有空闲的Span可使用。返回多个Span,将这些Span(除了第一个Span)放置ThreadCache的FreeList中,并且将第一个Span返回给Thread用户逻辑,流程结束。
- 如果CentralFreeList为空,则表示目前没有可用是Span可使用,向PageHeap申请对应大小的Span。
- PageHeap得到CentralCache的申请,加锁请求对应的Page刻度的Span链表。
- PageHeap将得到的Span根据本次流程请求的SizeClass大小为刻度进行拆分,分成N份SizeClass大小的Span返回给CentralCache,如果有多余的Span则放回PageHeap对应Page的Span链表中。
- CentralCache得到对应的N个Span,添加至CentralFreeList中,跳转至第8步。
中对象(256KB, 1MB]的分配Thread不再按照小对象的流程路径向ThreadCache获取,而是直接从PageHeap获取。其流程如下:
- Thread用户逻辑层提交内存申请处理,如果本次申请内存超过256KB但不超过1MB则属于中对象申请。TCMalloc将直接向PageHeap发起申请Span请求。
- PageHeap接收到申请后需要判断本次申请是否属于小Span(128个Page以内),如果是,则走小Span,即中对象申请流程,如果不是,则进入大对象申请流程。
- PageHeap根据申请的Span在小Span的链表中向上取整,得到最适应的第K个Page刻度的Span链表。
- 得到第K个Page链表刻度后,将K作为起始点,向下遍历找到第一个非空链表,直至128个Page刻度位置,找到则停止,将停止处的非空Span链表作为提供此次返回的内存Span,将链表中的第一个Span取出。如果找不到非空链表,则当成本次申请为大Span申请,则进入大对象申请流程。
- 假设本次获取到的Span由N个Page组成。PageHeap将N个Page的Span拆分成两个Span,其中一个为K个Page组成的Span,作为本次内存申请的返回,给到Thread,另一个为N-K个Page组成的Span,重新插入到N-K个Page对应的Span链表中。
大对象(1MB, +∞]的分配与中对象分配情况类似,Thread绕过ThreadCache和CentralCache,直接向PageHeap获取。其流程如下:
- Thread用户逻辑层提交内存申请处理,如果本次申请内存超过1MB则属于大对象申请。TCMalloc将直接向PageHeap发起申请Span。
- PageHeap接收到申请后需要判断本次申请是否属于小Span(128个Page以内),如果是,则走小Span中对象申请流程,如果不是,则进入大对象申请流程。
- PageHeap根据Span的大小按照Page单元进行除法运算,向上取整,得到最接近Span的且大于Span的Page倍数K,此时的K应该是大于128。如果是从中对象流程分过来的(中对象申请流程可能没有非空链表提供Span),则K值应该小于128。
- 搜索Large Span Set集合,找到不小于K个Page的最小Span(N个Page)。如果没有找到合适的Span,则说明PageHeap已经无法满足需求,则向操作系统虚拟内存的堆空间申请一堆内存,将申请到的内存安置在PageHeap的内存结构中,重新执行3步骤。
- 将从Large Span Set集合得到的N个Page组成的Span拆分成两个Span,K个Page的Span直接返回给Thread用户逻辑,N-K个Span退还给PageHeap。其中如果N-K大于128则退还到Large Span Set集合中,如果N-K小于128,则退还到Page链表中。
Go堆内存管理
Golang内存管理模型与TCMalloc的设计极其相似。基本轮廓和概念也几乎相同,只是一些规则和流程存在差异。
- page
与TCMalloc的Page一致。Golang内存管理模型延续了TCMalloc的概念,一个Page的大小依然是8KB。Page表示Golang内存管理与虚拟内存交互内存的最小单元。操作系统虚拟内存对于Golang来说,依然是划分成等分的N个Page组成的一块大内存公共池,
与TCMalloc中的Span一致。mspan概念依然延续TCMalloc中的Span概念,在Golang中将Span的名称改为mspan,依然表示一组连续的Page。
Golang内存管理针对size class对衡量内存的的概念又更加详细了很多,这里面介绍一些基础的有关内存大小的名词及算法。
object size:是只协程应用逻辑一次向Golang内存申请的对象object大小。object是Golang内存管理模块针对内存管理更加细化的内存管理单元。一个span在初始化时会被分成多个object。比如object size是8B(8字节)大小的0bject,所属的span大小是8KB(8192字节),那么这个span就会被平均分割成1024(8192/8=1024)个object。逻辑层向Golang内存模型取内存,实则是分配一个Object出去。
size class:Golang内存管理中的size class与TCMalloc所表示的设计含义是一致的,都表示一块内存的所属规格或者刻度。Golang内存管理中的size class是针对object size来划分内存的。也是划分object大小的级别。比如object size在1Byte ~ 8Byte之间的object属于size class 1级别,object size在8B ~ 16Byte之间的属于size class 2级别。
span class:这个是Golang内存管理额外定义的规格属性,是针对span来进行划分的,是span大小的级别。每个size class有两个span class,其中一个span为存放需要GC扫描的对象(包含指针的对象),另一个span为存放不需要GC扫描的对象(不包含指针的对象)。
mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的mspan,并按span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但是mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Golang中是每个P拥有1个mcache。因为Golang调度的GPM模型,真正可运行的线程M(Machine,代表着真正的执行计算资源,可以认为它就是os thread)的数量与P(逻辑Processor,代表线程M的执行的上下文,P的最大作用是其拥有的各种G对象队列、链表、cache和状态)的数量一致,即GOMAXPROCS个,可以保证每个G(Goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等)使用mcache时不需要加锁就可以获取到内存。(一个M绑定一个P,G只有绑定到P才能被调度)
mcentral与TCMalloc中的CentralCache概念相似。向mcentral申请span是同样是需要加锁的。当mcache中某个size class对应的span空缺时,mcache则会向mcentral申请对应的Span。其中协程逻辑层与mcache的内存交换单位是object,mcache与mcentral的内存交换单位是span,而mcentral与mheap的内存交换单位是page。mcentral与mcache不同的是,mcache每个级别保存一个mspan;而mcentral每个级别保存是两个mspan List链表。与TCMalloc中的CentralCache不同的是,CentralCache每个级别保存一个span list;而mcentral每个级别都保存了两个mspan list(partial和full)。
Golang内存管理的mheap依然是继承TCMalloc的PageHeap设计。mheap的上游是mcentral,mcentral中的mspan不够时会向mheap申请。mheap的下游是操作系统,mheap的内存不够时会向操作系统的虚拟内存空间申请。mheap是对内存块的管理对象,是通过page为内存单元进行管理。那么用来详细管理每一系列page的结构称之为一个heapArena,一个heapArena占用内存64MB,其中里面的内存的是一个一个的mspan,当然最小单元依然是page;所有的heapArena组成的集合是一个arenas,也就是mheap针对堆内存的管理。mheap是Golang进程全局唯一的所以访问依然加锁。
Tiny对象(1, 16B)的分配流程:
- P向mcache申请微小对象如一个Bool变量。如果申请的object在Tiny对象的大小范围则进入Tiny对象申请流程,否则进入小对象或大对象申请流程。
- 判断申请的Tiny对象是否包含指针,如果包含则进入小对象申请流程(不会放在Tiny缓冲区,因为需要GC走扫描等流程)。
- 如果Tiny空间的16B没有多余的存储容量,则从size class = 2(即span class = 4或5)的span中获取一个16B的0bject放置Tiny缓冲区。
- 将1B的Bool类型放置在16B的Tiny空间中,以字节对齐的方式。
Samll对象[16B, 32KB]的分配流程:
- 首先协程逻辑层P向Golang内存管理申请一个对象所需的内存空间。
- mcache在接收到请求后,会根据对象所需的内存空间计算出具体的大小size。
- 判断size是否小于16B,如果小于16B则进入Tiny微对象申请流程,否则进入小对象申请流程。
- 根据size匹配对应的size class内存规格,再根据size class和该对象是否包含指针,来定位是从noscan span class,还是 scan span class获取空间,没有指针则锁定noscan。
- 在定位的span class中的span取出一个object返回给协程逻辑层P,P得到内存空间,流程结束。
- 如果定位的span class中的span所有的内存块object都被占用,则mcache会向mcentral申请一个span。
- mcentral收到内存申请后,优先从相对应的span class中的partial set里取出span(多个object组成),partial set没有则从full set中取,返回给mcache。
- mcache得到mcentral返回的span,补充到对应的span class中,之后再次执行第5步流程。
- 如果full set中没有符合条件的span,则mcentral会向mheap申请内存。
- mheap收到内存请求从其中一个heapArena从取出一部分pages返回给mcentral;当mheap没有足够的内存时,mheap会向操作系统申请内存,将申请的内存也保存到heapArena中的mspan中。mcentral将从mheap获取的由pages组成的span添加到对应的span class链表或集合中,作为新的补充,之后再次执行第7步。
- 最后协程业务逻辑层得到该对象申请到的内存,流程结束。
Large对象(32KB, +∞]的分配流程:
- 协程逻辑层申请大对象所需的内存空间,如果超过32KB,则直接绕过mcache和mcentral直接向mheap申请。
- mheap根据对象所需的空间计算得到需要多少个page。
- mheap向arenas中的heapArena申请相对应的pages。
- 如果arenas中没有heapArena可提供合适的pages内存,则向操作系统的虚拟内存申请,且填充至arenas中。
- mheap返回大对象的内存空间。
- 协程逻辑层P得到内存,流程结束。
逃逸分析(Escape Analysis)
Golang的逃逸分析是一种编译器优化技术,用于确定哪些变量应该分配在栈上,哪些变量应该分配在堆上。在Golang中,函数的局部变量通常分配在栈上,因为栈上的内存分配和回收非常快。然而,如果函数外部需要访问这些变量(例如,通过返回局部变量的指针),则这些变量必须分配在堆上,以避免函数返回后被销毁。Golang中一个函数内局部变量,不管是不是动态new出来的,它会被分配在堆还是栈,是由编译器做逃逸分析之后做出的决定。
一般我们给一个引用类对象中的引用类成员进行赋值,可能出现逃逸现象。可以理解为访问一个引用对象实际上底层就是通过一个指针来间接的访问了,但如果再访问里面的引用成员就会有第二次间接访问,这样操作这部分对象的话,极大可能会出现逃逸的现象。Golang中的引用类型有:func、interface、slice、map、channel、*Type等。产生逃逸的场景主要有:
- 指针逃逸:当函数返回局部变量的指针时,该变量会逃逸到堆上。
- 栈空间不足逃逸:若局部变量的大小超过了编译器或运行时系统为栈分配的固定大小,那么大对象会被分配到堆上。
- 动态类型逃逸:当接口值的动态类型未知并且可能含有指针时,可能会发生逃逸,因为编译器无法确定具体的内存布局。
- 闭包引用对象逃逸:如果闭包(closure)捕获了外部作用域的变量,那么只要闭包还在存活,被捕获的变量就不能被栈回收,因此会逃逸到堆上。
- 间接赋值逃逸:当一个局部变量通过指针被赋值给全局变量或外部变量时,该局部变量会逃逸到堆上。因为全局变量或外部变量的生命周期可能比局部变量长,所以局部变量需要在堆上分配。
Go垃圾回收
在清除阶段,Golang使用垃圾回收收集不再使用的span,调用mspan.scavenge()把span释放。见Golang垃圾回收。
FYI
深入理解Linux内核