Go语言开发——内存管理与性能优化 | 青训营笔记

124 阅读9分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

课程内容与选题缘由

好几节课程断断续续讲了Go内存相关的知识,因此将所有内存板块的笔记整理成一篇文章。

Go内存分配

Golang的内存分配在heap(堆内存)上进行,事先将从os中申请到的内存进行提前分块,然后对象需要分配内存的时候,就回一个跟对象尺寸最接近的内存块,就算完成内存分配。

内存申请与分块

Golang在运行时通过内存分配器分配内存给对象。内存分配器从OS中申请大块的内存,维护一个堆内存Heap,当程序需要分配内存时,从堆内存Heap中分配;如果堆内存Heap不够用,再向OS申请新的内存。 这样可以避免多次访问OS分配内存带来的性能损失,同时保证内存分配效率。

具体过程如下:

  • 调用系统调用 mmap() 向 OS 申请一大块内存,例如 4 MB
  • 先将内存划分成大块,例如 8 KB,称作 mspan
  • 再将大块继续划分成特定大小的小块,用于对象分配
  • noscan mspan: 分配不包含指针的对象 —— GC 不需要扫描
  • scan mspan: 分配包含指针的对象 —— GC 需要扫描
  • 对象分配:根据对象的大小,选择合适的块返回

1

内存缓存

Golang内存管理形成了一个多级缓存机制。

从 OS 分配得的内存被内存管理回收后,也不会立刻归还给 OS,而是在 Go runtime 内部先缓存起来,从而避免频繁向 OS 申请内存。

内存分配的路线图如下

对内存做了很多级不同的缓存,从而加快整体内存分配的速度

2

mspan, mcache 和 mcentral 构成了内存管理的多级缓存机制。

内存管理优化

内存管理存在的问题:

  • 对象分配函数是最频繁调用的函数之一,内存分配的过程占用很多CPU

  • 对象的内存分配过程中,小对象的占比比较高

  • 内存分配的过程比较耗时

    • 分匹配路径为:g → m → p → mcache → mspan → memory block → return pointer

因此在分配小对象的时候要做特定的优化

字节跳动内存管理优化方案

  • Balanced GC

核心:将 noscan 对象在 per-g allocation buffer (GAB) 上分配,并使用移动对象 GC 管理这部分内存,提高对象分配和回收效率

3

  • 每个 g 会附加一个较大的 allocation buffer (例如 1 KB) 用来分配小于 128 B 的 noscan 小对象
  • 分配对象时,根据对象大小移动 top 指针并返回,快速完成一次对象分配
  • 同原先调用 mallocgc() 进行对象分配的方式相比,balanced GC 缩短了对象分配的路径,减少了对象分配执行的指令数目,降低 CPU 使用

从 Go runtime 内存管理模块的角度看,一个 allocation buffer 其实是一个大对象。本质上 balanced GC 是将多次小对象的分配合并成一次大对象的分配

因此,当 GAB 中哪怕只有一个小对象存活时,Go runtime 也会认为整个大对象(即 GAB)存活。

为此,balanced GC 会根据 GC 策略,将 GAB 中存活的对象移动到另外的 GAB 中,从而压缩并清理 GAB 的内存空间,原先的 GAB 空间由于不再有存活对象,可以全部释放,如下图所示

4

自动内存管理

自动内存管理的概念

开发者不需要手动分配和释放内存,Golang采用了垃圾回收算法(Garbage Collection)来自动管理内存。

同时,Golang的自动内存管理使用了多线程并行来回收内存实现实时性,因此也避免了 Double-free problem 和 Use-after-free problem:

  • Double-free problem是指一个内存块被多次释放,可能导致程序崩溃或者对系统造成更严重的影响。
  • Use-after-free problem是指内存块已经释放,但是程序仍然在使用该内存块,可能导致程序读取错误的数据或崩溃。

自动内存管理的三个任务

  • 为新对象分配空间
  • 找到存活对象
  • 回收死亡对象的内存空间

垃圾回收算法—Garbage Collection(GC)

垃圾回收算法是Golang实现自动内存管理的关键。

GC算法大类

5

Mutator: 业务线程,分配新对象,修改对象指向关系

Collector: GC 线程,找到存活对象,回收死亡对象的内存空间

Collector 必须感知对象指向关系的改变,否则无法完成内存回收。

已经被标记的对象,其新指向的对象也必须被标记。

6

  • Serial GC

只有一个collector 7

  • Parallel GC

并行GC,支持多个collectors同时回收

8

  • Concurrent GC

并发GC,支持 mutators 和 collectors 同时执行

9

追踪垃圾回收—Tracing Garbage Collection

追踪回收算法也是一种自动内存管理算法。

它的基本思想是,通过持续追踪程序中所有的对象,以及它们的引用关系,来确定哪些对象可以被回收。当没有对象引用该对象时,该对象就可以被回收。

但是,这种方法需要定期暂停程序执行,执行垃圾回收任务,因此会带来一定的额外时间开销

因此,追踪回收算法是以一定的额外时间开销来保证内存使用的正确性和安全性。

  • 对象被回收的条件:不可达对象

  • 垃圾回收的过程:

    • 标记根对象 (GC roots): 静态变量、全局变量、常量、线程栈等
    • 标记:找到所有可达对象
    • 回收所有不可达对象占据的内存空间
  • 追踪垃圾回收的种类

10

分代GC—Generational GC

为每个对象统计年龄——经历过GC的次数,区分为年轻对象和老年对象,从而制定不同的GC策略,降低整体内存管理的开销。

11

引用计数

引用计数算法也是一种自动内存管理算法。

它的基本思想是,每个对象都有一个引用计数器,记录这个对象被引用的次数。当引用计数为0时,说明这个对象没有被引用,可以回收它。

但是,这种算法存在循环引用的问题,即两个对象互相引用,但是它们的引用计数器都不为0,导致这两个对象永远不能被回收。(如下图红色圆环)

  • 优点:

    • 内存管理的操作被平摊到程序运行中:指针传递的过程中进行引用计数的增减
    • 不需要了解 runtime 的细节:因为不需要标记 GC roots,因此不需要知道哪里是全局变量、线程栈等
  • 缺点:

    • 开销大,因为对象可能会被多线程访问,对引用计数的修改需要原子操作保证原子性和可见性
    • 无法回收环形数据结构(如图红色)
    • 每个对象都引入额外存储空间存储引用计数
    • 虽然引用计数的操作被平摊到程序运行过程中,但是回收大的数据结构依然可能引发暂停(如图绿色)

12

评价GC算法

从以下几个方面评价GC算法:

  • 安全性(Safety):不能回收存活的对象(基本要求)
  • 吞吐量(Throughput):1-(GC时间/程序执行总时间)(花在GC上的时间)
  • 暂停时间(Pause time):业务是否感知
  • 内存开销(Space overhead):GC元数据开销

内存分配 与 代码性能优化

Go语言开发过程中,在保证代码简介清晰和正确可靠的前提下,可以通过对 时间效率 和 空间效率 的优化达到性能优化。

在开发过程中有意识地优化内存的分配和操作,可以有效地提高运行效率。

make()函数详解

**make()**函数在 Go 中用于创建并初始化一个内置数据类型的实例,例如切片、映射和通道。

在创建切片时,**make()**函数接受三个参数:类型、长度和容量。

  • 类型:指定切片的类型
  • 长度:指定切片中元素的数量
  • 容量:指定切片所占用的内存空间的大小

其中,使用make函数的时候如果不填写容量的大小,默认申请与长度相同的内存空间

Slice与内存优化

使用Slice时,尽量估算好切片所使用到的元素个数,一次性申请内存。

因为当切片的内存不足时,切片会自动扩容。扩容的策略是将原来的切片复制到一个新的切片中,新切片的内存空间是原来的两倍。

在这个扩容过程中,旧的切片可能会占据大量内存空间,得不到释放。

因此Slice在初始化的时候要申请合适的内存大小,避免扩容所带来的 时间 和 内存空间 的消耗

Map与内存优化

在使用map时,如果添加的元素超过map申请的容量,map会自动进行扩容,Go会自动重新分配一个更大的底层数组并进行重新散列(rehash)操作,这会导致所有的元素都需要重新计算它们在新的数组中的位置。

在这个而过程中,不仅仅元素在map中的位置发生改变了,还会发生内存拷贝的过程。

因此Map和Slice相似,合适的初始化能提升性能,因为:

  • 不断地向map添加元素会触发map扩容
  • 提前分配好map的内存空间可以减少内存拷贝和Rehash过程的资源消耗

String与内存优化

在字符串拼接的过程中,使用strings.Builder往往比直接 ”+“要快:

  • String字符串是不可变类型,内存大小是固定的
  • 对字符串使用 ”+” 每次都要生成一个新的字符串,会引发内存的重新分配
  • strings.Builder维护一个缓冲区,每次拼接时只需要在缓冲区中追加数据,不需要重新分配内存和拷贝数据

使用空结构体

  • 空结构体struct{}不占据内存空间
  • 空结构体本身就有很强的语义,不需要任何复赋值,可作为占位符
type Protocol struct {
    Action string 
    Data interface{}
}

var empty struct{}

// 发送空请求
protocol := &Protocol{
    Action: "empty_request",
    Data: empty,
}

无具体数据内容的请求,可以使用空结构体(interface{})代替Data字段,而不是使用nil或其他类型的占位符,这样可以节省内存