Go 内存模型 | 青训营笔记

180 阅读4分钟

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

1. 协程栈

  • Go 的协程栈位于堆内存中,堆内存位于操作系统的虚拟内存中。
  • 栈帧中主要存储:函数的局部变量、参数、返回值。
  • 函数的参数列表的实参是从右往左依次入栈的。

1.1. 逃逸分析

Go 编译器会尽可能将变量分配在栈上. 以下两种情况,Go 编译器会将变量分配在堆上:1. 如果一个变量被取地址,并且被逃逸分析为逃逸到堆;2. 该变量很大。

逃逸有三种方式:指针逃逸、空接口逃逸、大变量逃逸

  • 指针逃逸:函数返回了某个变量的指针或地址。
  • 空接口逃逸:如果函数的形参为 interface{}(即变量类型不确定),函数的实参很可能会逃逸到堆上。 这是因为 interface{} 类型大概率会使用到反射。
  • 大变量逃逸:大变量会消耗大量栈空间,导致栈空间不足。 64 位机器中,一般超过 64KB 的变量会逃逸到堆上。

详解Go逃逸分析

1.2. 栈扩容

  • Go 协程栈的初始空间为 2KB
  • 每次在函数调用前会先执行 morestack 来判断栈空间是否足够;不够需要进行栈扩容。

栈扩容机制:Go 1.13 之前使用分段栈;之后使用连续栈。

  • 分段栈:在新位置开辟一片空间供栈使用

    • 优点:没有空间浪费
    • 缺点:若在扩容之处有多次的函数调用,那么就会反复地进行栈帧开辟和回收操作,栈指针会在不连续的位置来回跳转。
  • 连续栈:在新的位置开辟一块更大(原来的 2 倍)的栈,将栈全部拷贝过来;当栈内存使用率低于 1/4 时,缩小栈为原来的一半。

    • 优点:空间一直连续
    • 缺点:伸缩时开销大

2. 堆

2.1. 操作系统虚拟内存

  • 操作系统是不允许进程直接使用物理内存的。
  • 操作系统为每个进程都提供了一块可供使用的虚拟的内存空间,由操作系统来将虚拟内存空间映射到物理内存或者外存(磁盘,Linux 称为 swap 区)中。
  • 虚拟内存的大小是:32 位 4G,64 位 256T。
  • Go 申请虚拟内存是一块一块申请的,每个单元被称为 heapArena,大小是 64M。
    最多可以有 222=4,194,3042^{22} = 4,194,304 个这种 heapArena 虚拟内存单元。
    所有的 heapArena 组成了 Go 的堆内存 mheap(刚好 64M222=256T64M * 2^{22} = 256T)。

2.2. 堆内存分配策略

  • 分级分配:把内存分为几种不同大小的块,将对象放入能容纳其的最小块中。

  • mspan:Go 中使用内存的最小单元为 mspan,每个 mspan 是若干个相同大小的内存块。一共有 67 种 mspan

  • mcentral:为快速找到适合分配给对象的 mspan,采用中心索引的方式。有 136 个 mcentral 个结构体,其中 68 个记录需要 GC 扫描的 mspan,68 个记录不需要 GC 扫描的 mspan

  • 对象分级

    • 分级
      • Tiny 微对象 0 - 16B, 无指针类型的属性
      • Small 小对象 16B - 32B
      • Large 大对象 32B 以上
    • 微对象和小对象分配至普通 mspan;大对象量身定做 mspanclass 0
    • 将多个微对象合并为一个,存入一个 16B 的 mspanclass 2)中
    • 小对象则寻找一个合适大小的 mspan 来存放

2.3. GC

垃圾收集算法:标记清除、标记整理、复制算法
由于 Go 使用的是分级分配内存的机制,并不会产生内存碎片的问题,所以使用的是标记清除算法。

根搜索算法 GC Roots Tracing
通过一系列名为 "GC Roots" 的对象作为起点,开始向下搜索(广度优先遍历 DFS)。当从 GC Roots 到一个对象不可达时,则该对象是不可用的。
Go 中的 GC Roots 包括:

  1. 被栈上指针所引用的对象
  2. 被全局变量指针所引用的对象
  3. 被寄存器中的指针所引用的对象