Go的内存管理

855 阅读9分钟

内存管理的设计

内存空间有堆区和栈区。栈一般存储局部变量,方法有关的数据,由编译器自动管理,。堆用来存放对象,java和go都是通过垃圾收集器回收,不需要手动对内存进行释放和管理。

内存管理一般包含三个组件

  • 应用程序
  • 内存分配器
  • 垃圾收集器

内存分配器

应用程序通过内存分配器申请内存,内存分配器从堆中初始化相应的内存区域。

分配方法

内存分配器一般包含两种分配方法

  • 线性分配器
  • 空闲链表分配器

线性分配器

Java就是使用的线性分配器的思想。

使用线性分配器时,只需要在内存中维护一个指针,如果用户程序向分配器申请内存,分配器只需要移动指针就可以。

bump-allocator

简单高效的内存方法。但是会有一个弊端,就是指针移动之后,如果之前分配的内存被回收掉了,无法重新分配利用

bump-allocator-reclaim-memory

所以需要配合特定的回收算法,例如标记压缩、复制回收和分代回收等算法。它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并。

空闲链表分配器

Go使用的就是空闲链表分配器思想

free-list-allocator

空闲链表分配器通过链表维护了空闲内存,保证了内存可以被复用。但是效率很低,是O(n),因为分配内存需要遍历链表,找到大于申请内存的内存块。

所以遍历选择内存也会有不同的策略,常见的有四种

  • 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
  • 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
  • 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

前三种比较简单,Go使用的是类似于隔离适应的策略,隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

例如把内存块分为2,4,8字节,那么我申请6字节的内存,就不需要遍历2,4字节的链表了。

segregated-list

分配机制

Go使用了线程缓存分配的机制,它的核心理念就是将对象按照大小分类,并按照类别实施不同的分配策略

Go将对象分为三类

  • 微对象 (0, 16B)
  • 小对象 [16B, 32KB]
  • 大对象 (32KB, +∞)

Go的大部分对象都属于小对象,分别处理大对象,小对象能提高内存分配器的性能。

这里对象的分类和上面介绍的隔离适应没有关系,隔离适应对应的是下文的mspan

Go内存分配器的组成

分配器由3种组件构成:线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap),对应的结构体分别是mcache, mcentral, mheap

go-memory-layout

下面关于对三个组件的介绍,可以看完下文的内存区域介绍再返过来看。

mcache

每个工作线程都会绑定一个mcache,缓存可用的mspan资源,这样就可以直接给Goroutine分配,所以不存在竞争的情况,不会消耗锁资源。

mcache-and-mspans

mcentral

为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。

mcentral被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源

mheap

mheap:代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存。

mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。

mheap主要用于大对象的内存分配,以及管理mspan。

同时我们也看到,mheap中含有所有规格的mcentral,所以,当一个mcachemcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan

垃圾收集器

垃圾收集算法

Go采用的是追踪式垃圾收集的标记清除算法

GC过程

  1. 清理终止阶段 暂停程序
  2. 标记阶段 开启写屏障、辅助GC,采取三色标记法,根节点入队,进行引用链查找
  3. 标记终止阶段 暂停程序,清理处理器的线程缓存
  4. 清理阶段 关闭写屏障,进行后台并发清理,申请内存也会触发清理

怎么解决并发可达性对象消失问题

对象消失问题有两种条件

  1. 插入了黑色对象到白色对象的新引用
  2. 删除了灰色对象到白色对象的引用

Go通过的插入写屏障,删除写屏障解决了这两个问题,当对象新增、更新时,将对象着色为灰色。

垃圾收集的触发

后台触发

运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的 Goroutine,大多数时间是陷入休眠的,定期被唤醒

手动触发

runtime.GC()

申请内存

  1. 当不存在空闲空间时
  2. 申请分配32kb以上的大对象时

Go的内存区域

Go在1.10以前的版本使用的是线性内存,即连续的内存空间,但是在之后的版本使用了稀疏的内存空间替代了线性内存。

线性内存

heap-before-go-1-10

可以看出内存被分为了三部分

  • spans
  • bitmap
  • arena

arena

arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,分配了512GB。它把内存分割成8KB大小的页。

这里介绍下mspan的概念,mspan就是一些8KB页的组合,一会要详细讲下mspan

bitmap

bitmap用来标识arena哪里保存了对象,每1字节对应了arena4个指针大小(指针大小为8B),即每个字节都会表示堆区中的32字节是否空闲

所以bitmap区域的大小是512GB/(4*8B)=16GB

spans

spans存放mspan的指针,所以spans区域的大小就是512GB/8KB*8B=512MB

512GB/8KB是页数,*8B是指针大小。

mspan

这里介绍下mspan,不要把它和spans混淆。

mspan是Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。

一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双向链表

spanclass跨度类与sizeclass
type mspan struct {
	...
	spanclass   spanClass
	...
}

由于mspan结构体很长,后面所有代码片段都只截取重要的区域

mspan有个属性,叫spanClass(跨度类),他决定了mspan存储的对象大小和个数

page mspan

每个mspan按照sizeClass(不是spanclass)的大小分割成若干个object,每个object可存储一个对象。

Size_Class = Span_Class / 2

意思就是object大小等于sizeclass,spanclass是object大小的两倍

Size Class共有67种,每种mspan分割的object大小是8*2n的倍数,这个是写死在代码里的。

代码位置:runtime/sizeclasses.go

// path: /usr/local/go/src/runtime/sizeclasses.go

const _NumSizeClasses = 67

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

比如Size Class等于3,object大小就是32B。 32B大小的object可以存储对象大小范围在17B~32B的对象。而对于微小对象(小于16B),分配器会将其进行合并,将几个对象分配到同一个object中。sizeclass为0的代表的是大对象,这个要去堆里单独分配,不需要通过mspan的机制。也就是说微对象和小对象都是使用mspan进行分配。

由于mspan是由页组成,大小为8的整数倍,所以对于不同的sizeclass,会有着不同的浪费内存的现象。

稀疏内存

  • 优点:使用稀疏的内存布局不仅能移除堆大小的上限,还解决了 C 和 Go 混合使用时的地址空间冲突问题
  • 缺点:失去了内存的连续性,使内存管理变得更加复杂

逃逸分析

逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。减少了堆的压力