golang内存分配

标签:Go语言首次发布:2024-03-25最近修改:2024-04-02

内存分配思想

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。

  • 函数调用的参数、返回值以及局部变量基本上会被分配到栈上,这部分内存会由编译器进行管理;
  • 对于堆区内存,不同编程语言使用不同的方法管理堆区的内存。C++ 等编程语言会由使用者主动申请和释放内存,Go 以及 Java 等编程语言会由使用者和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。

Golang 的内存分配器借鉴了 TCMalloc 的思想,尽量减少在多线程模型下,锁的竞争开销,来提高内存分配的效率。

TCMalloc

TCMalloc,其实就是 thread cache malloc 的缩写。看下图大概了解一下 TCMalloc 的思想。

span 这个单词的直译是跨度的意思,在当前的语境下,可以理解为一段连续的内存。下面是一些名词解释:

  1. PageHeap:这是 TCMalloc 中用于管理操作系统页面的部分。它负责较大的分配内存,通常以页(一般为 4KB)为单位。在 PageHeap 当中将多个页组合成 spans,因此 spans 可以是一个或多个连续的页。
  2. Span:一个 Span 是由 PageHeap 管理的连续页的集合。因此 Span 可以是单个页面,也可以是多个连续页面的组合。
  3. CentralCache:这是 TCMalloc 中的一个中心缓存,用于存放固定大小的内存块,这些内存块在不同大小的 size class 之间分组。中心缓存的作用是给所有的线程的 ThreadCache 分配内存,当某个线程中的内存不够时,向 CentralCache 申请内存时是需要加锁的。
  4. size class:为了高效地复用和分配内存,内存块被分为不同大小的 size class。每个 size class 包含大小相同的内存块,以满足不同大小的内存请求。
  5. ThreadCache:这是 TCMalloc 中为每个线程分配的本地缓存,用于提供快速的内存分配。当一个线程需要分配内存时,它首先尝试从其 ThreadCache 中获取内存,由于是从本地线程申请内存,所以申请内存时不需要加锁。
  6. Application:应用程序,它是内存分配的终端用户,会从 TCMalloc 请求内存来满足其运行需要。

基于 TCMalloc 思想进行内存分配的过程通常如下:

  1. 当应用程序请求分配内存时,它首先检查本地线程的ThreadCache中是否有适当大小的内存块。如果有,则直接分配,这是最快的途径。
  2. 如果线程的ThreadCache没有可用的内存块,则尝试从CentralCache中获取内存。CentralCache中的内存块是预先按size class分类的。
  3. 如果CentralCache中也没有可用的内存块,则从PageHeap中分配一个或多个页面,这会形成一个新的Span,然后将其放入CentralCache
  4. 在需要释放内存时,内存块会被放回到对应线程的ThreadCache。如果ThreadCache已满,它会被移动到CentralCache,并可能最终返回到PageHeap以供将来使用。

这就是基于 TCMalloc 思想进行内存分配的大致流程。总的来说,就是先向操作系统申请一大块内存,然后再对这一大块内存进行瓜分,分别分配给 PageHeap、CentralCache 和 ThreadCache。应用程序需要内存的时候就向上级申请,上级内存不够就向上上级申请,上上级不够就向上上上级申请。由于向本地的线程的 ThreadCache 申请内存时无需加锁,所以这让内存分配的效率有了一定的提升。

内存分配对象的划分

golang 根据对象大小将它们分成微对象、小对象和大对象,并且会根据对象大小的不同选择不同的内存分配方案。不同大小对象的划分规则如下:

  • 微对象 (0, 16B) :先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;
  • 小对象 [16B, 32KB]:依次尝试使用线程缓存、中心缓存和堆分配内存;
  • 大对象 (32KB, +∞):直接在堆上分配内存;

Go 内存管理组件

Go 语言的内存分配器包含内存管理单元线程缓存中心缓存页堆几个重要组件,接下来将介绍这几种最重要组件对应的数据结构 mspanmcachemcentralmheap

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器(P)都会分配一个线程缓存 mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 mspan

内存管理单元:mspan

mspan是 Go 语言内存管理的基本单元。每个 mspan 都对应一个大小等级,总共 67 个等级。mspan 结构体中包含 nextprev 两个字段,它们分别指向了前一个和后一个 mspan:

go
// src\runtime\mheap.gotype mspan struct {    next *mspan    prev *mspan    list *mSpanList    ...}type mSpanList struct {    first *mspan    last  *mspan}

mspan 串联后会构成如下双向链表,运行时会使用 mSpanList 存储双向链表的头节点和尾节点,并在 mcache 以及 mcentral 中使用。

image-20240324203132837

每个 mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页(一般是 4KB)的整数倍,mspan 会使用下面这些字段来管理内存页的分配和回收:

go
type mspan struct {    startAddr uintptr // 起始地址    npages    uintptr // 页数    freeindex uintptr    allocBits  *gcBits    gcmarkBits *gcBits    allocCache uint64    ...}
  • startAddrnpages:确定 mspan 管理的多个页所在的内存,每个页的大小都是 8KB。知道了内存的起始地址、每页的大小和数量就能确定 mspan 所管理的一段连续内存所在的区域;
  • freeindex:是一个索引,指示从哪个位置开始查找下一个空闲对象。这个索引可以加速内存分配过程,因为分配器知道无需从头开始检查内存块;
  • allocBits:一个位图,用于表示 mspan 管理的内存中的对象是否已经被分配。如果相应的位被设置为 1,说明对应的对象已经被分配,如果是 0,则对象空闲;
  • gcmarkBits:也是一个位图,用于垃圾回收过程中标记对象是否可以访问(即是否被 “标记”)。在垃圾回收时,这些位用来决定哪些对象应该被回收;
  • allocCache:allocBits 的补码,可以用于快速查找内存中未被使用的内存;

image-20240324211550416

所以,当程序中的某个对象需要申请内存时,内存分配器会在相应大小的 mspan中根据相应的字段查找空余的空间来分配给这些对象。

跨度类

spanClass 是 mspan 的跨度类,它决定了内存管理单元中存储的对象大小和个数。这么讲可能有点抽象,通俗一点就是:mspan 这个内存管理单元是分不同大小的,有小一点的 mspan,也有大一点的 mspan,为了区分这些不同的 mspan,需要用一个唯一的 ID 去标识不同的 mspan,这个 ID 就是跨度类。

go
type mspan struct {    spanclass   spanClass    ...}type spanClass uint8

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象,并且包含特定数量的页数和对象。67 种跨度类是从 8B 到 32KB,具体的详细信息可以在 go 源码中找到(src\runtime\sizeclasses.go)。下面是一张简化版的表:

下面是各个列的含义:

  1. class:可以认为是跨度类的 ID;
  2. bytes/obj:一次对外提供的内存大小。比如程序需要申请一个 40 字节的对象,内存分配器分不会分配 40 字节的内存,而是分配 48 字节的内存。
  3. bytes/span:当前的跨度类所对应的 mspan 的大小。
  4. objects:当前 mspan 一共能存放的对象的个数。以 class=7 为例,mspan 的大小为 8192,那么 8192 / 80=102.4,也就是当前的 mspan 可以放下 102 个大小为 80 字节的对象。
  5. tail waste:mspan 放满后的浪费内存。
  6. max waste:最大可能浪费的空间所占百分比。还是以 class=7 为例,如果每个分配的对象都是 65 字节(上一档位的 obj size + 1),那么分配 102 后总共浪费的内存就是:(80 - 65) 102 + 32 = 1562字节,浪费的内存占比就是(1562 / 8192) 100%=19.07%

关于 mspan 的总结

  1. mspan 按照大小进行分类共有 67 种,这 67 个 mspan 通过双向链表进行连接。
  2. 每一种 mspan 都是由一个或多个 8KB 的页组成的一段连续的内存。
  3. 对于每一种 mspan,都有与之对应的对象(obj)的大小。当应用程序需要申请内存时,根据申请内存的大小来找到对应的 mspan,然后通过 mspan 上记录的一些字段值来进行扫描,到扫描到空闲对象的时候,就把该对象对应的内存空间分配给应用程序。

线程缓存:mcache

mcache是与 Go 协程调度模型 GMP 中的 P(调度器)所绑定,而不是和线程 M 绑定,因为在 Go 调度的 GMP 模型中,真正可运行的线程 M 的数量与 P 的数量一致,即 GOMAXPROCS 个,跟 P 绑定节省了 P 移动到其他 M 上去的 mcahe 的切换开销。每个线程分配一个 mcache 用于处理微对象和小对象的分配。因为是每个线程独有的,所以不需要加锁申请内存。

image-20240325111409933

mcache在刚刚被初始化时是不包含 mspan 的,只有当用户程序申请内存时才会从上一级组件获取新的 mspan 满足内存分配的需求。并且:

  • mcache 会持有 tiny 相关字段用于微对象内存分配。
  • mcache 会持有 mspan 用于小对象内存分配。

mcache 的数据结构如下:

text
type mcache struct {    tiny       uintptr // 申请tiny对象的起始地址    tinyoffset uintptr // 从tiny地址开始的偏移量    tinyAllocs uintptr // tiny对象分配的数量    alloc [numSpanClasses]*mspan // 待分配的mspan列表,通过spanClass作为索引}

微分配器只会给非指针类型的对象分配为微内存。tiny,tinyoffset 和 tinyAllocs 是跟 tiny 微对象分配相关的参数;alloc 是待分配的 mspan 列表,不同规格的 mspan 通过 spanClass 值作为索引。

如下是 runtime.mcache 的内存结构,主要包含两部分的内容,Tiny 对象和小对象的分配空间,小对象的内存分配空间是由 136(68 2+1)个 spanClass 规格大小的 mspan 组成的列表。

关于 mcache 的总结

  1. 一个 mcache 和一个 P(调度器)进行绑定,当这个 P 与当前 M 线程解绑,并和其它 M 线程进行绑定时,不需要进行 mcache 重新分配,节省了 goroutine 获取本地的 mcache 时的上下文切换所带来的性能开销。
  2. 每一个 mcache 都包含 tiny 对象和小对象的内存空间。其中 tiny 对象除了要满足对象的大小要小于 16B 字节以外,还要求这个对象不包含指针。
  3. alloc 列表有 136 个索引,noscan 指针指向的 mspan 是用于分配没有内存引用的对象;scan 指针指向的 mspan 是用于分配有内存引用的对象。

中心缓存:mcentral

mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁。

text
type mcentral struct {    spanclass spanClass    partial  [2]spanSet    full     [2]spanSet}
  • spanclass 表示这个 mcentral 的类型,不同跨度类的 mspan 对应有不同的 mcentral 管理。Size Class 总共有 0,8B,16B,24B,32B 到 32KB 共 68 种,因此 Go 内存分配器的中央缓存模型 MCentral 总共有 67 种 mcentral(会排除掉 0)。

  • partial 和 full 分别维护空闲的 mspan 集合和已经被使用的 mspan 集合。mcache 向 mcentral 申请资源,当然是从 partial 集合获取。partial 和 full 都是一个[2]spanSet 类型,也就每个 partial 和 full 都各有两个 spanSet 集合,这是为了给 GC 垃圾回收来使用的,其中一个集合是已扫描的,另一个集合是未扫描的。

image-20240325122946018

页堆:mheap

mheap是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。如下是页堆的内存示意图

image-20240325123506263

总结

Go 内存分配器中使用到的多级缓存机制,是程序开发中常用的设计理念,将对象根据大小分为不同规格,通过提高数据局部性和细粒度内存的复用率,能够有效提升不同大小对象的内存分配的整体效率。

附录(67种跨度类)

class bytes/obj bytes/span objects tail waste max waste min align
1 8 8192 1024 0 87.50% 8
2 16 8192 512 0 43.75% 16
3 24 8192 341 8 29.24% 8
4 32 8192 256 0 21.88% 32
5 48 8192 170 32 31.52% 16
6 64 8192 128 0 23.44% 64
7 80 8192 102 32 19.07% 16
8 96 8192 85 32 15.95% 32
9 112 8192 73 16 13.56% 16
10 128 8192 64 0 11.72% 128
11 144 8192 56 128 11.82% 16
12 160 8192 51 32 9.73% 32
13 176 8192 46 96 9.59% 16
14 192 8192 42 128 9.25% 64
15 208 8192 39 80 8.12% 16
16 224 8192 36 128 8.15% 32
17 240 8192 34 32 6.62% 16
18 256 8192 32 0 5.86% 256
19 288 8192 28 128 12.16% 32
20 320 8192 25 192 11.80% 64
21 352 8192 23 96 9.88% 32
22 384 8192 21 128 9.51% 128
23 416 8192 19 288 10.71% 32
24 448 8192 18 128 8.37% 64
25 480 8192 17 32 6.82% 32
26 512 8192 16 0 6.05% 512
27 576 8192 14 128 12.33% 64
28 640 8192 12 512 15.48% 128
29 704 8192 11 448 13.93% 64
30 768 8192 10 512 13.94% 256
31 896 8192 9 128 15.52% 128
32 1024 8192 8 0 12.40% 1024
33 1152 8192 7 128 12.41% 128
34 1280 8192 6 512 15.55% 256
35 1408 16384 11 896 14.00% 128
36 1536 8192 5 512 14.00% 512
37 1792 16384 9 256 15.57% 256
38 2048 8192 4 0 12.45% 2048
39 2304 16384 7 256 12.46% 256
40 2688 8192 3 128 15.59% 128
41 3072 24576 8 0 12.47% 1024
42 3200 16384 5 384 6.22% 128
43 3456 24576 7 384 8.83% 128
44 4096 8192 2 0 15.60% 4096
45 4864 24576 5 256 16.65% 256
46 5376 16384 3 256 10.92% 256
47 6144 24576 4 0 12.48% 2048
48 6528 32768 5 128 6.23% 128
49 6784 40960 6 256 4.36% 128
50 6912 49152 7 768 3.37% 256
51 8192 8192 1 0 15.61% 8192
52 9472 57344 6 512 14.28% 256
53 9728 49152 5 512 3.64% 512
54 10240 40960 4 0 4.99% 2048
55 10880 32768 3 128 6.24% 128
56 12288 24576 2 0 11.45% 4096
57 13568 40960 3 256 9.99% 256
58 14336 57344 4 0 5.35% 2048
59 16384 16384 1 0 12.49% 8192
60 18432 73728 4 0 11.11% 2048
61 19072 57344 3 128 3.57% 128
62 20480 40960 2 0 6.87% 4096
63 21760 65536 3 256 6.25% 256
64 24576 24576 1 0 11.45% 8192
65 27264 81920 3 128 10.00% 128
66 28672 57344 2 0 4.91% 4096
67 32768 32768 1 0 12.50% 8192