go内存分配与垃圾回收

go 内存管理基本概念

go 与 java类似, 都实现了自动内存管理。

Golang运行时的内存分配算法主要是 TCMalloc算法
核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

基本概念

  1. Page
  • Page 是内存管理的基本单位,通常大小为4KB(或其他固定大小,取决于具体实现)
  • Go 运行时将物理内存划分为连续的页,这些页用于分配给程序使用
  • Page 本身并不直接参与对象分配,而是作为内存资源池,供 mSpan 使用
  1. mSpan(内存跨度)
  • mSpan 是Go运行时用于管理一组连续内存页的结构。
  • 每个 mSpan 包含一定数量的 Page,并且这些 Page 被划分为固定大小的块(称为元素或object)。
  • mSpan 有三种类型:空闲(free)已分配(busy)和垃圾回收(gc)。空闲 mSpan 表示其中的 Page 未被分配;已分配 mSpan 表示其中的 Page 已被分配给对象;垃圾回收 mSpan 表示正在等待垃圾回收器处理的内存区域。
  • mSpan 通过维护一个链表来管理空闲的 Page,以便在需要时快速分配内存。
  1. Object(对象):
  • Object 是 Go 程序中实际使用的内存单元,它是由 mSpan 中的元素分配而来的。
  • 每个 mSpan 中的元素大小是固定的,这意味着可以快速计算出 mSpan 能够容纳多少个对象。
  • 当 Go 程序创建一个新对象时,运行时会从空闲的 mSpan 中分配一个或多个元素,并将这些元素组合成一个 Object 供程序使用。
  • 对象的内存布局由编译器在编译时确定,并遵循 Go 语言的 内存对齐规则


4. SizeClass

是指内存分配时根据对象大小选择的一组预定义的类别,例如8B、16B、32B等等。Go的内存分配器使用大小类别来决定如何分配和管理内存。每个大小类别对应一个特定的对象大小范围。这样做的目的是为了减少内存碎片并提高内存分配的效率。

  1. SpanClass

是指 mSpan 的一个属性,它描述了 mSpan 的用途和特性。SpanClass 与 SizeClass 相关联,因为它决定了 mSpan 如何被用于特定大小类别的对象分配。
SpanClass通常包含两个部分的信息:

  • SpanType:表示 mSpan 是用于堆分配还是栈分配。
  • Number:表示 mSpan 的索引,它与 SizeClass 相对应

SizeClass 和 SpanClass 之间的关系在于,SizeClass 决定了对象的大小范围,而 SpanClass 决定了哪个 mSpan 应该被用来分配这个大小范围内的对象。SpanClass 实际上是对 SizeClass 的一种补充,它帮助内存分配器在内部管理和组织 mSpan。

内存对齐

内存占用

基本类型内存占用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int8uint8bytebool 占用 1B
int16uint16 占用 2B
int32uint32float32rune 占用 4B
int64uint64float64 占用 8B

intuint 具体看 CPU
32位 CPU 占用 4B,等同于 int32
64位 CPU 占用 8B,等同于 int64
现在基本都是 64位 CPU,因此 int 基本上等同于 int64
uintptr 一般情况下,32位 是 4B,64 位是 8B

string 底层实现类似结构体,由两部分组成,底层是
type StringHeader struct {
Data uintptr; // 数据所在的内存地址,8B
Len int; // 字符串产固定, 8B
}
因此 string 类型占用大小为 16B,结构体中存储的是 string 这个结构体,而不是真实数据

切片底层跟 string 也是一个类似结构体的结构,
type slice struct {
Data uintptr; // 数据所在的内存地址,8B
Len int; // 字符串产固定, 8B
}
因此结构体中存储的切片占用内存固定位 16B,因为不存储真实数据

结构体内存占用:

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
type T struct {
a bool //1
b int32 //4
c int8 //1
d int64 //8
e byte //1
// 15
}

func main() {
var t T

fmt.Printf("a: %d b: %d c: %d d: %d e: %d\n",
unsafe.Sizeof(t.a), unsafe.Sizeof(t.b), unsafe.Sizeof(t.c), unsafe.Sizeof(t.d), unsafe.Sizeof(t.e))
fmt.Printf("所有变量理论占用内存大小:%d\n",
unsafe.Sizeof(t.a)+unsafe.Sizeof(t.b)+unsafe.Sizeof(t.c)+unsafe.Sizeof(t.d)+unsafe.Sizeof(t.e))

fmt.Printf("实际结构体占用内存大小 sizeof:%d\n", unsafe.Sizeof(t)) // 8
}


// ======= 整体输出
a: 1 b: 4 c: 1 d: 8 e: 1
所有变量理论占用内存大小:15
实际结构体占用内存大小 sizeof:32

从上可以看出, 虽然理论占用 15B, 实际上却占用了 32B, 这就是编译器会对结构体进行内存对齐

内存对齐

结构体中变量的内存地址不是随意分配的,它是根据 对齐保证 作为依据来进行分配的, 通过 unsafe.Alignof() 可以获取对齐保证的值。

内存对齐保证规则:

  • 变量的偏移起始地址是对齐保证的整数倍
  • 变量的内存占用大小是对齐保证的整数倍(实际上是相互作用的,一般情况下占用大小和对齐保证是相等的,可以理解为内存大小为多少,那么对齐保证也为多少)

一般而言,普通数据类型的对齐保证是它的内存大小,结构体的对齐保证是它内部所有变量中的最大的对齐保证

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
41
42
43
type T struct {
a bool //1
b int32 //4
c int8 //1
d int64 //8
e byte //1
// 15
}

func main() {
var t T

fmt.Printf("t 中实际占用内存大小:%d, 对齐保证:%d\n", unsafe.Sizeof(t), unsafe.Alignof(t))
fmt.Printf("a 占用内存大小:%d, 对齐保证:%d, 内存地址偏移量:%d\n", unsafe.Sizeof(t.a), unsafe.Alignof(t.a), unsafe.Offsetof(t.a))
fmt.Printf("b 占用内存大小:%d, 对齐保证:%d, 内存地址偏移量:%d\n", unsafe.Sizeof(t.b), unsafe.Alignof(t.b), unsafe.Offsetof(t.b))
fmt.Printf("c 占用内存大小:%d, 对齐保证:%d, 内存地址偏移量:%d\n", unsafe.Sizeof(t.c), unsafe.Alignof(t.c), unsafe.Offsetof(t.c))
fmt.Printf("d 占用内存大小:%d, 对齐保证:%d, 内存地址偏移量:%d\n", unsafe.Sizeof(t.d), unsafe.Alignof(t.d), unsafe.Offsetof(t.d))
fmt.Printf("e 占用内存大小:%d, 对齐保证:%d, 内存地址偏移量:%d\n", unsafe.Sizeof(t.e), unsafe.Alignof(t.e), unsafe.Offsetof(t.e))
}

// ======= 整体输出 =======
t 中实际占用内存大小:32, 对齐保证:8

a 占用内存大小:1, 对齐保证:1, 内存地址偏移量:0 // 第一个变量的偏移量永远是 0,不需要是对齐保证的整数倍

// a 内存占用大小为 1,内存偏移量为 0,那么理论上下一个变量的内存地址偏移量应该为 1,但实际上 b 的内存地址偏移量为 4
// 这是因为 b 占用内存大小为 4,而内存大小必须是对齐保证的整数倍,由于内存大小为 4,那么就意味着对齐保证必须是 4 以上
// 因此 b 的内存地址偏移量也必须是 4 以上,那么满足要求的最小内存地址偏移量就是 4 了,因此 b 的内存地址偏移量不是 1 而是 4
// 也因此,导致 [1, 3] 之间的内存是空余的,浪费了 3byte
b 占用内存大小:4, 对齐保证:4, 内存地址偏移量:4

// c 内存地址偏移量为 8 是因为 b 内存大小 + b 内存地址偏移量 = 8,所以没问题
c 占用内存大小:1, 对齐保证:1, 内存地址偏移量:8

// c 占用内存为 1,内存偏移量为 8,那么理论上下一个变量的内存地址偏移量应该为 9,但实际上 b 的内存地址偏移量为 16
// 理由同 b
// 因此导致 [9, 15] 间的内存是空余的,浪费了 7byte
d 占用内存大小:8, 对齐保证:8, 内存地址偏移量:16

// 内存占用没问题,内存地址偏移量也是根据 d 来计算的
// 最终总的内存大小占用为 25
// 但是因为结构体的对齐保证为 8,因此结构体整体的内存占用大小必须是 8 的整数倍,因此需要再填充 7byte,变成 32
e 占用内存大小:1, 对齐保证:1, 内存地址偏移量:24

内存管理组件与分层管理

在Go语言的内存管理系统中,MCache、MCentral和堆栈内存是构成Go内存分配器的关键组件:

  1. MCache 是每个处理器(P)私有的内存缓存,用于存储最近分配的小对象。MCache 的目的是 减少线程对全局内存分配器的调用次数,从而提高内存分配的性能。当一个线程需要分配小对象时,它会首先检查自己的 MCache 是否有足够的空闲空间。如果有,就直接从 MCache 中分配;如果没有,才会向下一层内存管理器请求。

  2. MCentral 是一个全局的内存缓存,它由一系列的 mSpan 组成,每个 mSpan 包含特定大小类别的对象。当 MCache 中没有足够的空间时,线程会向 MCentral 请求内存。MCentral 管理着不同大小类别的对象,并提供了一种机制,允许线程从 MCentral 中获取适当大小的 mSpan 来填充其 MCache。

  3. MHeap 是Go程序的主堆内存管理器,它负责管理所有动态分配的内存。MHeap 由大量的 mSpan 组成,这些 mSpan 可以包含不同大小类别的对象。当 MCentral 没有足够的mSpan 来满足内存请求时,MCentral 会从 MHeap 中获取新的 mSpan。MHeap还负责与操作系统的内存管理接口交互,请求和释放物理内存。

  4. 堆栈内存用于存储函数调用的局部变量和返回地址。在Go中,每个 Goroutine 都有自己的栈,这个栈随着函数的调用和返回动态地增长和缩小。与堆内存不同,栈内存的管理是自动的,不需要垃圾回收器介入。当栈空间不足时,Go运行时会自动扩展栈;当栈使用减少时,Go运行时会自动收缩栈。

这些组件之间的关系可以概括如下:

  1. 当一个Goroutine需要分配小对象时,首先会检查其MCache。
  2. 如果MCache中没有空间,会向MCentral请求。
  3. MCentral管理着特定大小类别的mSpan,如果MCentral也没有足够的mSpan,会向MHeap请求。
  4. MHeap是最终的内存来源,它与操作系统交互,管理整个程序的堆内存。
  5. 堆栈内存与堆内存是分开的,每个Goroutine都有自己的栈,而堆内存是所有Goroutines共享的

对象分配

在 go 里面对象分为三类:

  1. Tiny Objects 是非常小的对象,通常小于16字节。由于 Tiny Objects 非常小,它们通常不会单独分配。相反,它们会通过 “对象内联” 的方式与其他对象一起分配,或者作为更大对象的一部分。这样可以减少内存分配的开销。Tiny Objects 的内存管理通常不需要复杂的机制,因为它们作为其他对象的一部分,其生命周期由包含它们的对象决定。

  2. Small Objects 是小对象,大小通常在 16B 到 32KB 之间。这个范围可能会根据具体的Go版本和配置有所不同。Small Objects 通过 MCache(线程缓存)来分配,这样可以减少线程之间的竞争和锁的开销。MCache 从 MCentral(中央自由列表)获取预分配的mSpan(内存跨度),并从这些mSpan中分配对象。Small Objects的内存管理由MCentral处理,它维护了按大小分组的空闲对象列表。当MCache需要更多对象时,MCentral会从MHeap(主堆)获取更多的内存页,并将它们分割成相应大小的mSpan。

  3. Large Objects是大对象,大小超过32KB(这个阈值可能会根据具体的Go版本和配置有所不同)。Large Objects直接从 MHeap分配,因为它们可能跨越多个内存页,不适合通过 MCache 或 MCentral 来管理。分配大对象时,MHeap会请求操作系统分配足够的内存页,并将它们映射到Go进程的地址空间。Large Objects的内存管理更为直接,因为它们的分配和回收通常涉及操作系统的内存映射和页表。此外,大对象的生命周期不受MCache和MCentral的影响,它们的存在时间通常较长,直到显式释放或由垃圾回收器标记为不可达。

垃圾回收(gc)

早期版本:标记清除

go 在 V1.3 版本用的是 标记-清除, 与 java 类似, 流程是:

  1. 开启 STW
  2. 从 gc roots(一般就是全局变量, 函数的局部变量) 出发, 标记所有的可达对象
  3. 关闭 STW, 回收所有的不可达对象

标记-清除机制最大的缺点就是 STW 会让程序暂停, 对性能有严重的影响(包括 Java 的 CMS 也是让 STW 的时间尽量变短)

三色标记

从 V1.5 开始, GO 改为了三色标记法, 主要思想就是:

  1. 初始状态, 所有对象都标记为白色(while sets)
  2. GC 开始, 遍历 root sets, 将 直接可达(也就是 root 对象直接引用的对象)对象标记为灰色(grey sets)
  3. 遍历 gray sets, 将直接对象标记为灰色,将自身标记为黑色。
  4. 重复上一步骤, 直至 grey sets 为空
  5. 将标记为白色的对象回收

但是上述的三色标记法依然是依赖 STW 的, 因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。
比如:

不过这种标记过程中的对象引用关系丢失, 需要满足两个条件:

  • 条件一:白色对象被黑色对象引用
  • 条件二:灰色对象与白色对象之间的可达关系遭到破坏

只要把上面两个条件破坏掉一个,就可以保证对象不丢失,所以我们的golang团队就提出了两种破坏条件的方式:强三色不变式和弱三色不变式.

  • 强三色不变式: 不允许黑色对象引用白色对象(破坏了条件一, 只要黑色不引用白色, 就不会出现白色对象扫描不到被当做垃圾回收)
  • 弱三色不变式:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象(破坏了条件二,如果上游有灰色对象, 那么这个白色对象一定会被扫描到)

屏障机制

Golang团队遵循上述两种不变式提到的原则,分别提出了两种实现机制:插入写屏障和删除写屏障。

插入写屏障

规则:当一个对象引用另外一个对象时,将另外一个对象标记为灰色 (满足强三色不变式)
解释: 不会存在黑色对象引用白色对象

这里需要注意一点,插入屏障仅会在堆内存中生效,不对栈内存空间生效,这是因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。数十万goroutine的栈都进行屏障保护自然会有性能问题。

由于插入写屏障对栈上的对象不生效, 所以他有一个弊端: 在一次正常的三色标记流程结束后,需要对栈上重新进行一次stw,然后再rescan一次

删除写屏障

在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色(满足弱三色不变式)
解释: 白色对象始终会被灰色对象保护,灰色对象到白色对象的路径不会断

但是引入删除写屏障,有一个弊端,就是一个对象的引用被删除后,即使没有其他存活的对象引用它,它仍然会活到下一轮。如此一来,会产生很多的冗余扫描成本,且降低了回收精度,举例来讲

插入写屏障/删除写屏障总结

从上面示例来看,插入写屏障机制和删除写屏障机制中任一机制均可保护对象不被丢失。在V1.5的版本中采用的是插入写机制实现

对比插入写屏障和删除写屏障:

  1. 插入写屏障:
  • 插入写屏障哪里都好,就是栈上的操作管不到,所以最后需要对栈空间进行stw保护,然后rescan保证引用的白色对象存活。
  1. 删除写屏障:
  • 在GC开始时,会扫描记录整个栈做快照,从而在删除操作时,可以拦截操作,将白色对象置为灰色对象。
  • 回收精度低
V1.8 混合写屏障

从上面来看插入写屏障和删除写屏障各有优劣, go 团队结合两者的特点, 在v1.8版本下引入了混合写屏障机制。下面我们看下混合屏障机制的核心定义:

  • GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
  • GC期间,任何在栈上新创建的对象,均为黑色。

    备注: 上面两点只有一个目的,将栈上的可达对象全部标黑,最后无需对栈进行STW,就可以保证栈上的对象不会丢失。有人说,一直是黑色的对象,那么不就永远清除不掉了

  • 堆上被删除的对象标记为灰色
  • 堆上新添加的对象标记为灰色

参考资料


go内存分配与垃圾回收
https://haobin.work/2024/10/14/go/go内存分配与垃圾回收/
作者
Leo Hao
发布于
2024年10月14日
许可协议