Go 的内存是自动管理的,我们可以随意定义变量直接使用,不需要考虑变量背后的内存申请和释放的问题。本文意在搞清楚 Go 在方面帮我们做了什么,使我们不用关心那些复杂内存的问题,还依旧能写出较为高效的程序。

Pool

程序动态申请内存空间,是要使用系统调用的,比如 Linux 系统上是调用 mmap 方法实现的。但对于大型系统服务来说,直接调用 mmap 申请内存,会有一定的代价。比如:

  • 内核态与用户态之间的切换浪费系统资源
  • 频繁申请小块内存空间容易造成内存碎片
  • 为了保证内存访问具有良好的局部性,开发者需要投入大量的精力去做优化,这是一个很重的负担。

解决方案:对象池(缓存)。

假设系统需要频繁动态申请内存来存放一个数据结构,比如 [10]int 。那么我们完全可以在程序启动之初,一次性申请几百甚至上千个 [10]int 。这样完美的解决了上面遇到的问题:

  1. 不需要频繁申请内存了,而是从对象池里拿,程序不会频繁进入内核态。
  2. 因为一次性申请一个连续的大空间,对象池会被重复利用,不会出现碎片。
  3. 程序频繁访问的就是对象池背后的同一块内存空间,局部性良好。

Golang内存管理

Golang 的内存管理本质上就是一个内存池,只不过内部做了很多的优化。比如自动伸缩内存池大小,合理的切割内存块等等。

内存池mheap

Golang的程序在启动时,会一次性从操作系统申请一大块内存作为内存池。这块内存空间会放在一个叫mheap的struct中管理,mheap负责将这一块内存切割成不同的区域,并将其中一部分的内存切割成合适的大小。

关于mheap的几个重要概念:

page:内存页,一块8K大小的内存空间。

span: 一个或者多个连续的page。

sizeclass:空间规格,标记span中的page应该如何使用。

object:对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object 。假设 object 的大小是 16B , span 大小是 8K ,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object 。所谓内存分配,就是分配一个 object 出去。

示意图:

内部的整体内存布局如下图所示:

  • mheap.spans: 存储page和span信息,比如一个span的起始地址,page数目。
  • mheap.bitmap: 存储着各个span中对象的标记信息,比如对象是否可回收。
  • mheap.arena_start: 将要分配给应用程序使用的空间。

mcentral

用途相同的span会以链表的形式组织在一起。这里的用途用sizeclass来表示。

比如当分配一块大小为 n 的内存时,系统计算 n 应该使用哪种 sizeclass ,然后根据 sizeclass 的值去找到一个可用的 span 来用作分配。其中 sizeclass 一共有 67 种(Go1.5),如图所示:

找到合适的 span 后,会从中取一个 object 返回给上层使用。这些 span 被放在一个叫做 mcentral 的结构中管理。

mheap 将从 OS 那里申请过来的内存初始化成一个大 span (sizeclass=0)。然后根据需要从这个大 span 中切出小 span ,放在 mcentral 中来管理。大 span 由 mheap.freelarge 和 mheap.busylarge 等管理。如果 mcentral 中的 span 不够用了,会从 mheap.freelarge 上再切一块,如果 mheap.freelarge 空间不够,会再次从 OS 那里申请内存重复上述步骤。下面是 mheap 和 mcentral 的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
GO
type mheap struct {

// other fields

lock mutex

free [_MaxMHeapList]mspan // free lists of given length, 1M 以下

freelarge mspan // free lists length >= _MaxMHeapList, >= 1M

busy [_MaxMHeapList]mspan // busy lists of large objects of given length

busylarge mspan // busy lists of large objects length >= _MaxMHeapList

central [_NumSizeClasses]struct {// _NumSizeClasses = 67

mcentral mcentral

// other fields

}

// other fields

}

// Central list of free objects of a given size.

type mcentral struct {

lock mutex // 分配时需要加锁

sizeclass int32 // 哪种 sizeclass

nonempty mspan // 还有可用的空间的 span 链表

empty mspan // 没有可用的空间的 span 列表

}

mcache

mcental中有一个lock字段,在高并发场景下必要时用锁来避免冲突。

但是锁是低效的,在高并发的服务中,它会使内存申请成为整个系统的瓶颈;所以在mcentral的前面又加了一层mchache。

每一个 mcache 和每一个处理器(P) 是一一对应的,也就是说每一个 P 都有一个 mcache 成员。 Goroutine 申请内存时,首先从其所在的 P 的 mcache 中分配,如果 mcache 没有可用 span ,再从 mcentral 中获取,并填充到 mcache 中。

从 mcache 上分配内存空间是不需要加锁的,因为在同一时间里,一个 P 只有一个线程在其上面运行,不可能出现竞争。没有了锁的限制,大大加速了内存分配。

其他优化

Tiny对象

面提到的 sizeclass=1 的 span,用来给 <= 8B 的对象使用,所以像 int32 , byte , bool 以及小字符串等常用的微小对象,都会使用 sizeclass=1 的 span,但分配给他们 8B 的空间,大部分是用不上的。并且这些类型使用频率非常高,就会导致出现大量的内部碎片。

所以 Go 尽量不使用 sizeclass=1 的 span, 而是将 < 16B 的对象为统一视为 tiny 对象(tinysize)。配时,从 sizeclass=2 的 span 中获取一个 16B 的 object 用以分配。如果存储的对象小于 16B ,这个空间会被暂时保存起来 (mcache.tiny 字段),下次分配时会复用这个空间,直到这个 object 用完为止。

大对象

如上面所述,最大的 sizeclass 最大只能存放 32K 的对象。如果一次性申请超过 32K 的内存,系统会直接绕过 mcache 和 mcentral,直接从 mheap 上获取,mheap 中有一个 freelarge 字段管理着超大 span。