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
四级内存块管理
根据对象大小,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