图解Golang的内存分配

Golang
294
0
0
2023-08-23

一般程序的内存分配

在讲Golang的内存分配之前,让我们先来看看一般程序的内存分布情况:

以上是程序内存的逻辑分类情况。

我们再来看看一般程序的内存的真实(真实逻辑)图:

Go的内存分配核心思想

Go是内置运行时的编程语言(runtime),像这种内置运行时的编程语言通常会抛弃传统的内存分配方式,改为自己管理。这样可以完成类似预分配、内存池等操作,以避开系统调用带来的性能问题,防止每次分配内存都需要系统调用。

Go的内存分配的核心思想可以分为以下几点:

  • 每次从操作系统申请一大块儿的内存,由Go来对这块儿内存做分配,减少系统调用
  • 内存分配算法采用Google的TCMalloc算法。算法比较复杂,究其原理可自行查阅。其核心思想就是把内存切分的非常的细小,分为多级管理,以降低锁的粒度。
  • 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

Go的内存结构

Go在程序启动的时候,会分配一块连续的内存(虚拟内存)。整体如下:

图中span和bitmap的大小会随着heap的改变而改变

arena

arena区域就是我们通常所说的heap。

heap中按照管理和使用两个维度可认为存在两类“东西”:

一类是从管理分配角度,由多个连续的页(page)组成的大块内存:

另一类是从使用角度出发,就是平时咱们所了解的:heap中存在很多”对象”:

spans

spans区域,可以认为是用于上面所说的管理分配arena(即heap)的区域。

此区域存放了mspan的指针,mspan是啥后面会讲。

spans区域用于表示arena区中的某一页(page)属于哪个mspan。

mspan可以说是go 内存管理 的最基本单元,但是内存的使用最终还是要落脚到“对象”上。mspan和对象是什么关系呢?

其实“对象”肯定也放到page中,毕竟page是内存存储的基本单元。

我们抛开问题不看,先看看一般情况下的对象和内存的分配是如何的:如下图

假如再分配“p4”的时候,是不是内存不足没法分配了?是不是有很多碎片?

这种一般的分配情况会出现内存碎片的情况,go是如何解决的呢?

可以归结为四个字:按需分配。go将内存块分为大小不同的67种,然后再把这67种大内存块,逐个分为小块(可以近似理解为大小不同的相当于page)称之为span(连续的page),在go语言中就是上文提及的mspan。

对象分配的时候,根据对象的大小选择大小相近的span,这样,碎片问题就解决了。67中不同大小的span代码注释如下(目前版本1.11):

说说每列代表的含义:

  • class: class ID,每个span结构中都有一个class ID, 表示该span可处理的对象类型
  • bytes /obj:该class代表对象的字节数
  • bytes/span:每个span占用堆的字节数,也即页数*页大小
  • objects: 每个span可分配的对象个数,也即(bytes/spans)/(bytes/obj)
  • waste bytes: 每个span产生的内存碎片,也即(bytes/spans)%(bytes/obj)

阅读方式如下:

以类型(class)为1的span为例,span中的元素大小是8 byte, span本身占1页也就是8K, 一共可以保存1024个对象。

细心的同学可能会发现代码中一共有66种,还有一种特殊的span:

即对于大于32k的对象出现时,会直接从heap分配一个特殊的span,这个特殊的span的类型(class)是0, 只包含了一个大对象, span的大小由对象的大小决定。

bitmap

bitmap 有好几种:Stack, data, and bss bitmaps,再就是这次要说的heap bitmaps。

在此bitmap的做作用是标记标记arena(即heap)中的对象。一是的标记对应地址中是否存在对象,另外是标记此对象是否被gc标记过。一个功能一个bit位,所以,heap bitmaps用两个bit位。

bitmap区域中的一个byte对应arena区域的四个指针大小的内存的结构如下:

bitmap的地址是由高地址向低地址增长的。

宏观的图为:

bitmap 主要的作用还是服务于GC。

arena中包含基本的管理单元和程序运行时候生成的对象或实体,这两部分分别被spans和bitmap这两块非heap区域的内存所对应着。

逻辑图如下:

spans和bitmap都会根据arena的动态变化而动态调整大小。

内存管理组件

go的内存管理组件主要有:mspan、mcache、mcentral和mheap

  • mspan为内存管理的基础单元,直接存储数据的地方。
  • mcache:每个运行期的goroutine都会绑定的一个mcache(具体来讲是绑定的GMP并发模型中的P,所以可以无锁分配mspan,后续还会说到),mcache会分配goroutine运行中所需要的内存空间(即mspan)。
  • mcentral为所有mcache切分好后备的mspan
  • mheap代表Go程序持有的所有堆空间。还会管理闲置的span,需要时向操作系统申请新内存。

mspan

有人会问:mspan结构体存放在哪儿?其实,mspan结构本身的内存是从系统分配的,在此不做过多讨论。

mspan在上文讲spans的时候具体讲过,就是方便根据对象大小来分配使用的内存块,一共有67种类型;最主要解决的是内存碎片问题,减少了内存碎片,提高了内存使用率。

mspan是双向链表,其中主要的属性如下图所示:

mspan是go中内存管理的基本单元,在上文spans中其实已经做了详细的解说,在此就不在赘述了。

mcache

为了避免多线程申请内存时不断的加锁,goroutine为每个线程分配了span内存块的 缓存 ,这个缓存即是mcache,每个goroutine都会绑定的一个mcache,各个goroutine申请内存时不存在锁竞争的情况。

如何做到的?

在讲之前,请先回顾一下Go的并发调度模型,如果你还不了解,请看我这篇文章

然后请看下图:

大体上就是上图这个样子了。注意看我们的mcache在哪儿呢?就在P上!

知道为什么没有锁竞争了吧,因为运行期间一个goroutine只能和一个P关联,而mcache就在P上,所以,不可能有锁的竞争。

我们再来看看mcache具体的结构:

mcache中的span链表分为两组,一组是包含指针类型的对象,另一组是不包含指针类型的对象。为什么分开呢?

主要是方便GC,在进行垃圾回收的时候,对于不包含指针的对象列表无需进一步扫描是否引用其他活跃的对象(如果对go的gc不是很了解,请看我这篇文章 。

对于 <=32k的对象,将直接通过mcache分配。

在此,我觉的有必要说一下go中对象按照的大小维度的分类。

分为三类:

  • tinny allocations (size < 16 bytes,no pointers)
  • small allocations (16 bytes < size <= 32k)
  • large allocations (size > 32k)

前两类:tiny allocations和small allocations是直接通过mcache来分配的。

对于tiny allocations的分配,有一个微型分配器tiny allocator来分配,分配的对象都是不包含指针的,例如一些小的字符串和不包含指针的独立的逃逸变量等。

small allocations的分配,就是mcache根据对象的大小来找自身存在的大小相匹配mspan来分配。

当mcach没有可用空间时,会从mcentral的 mspans 列表获取一个新的所需大小规格的mspan。

mcentral

为所有mcache提供切分好的mspan。

每个mcentral保存一种特定类型的全局mspan列表,包括已分配出去的和未分配出去的。

还记得mspan的67种类型吗?有多少种类型的mspan就有多少个mcentral。

每个mcentral都会包含两个mspan的列表:

  • 没有空闲对象或mspan已经被mcache缓存的mspan列表(empty mspanList)
  • 有空闲对象的mspan列表(empty mspanList)

由于mspan是全局的,会被所有的mcache访问,所以会出现并发性问题,因而mcentral会存在一个锁。

单个的mcentral结构如下:

假如需要分配内存时,mcentral没有空闲的mspan列表了,此时需要向mheap去获取。

mheap

mheap可以认为是Go程序持有的整个堆空间,mheap全局唯一,可以认为是个全局变量。

其结构如下:

mheap包含了除了上文中讲的mcache之外的一切,mcache是存在于Go的GMP调度模型的P中的,上文中已经讲过了,关于GMP并发模型,可以参考我的文章 。

仔细观察,可以发现mheap中也存在一个锁lock。这个lock是作用是什么呢?

我们知道,大于32K的对象被定义为大对象,直接通过mheap 分配。这些大对象的申请是由mcache发出的,而mcache在P上,程序运行的时候往往会存在多个P,因此,这个内存申请是并发的;所以为了保证线程安全,必须有一个全局锁。

假如需要分配的内存时,mheap中也没有了,则向操作系统申请一系列新的页(最小 1MB)。

Go内存分配流程总结

对象分三种:

  • 微小对象,size < 16B
  • 一般小对象, 16 bytes < size <= 32k
  • 大对象 size > 32k

分配方式分三种:

  • tinny allocations (size < 16 bytes,no pointers) 微型分配器分配。
  • small allocations ( size <= 32k) 正常分配;首先通过计算使用的大小规格,然后使用 mcache 中对应大小规格的块分配
  • large allocations (size > 32k) 大对象分配;直接通过mheap分配。这些大对象的申请是以一个全局锁为代价的,因此任何给定的时间点只能同时供一个 P 申请。

对象分配:

  • size范围在在( size < 16B),不包含指针的对象。 mcache上的微型分配器分配
  • size范围在(0 < size < 16B), 包含指针的对象:正常分配
  • size范围在(16B < size <= 32KB), : 正常分配
  • size范围在( size > 32KB) : 大对象分配

分配顺序:

  • 首先通过计算使用的大小规格。
  • 然后使用mcache中对应大小规格的块分配。
  • 如果mcentral中没有可用的块,则向mheap申请,并根据算法找到最合适的mspan。
  • 如果申请到的mspan 超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的 mspan 放回 mheap 的空闲列表。
  • 如果 mheap 中没有可用 span,则向操作系统申请一系列新的页(最小 1MB)。