23 内存分配

[TOC]

内存分配

程序的运行离不开在内存中存储、组织数据,存储在内存中的数据可以更快被访问。

  • 内存是有限的,所有要合理安排、组织、管理、释放内存。

  • Go 通过细微的对象切割、多级缓存、位图管理实现了对内存的精细化管理,加快内存访问速度,减少内存碎片。

Go 有栈和堆的概念,栈就是函数使用的栈,堆为GC堆。

  • Go是内存安全的语言,所有变量都分配到GC堆上,栈只作为调用栈使用。

  • 这种方式对性能有很大影响,需要GC的对象急剧增加,会使得STW时间变长。

  • 通过 go tool compile -N -m test.go 查看 静态分析 escape Analyse的日志(-N 禁止inlining,-m 打印verbose),也可以通过 go tool compile -N -S -l test.go 看内存分配的汇编代码。

  • Go编译器会动态分辨出哪些变量分配在堆上、哪些在栈上,以此提高GC效率,对开发者来说是透明的。

  • Go 1.3之后使用的是连续栈,栈空间是一块连续的内存,不是由链表组织的零散内存块,当堆栈需要增长时,运行时会创建一个更大的新堆栈、将旧堆栈的内容复制到新堆栈、重新调整每个复制的指针以指向新地址、销毁旧堆栈。

内存分级管理

Go 运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法(Thread-Caching Malloc)。

  • 把内存分为多级管理,从而降低锁的粒度。

  • 将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以此避免不同线程对全局内存池的频繁竞争。

  • Go 在程序启动时向操作系统申请一块内存,这时只是一段虚拟的地址空间,不会真正地分配内存,Go会切成小块后自行管理。

数据范围(span)与元素(element)

Go 将内存按照数据范围(span)分成了67个级别。

  • 1至66级的大小是固定的,0级用于存储特大对象所以大小不固定。

  • 具体对象需要分配内存时,按照具体对象的大小为依据,分配比元素大但是最接近元素大小的级别。如对象 cat 是11个字节,则分配的内存是按照元素是16个字节的第2级的 span 为8192字节的连续内存空间,这个 span 可以存储512个元素。

  • 这种内存分配方式会不可避免地导致内存的浪费,就如同 cat 是11个字节但是分配了16个字节,浪费了5个字节。

范围等级
元素大小
范围大小
可存元素数量

1

8

8192

1024

2

16

8192

512

3

32

8192

256

4

48

8192

170

5

64

8192

128

...

...

...

...

65

28672

57344

2

66

32768

32768

1

  • 按照这个等级划分,代码中使用int32是4个字节,但是按照1等级分配内存,实际是使用了8字节空间,这样浪费了4个字节造成大量的内存碎片,因此 Go 不会使用 等级为1 的 span, 而是将小于16字节的对象为统一视为小对象。分配时,从等级2的 span 中获取一个16字节的空间用以分配。如果存储的对象小于16字节,这个空间会被暂时保存起来,下次分配时会复用这个空间,直到这个空间用完为止。

三级对象管理

Go采取三级对象管理结构:mcache、mcentral、mheap对span进行管理,加速对span 元素的访问和分配。

  • mcache:每个逻辑处理器P都存储一个本地span缓存,协程需要内存时直接使用mcache,在一个时刻只有一个协程运行在逻辑处理器P上,所以这个内存是安全的。mcache包含每种大小规格的span各一个。

  • mcentral:所有逻辑处理器P共享mcentral,mcentral为所有mcache提供切分好的mspan资源。每个mcentral都包含两个mspan的链表,empty mspanList 存放没有空闲元素或span已经被mcache缓存的span链表,nonempty mspanList 存放有空闲元素的span链表。做出区分是为了更快分配span到mcache中,1至66级的span都有一个mcentral用于管理span链表,而所有级别的mcentral都是一个数组由mheap进行管理。

  • mheap:mheap的作用是管理central、分配0级大对象,mheap实现对虚拟内存线性地址空间的精准管理,建立span与具体线性地址空间的联系,保存分配的位图信息,是管理内存的最核心单元。

mheap和mcentral

mheap和mcentral

四级内存块管理

根据对象大小,Go将堆内存分成HeapArea、chunk、span、page四种内存块进行管理,不同的内存块用于不同的场景,提高内存管理的效率。

  • HeapArea 堆区:占据空间最大,它的大小与操作系统相关。

  • chunk:占据512KB。

  • span:根据67个级别大小分配。

  • page:占据8KB。

对象分配

不同大小的对象分配到不同等级的span中

  • 运行时分配对象的逻辑在mallocgc函数,malloc 是分配内存,gc 是垃圾回收,mallocgc函数除了分配内存还会为垃圾回收做一些位图标记工作。

  • Go 按照对象大小将对象划分为微小对象(不包括指针小于等于16字节)、小对象(不包括指针大于16小于等于32字节)、大对象(不包括指针大于32字节)。

  • Go内存管理的层级从最下到最上可以分为:mspan -> mcache -> mcentral -> mheap -> heapArena。

类别
大小字节

微小对象

tiny object (0, 16)

小对象

small object (16, 32)

大对象

large object (32, +)

微小对象 小对象 分配

微小对象是小于等于16字节,划分微小对象是为了处理极小的字符串和独立转义变量。

  • 微小对象会被放入级别2的span中,2级span大小为16字节,但是微小对象分配会做优化,尽可能减少浪费。

  • 微小对象按照2、4、8的规则进行字节对齐,如1字节大小的对象会被分配2字节内存,3字节大小的对象会被分配4字节内存。

  • 正式分配内存之前会查看元素中是否有空余的空间,当前要分配4个字节,而元素目前已用12字节,则空余空间能够容纳4个字节,就会将这个微小对象分配到这个元素中。

小对象是大于16小于等于32字节

微小对象与小对象分配会经历 mcache -> mcentral -> mheap缓存查找 -> mheap基数树查找 -> 操作系统分配过程

  • mcache缓存:如果当然元素空间不足以容纳,就会利用mcache的缓存位图从mcache查找span中下一个可用的元素。因此微小对象分配是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

  • mcentral中查找:如果当前span中没有可以使用的元素就需要从mcentral中查找,mcentral是不安全的,查找时会进行同步,会在nonempty mspanList 和 empty mspanList中查找可用的span。nonempty mspanList 也会被遍历是因为有些span虽然被gc标记为空闲但还没有被清理,这些span被清理后还可以使用。

  • mheap缓存查找:如果在mcentral中找不到可以使用的span还会到mheap缓存中查找。Go不同版本在这里的实现不同,在1.14之后每个逻辑处理器P都会维护一份page cache,mheap首先会查找每个逻辑处理器P中的page cache,page cache中也维护了一个位图cache,mheap会在cache查找是否有可用的内存,如果有就会用于构建span。

  • mheap基数树查找:分配的page cache过大或者在逻辑处理器P的cache中没有找到可用的page,就会对mheap加锁,在mheap管理的虚拟地址的空间位图中查找是否可用的page,这涉及Go对线性地址空间的位图管理。管理线性地址空间的位图结构叫作基数树(radix tree),由父节点包含字节点若干信息,树中的每个节点都对应一个pallocSum,最底层的叶子节点对应的pallocSum包含一个chunk的信息(512*8KB),除叶子节点外的都节点都包含连续8个子节点的内存信息,越上层的节点对应的内存越多。Go基数树查找做了优化,用一个searchAddr字段记录内存查找过的位置,下次查找跳过已查找过的位置。

  • 操作系统内存分配:mheap基数树中找不到可用的连续内存时,要从操作系统获取内存。Go规定每次向操作系统申请内存大小必须为HeapArea 堆区 的倍数,堆区初始内存大小和系统相关。

内存分配效率对比

内存分配效率对比

大对象分配 大于32字节

大对象是大于32字节的对象

  • 大对象分配不经历mcache、mcentral,直接分配在mheap,大对象分配会有可能经历mheap基数树查找 -> 操作系统内存分配的过程,每个大对象都是一个级别为0的span。

Last updated