Go 内存管理与垃圾回收

本文章主要从原理层面分析 Go 的内存管理和垃圾回收机制,包括堆内存、栈内存和垃圾回收等,对于源码的分析涉及较少,对源码有兴趣的朋友可以查看文末的参考链接进行查看,都是写的很好的文章,本文大部分都是从参考文章整理而来。本文较长,建议收藏后慢慢阅读。

Go 语言抛弃了 C/C++ 中的开发者管理内存的方式:主动申请与主动释放,增加了逃逸分析GC,这样开发者就能从内存管理中释放出来,有更多的精力去关注软件设计,而不是底层的内存问题。这是 Go 语言成为高生产力语言的原因之一。

从非常宏观的角度讲,Go的内存管理是下图这个样子:

Go内存管理

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域 — 栈区(Stack)和堆区(Heap)。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。

传统的存储体系

计算机存储体系

计算机的存储体系的存储金字塔如图:

计算机存储体系

从上至下依次是:

  • CPU寄存器
  • Cache
  • 内存
  • 硬盘等辅助存储设备
  • 鼠标等外接设备

从上至下,访问速度越来越慢,访问时间越来越长。

CPU速度很快,但硬盘等持久存储很慢,如果 CPU 直接访问磁盘,磁盘可以拉低 CPU 的速度,机器整体性能就会低下,为了弥补这 2 个硬件之间的速率差异,所以在 CPU 和磁盘之间增加了比磁盘快很多的内存。

然而,CPU 跟内存的速率也不是相同的,CPU 的速率提高的很快(摩尔定律),而内存速率增长的很慢,虽然 CPU 的速率现在增加的很慢了,但是内存的速率也没增加多少,速率差距很大,从 1980 年开始 CPU 和内存速率差距在不断拉大,为了弥补这 2 个硬件之间的速率差异,所以在 CPU 跟内存之间增加了比内存更快的 Cache,Cache 是内存数据的缓存,可以降低 CPU 访问内存的时间。

随着 CPU 的速率还在不断增大,Cache 也在不断改变,从最初的 1 级,到后来的 2 级,到当代的 3 级 Cache:

3级Cache

三级 Cache 分别是 L1、L2、L3,它们的速率是三个不同的层级,L1 速率最快,与 CPU 速率最接近,是 RAM 速率的 100 倍,L2 速率就降到了 RAM 的25倍,L3 的速率更靠近 RAM 的速率。

整个存储体系,从磁盘到CPU寄存器,上一层都可以看做是下一层的缓存。

操作系统存储体系

虚拟内存

虚拟内存是当代操作系统必备的一项重要功能了,它向进程屏蔽了底层了 RAM 和磁盘,并向进程提供了远超物理内存大小的内存空间。

虚拟内存的分层设计如下:

虚拟内存分层设计

上图展示了某进程访问数据,当 Cache 没有命中的时候,访问虚拟内存获取数据的过程。

访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看当前要访问的虚拟内存地址是否已经加载到了物理内存,如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

在这里,物理内存就是磁盘存储缓存层

另外,在没有虚拟内存的时代,物理内存对所有进程是共享的,多进程同时访问同一个物理内存存在并发访问问题。引入虚拟内存后,每个进程都要各自的虚拟内存,内存的并发访问问题的粒度从多进程级别,可以降低到多线程级别

堆和栈

在操作系统中,进程对内存的管理,主要是通过虚拟内存的堆和栈进行。

下图展示了一个进程的虚拟内存划分,代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上 2 块不同功能的内存区域:

  • 栈在高地址,从高地址向低地址增长。
  • 堆在低地址,从低地址向高地址增长。

虚拟内存的堆和栈

栈和堆相比有这么几个好处

  1. 栈的内存管理简单,分配比堆上快。
  2. 栈的内存不需要回收,而堆需要,无论是主动 free,还是被动的垃圾回收,这都需要花费额外的 CPU。
  3. 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU 访问的 2 块数据可能在不同的页上, CPU 访问数据的时间可能就上去了。

堆内存管理

通常在讲内存管理的时候,主要都是指堆内存的管理,因为栈的内存管理不需要程序去操心。堆内存分为三个部分:分配内存块,回收内存块和组织内存块

内存的分配一般会由分配器进行分配,编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性。

线性分配

线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当在编程语言中使用线性分配器,只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:

线性分配

根据线性分配器的原理,可以推测它有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器是无法重新利用红色的这部分内存的:

线性分配的缺陷

正是因为线性分配器的这种特性,所以需要合适的垃圾回收算法配合使用。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。

因为线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略。

空闲链表分配

空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配内存。当用户程序申请内存时,就会从未分配内存,分割出一个小内存块(block),然后用链表把所有内存块连接起来。此时需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used)和下一个内存块的地址(next),内存块实际数据存储在 data 中。如果之前已经分配过,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:

空闲链表分配

一个内存块包含了 3 类信息,如下图所示,分别是元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请 5 Byte 内存的时候,就需要进行内存对齐:

内存块对齐

因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度就是 𝑂(𝑛)。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式:

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

Go 中使用的管理方式跟第四种有些相似,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当向内存分配器申请 8 字节的内存时,会在下图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

隔离适应

Go 堆内存管理

TCMalloc

TCMalloc 是 Thread Cache Malloc 的简称,是 Go 内存管理的起源,Go 的内存管理是借鉴了 TCMalloc,随着 Go 的迭代,Go 的内存管理与 TCMalloc 不一致地方在不断扩大,但其主要思想、原理和概念都是和 TCMalloc 一致的

在 Linux 里,其实有不少的内存管理库,比如 glibc 的 ptmalloc,FreeBSD 的 jemalloc,Google 的 tcmalloc 等等,本质都是在多线程编程下,追求更高内存管理效率:更快的分配是主要目的。

那如何更快的分配内存?可以从三个层次来说明。

前面提到:

引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。

这是更快分配内存的第一个层次

同一进程的所有线程共享相同的内存空间,他们申请内存时需要加锁,如果不加锁就存在同一块内存被 2 个线程同时访问的问题。

TCMalloc 的做法是为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有 2 个好处:

  1. 为线程预分配缓存需要进行 1 次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态执行,没有系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次
  2. 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次

TCMalloc 基本原理

TCMalloc 的基本原理如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MjeFY5xg-1608569375355)(https://qiniu.xiaoming.net.cn/TCMalloc%E6%A6%82%E8%A6%81%E5%9B%BE.svg)]

在 TCMalloc 中,有几个重要概念:

  • Page:操作系统对内存管理以页为单位,TCMalloc 也是这样,只不过 TCMalloc 里的 Page 大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称 x64 下 Page 大小是 8KB。

  • Span:一组连续的 Page 被称为 Span,比如可以有 2 个页大小的 Span,也可以有 16 页大小的 Span,Span 比 Page 高一个层级,是为了方便管理一定大小的内存区域,Span 是 TCMalloc 中内存管理的基本单位

  • ThreadCache:每个线程各自的 Cache,一个 Cache 包含多个空闲内存块链表,每个链表连接的都是内存块,不同的链表上的内存块大小可能是不相同的,而同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的 ThreadCache,所以 ThreadCache 访问是无锁的。

  • CentralCache:是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与 ThreadCache 中链表数量相同,当 ThreadCache 内存块不足时,可以从 CentralCache 取,当 ThreadCache 内存块多时,可以放回 CentralCache。由于 CentralCache 是共享的,所以它的访问是要加锁的。

  • PageHeap:PageHeap 是堆内存的抽象,PageHeap 存的也是若干链表,链表保存的是 Span,当 CentralCache 没有内存的时,会从 PageHeap 取,把 1 个 Span 拆成若干内存块,添加到对应大小的链表中,当 CentralCache 内存多的时候,会放回 PageHeap。如下图,分别是 1 页 Page 的 Span 链表,2 页 Page 的 Span 链表等,最后是 large span set,这个是用来保存中大对象的。毫无疑问,PageHeap 也是要加锁的。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QMc1Shww-1608569375356)(https://qiniu.xiaoming.net.cn/PageHeap.svg)]

上面提到的小、中、大对象,在 Go 内存管理中也有类似的概念,TCMalloc 中对于对象大小的定义是:

  1. 小对象大小:0~256KB
  2. 中对象大小:257~1MB
  3. 大对象大小:>1MB

小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache 缓存都是足够的,不需要去访问 CentralCache 和 HeapPage,无锁分配加无系统调用,分配效率是非常高的。

中对象分配流程:直接在 PageHeap 中选择适当的大小即可,128 Page 的 Span 所保存的最大内存就是1MB。

大对象分配流程:从 large span set 选择合适数量的页面组成 span,用来存储数据。

Go 堆内存管理的实现

Go 内存管理源自 TCMalloc,但它比 TCMalloc 还多了 2 件东西:逃逸分析和垃圾回收,这两项也大大提高了 Go 的生产力。

Go 内存管理的基本概念

Go 内存管理的许多概念在 TCMalloc 中已经有了,含义是相同的,只是名字有一些变化。下面是 Go 内存管理的原理图:

Go内存管理原理图

Page

与 TCMalloc 中的 Page 相同,x64 下 1 个 Page 的大小是 8KB。上图的最下方,1 个浅蓝色的长方形代表 1 个Page。

Span

与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。

mcache

mcache 与 TCMalloc 中的 ThreadCache 类似,mcache 保存的是各种大小的 Span,并按 Span class 分类,小对象直接从 mcache 分配内存,它起到了缓存的作用,并且可以无锁访问

但 mcache 与 ThreadCache 也有不同点,TCMalloc 中是每个线程 1 个 ThreadCache,Go 中是每个 P 拥有 1 个 mcache,因为在 Go 程序中,当前最多有 GOMAXPROCS 个线程在运行,所以最多需要 GOMAXPROCS 个 mcache 就可以保证各线程对 mcache 的无锁访问,线程的运行又是与 P 绑定的,把 mcache 交给 P 刚刚好。

mcentral

mcentral 与 TCMalloc 中的 CentralCache 类似,是所有线程共享的缓存,需要加锁访问,它按 Span class 对 Span 分类,串联成链表,当 mcache 的某个级别 Span 的内存被分配光时,它会向 mcentral 申请 1 个当前级别的 Span。

但 mcentral 与 CentralCache 也有不同点,CentralCache 是每个级别的 Span 有1个链表,mcache 是每个级别的 Span 有 2 个链表,这和 mcache 申请内存有关

在代码实现层面,mcentral 实际上是被 mheap 所持有的,是 mheap 结构体中的一个字段,这里是从物理含义层面把它们区分开来

mheap

mheap 与 TCMalloc 中的 PageHeap 类似,它是堆内存的抽象,把从 OS 申请出的内存页组织成 Span,并保存起来。当 mcentral 的 Span 不够用时会向 mheap 申请,mheap 的 Span 不够用时会向 OS 申请,向 OS 的内存申请是按页来的,然后把申请来的内存页生成 Span 组织起来,同样也是需要加锁访问的。

但 mheap 与 PageHeap 也有不同点:mheap 把 Span 组织成了树结构,而不是链表,并且还是 2 棵树,然后把 Span 分配到 heapArena 进行管理,它包含地址映射和 span 是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。

mheap 里保存了 2 棵二叉排序树,按 span 的 page 数量进行排序:

  1. free:free 中保存的 span 是空闲并且非垃圾回收的 span。
  2. scav:scav 中保存的是空闲并且已经垃圾回收的 span。

如果是垃圾回收导致的 span 释放,span 会被加入到 scav,否则加入到 free,比如刚从 OS 申请的的内存也组成的 Span。

Go内存mheap

mheap 中还有 arenas,由一组 heapArena 组成,每一个 heapArena 都包含了连续的 pagesPerArena 个 span,这个主要是为 mheap 管理 span 和垃圾回收服务。

mheap 本身是一个全局变量,它其中的数据,也都是从 OS 直接申请来的内存,并不在 mheap 所管理的那部分内存内。

heapArena

Go 采用了稀疏内存管理,堆区的所有内存可能为不连续的,go 将堆区内存视为一个个内存块,每一个内存块的大小为 64MB,并使用 heapArena 结构进行管理。也就是说,每一个 heapArena 会管理 64 MB 的内存空间。在 mheap 全局结构中维护 heapArena 的指针数组,大小为 4M,这使 go 能管理 256 TB 的内存。

heapArena 结构中的成员变量如下:

type heapArena struct {
   
	bitmap [heapArenaBitmapBytes]byte
	spans [pagesPerArena]*mspan
	pageInUse [pagesPerArena / 8]uint8
	pageMarks [pagesPerArena / 8]uint8
	zeroedBase uintptr
}
  1. bitmap :bitmap 区域标识 arena 区域哪些地址保存了对象,并且用 4bit 标志位表示对象是否包含指针、GC 标记信息。bitmap 中一个 byte 大小的内存对应 arena 区域中 4 个指针大小(指针大小为 8B )的内存,也就是每 2 bit 管理一个 8Byte(x64 平台下的指针大小)的指针,低 4 位为这四个指针大小数据是否为指针,高 4 位为 gc 扫描时是否需要继续往后扫描,主要用于 GC
  2. spans:mspan 数组,go 语言中会为每一页指定一个 mspan 结构用来管理(每页大小为 8KB),所以数组大小为 64MB /8KB=8192 (每一个 heapArena 管理 64 MB 内存)
  3. pageInUse,pageMarks:pages gc 使用的字段,pageInUse 表示相关的页的状态为 mSpanInUse(GC 负责垃圾回收,与之相对的是 goroutine 的栈内存,由栈管理),pages 表示相关页是否存在有效对象(在 gc 扫描中被标记),用来加速内存回收。
  4. zeroedBase:用来加速第一次分配,一开始为0,标记该 arena 的第一页的第一个字节尚未使用过,随着内存的分配逐渐递增直到 heapArenaBitmapBytes,换句话说,它标记了该 arena 已经分配到了哪个位置。
大小转换

除了以上内存块组织概念,还有几个重要的大小概念,它们是内存分配、组织和地址转换的基础。

Go内存大小转换

  1. object size:代码里简称size,指申请内存的对象的实际大小。
  2. size class:代码里简称class,它表示 size 的级别,相当于把 size 归类到一定大小的区间段,比如 size[1,8] 属于 size class 1size(8,16] 属于 size class 2
  3. span class:跨度类,指 span 的级别,但 span class 的大小与 span 的大小并没有正比关系。span class 主要用来和 size class 做对应,1 个 size class 对应 2 个 span class,2 个 span class 的 span 大小相同,只是功能不同,1 个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的 Span 就无需GC扫描了
  4. num of page:代码里简称 npage,代表 Page 的数量,其实就是 Span 包含的页数,用来分配内存。

Go 语言的内存管理模块中一共包含 67 种跨度类(span class),每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在变量中。

下表展示了跨度类和其他 size 之间的映射关系,从左到右前四列对应的就是 size classobj sizespan sizenum of objects

截图代码在 runtime.sizeclasses.go

内存大小转换映射关系

在实现中,会根据前 3 列做 sizesize classnum of page 之间的转换。

另外,第4列 num of objects 代表是当前 size class 级别的 Span 可以保存多少对象数量,第 5 列 tail wastespan%obj 计算的结果,表示尾部会浪费多少字节的内存,因为 span 的大小并不一定是对象大小的整数倍。最后一列 max waste 代表最大浪费的内存百分比。

以上图中的第四个跨度类为例,跨度类为 4 的 span class 中对象的大小上限为 48 字节、管理 1 个页、最多可以存储 170 个对象。因为内存需要按照页进行管理,所以在尾部会浪费 32 字节的内存,当页中存储的对象都是 33 字节时(满足存储在第 4 类的类大小的最低阈值),最多会浪费 31.52% 的资源:
( 48 − 33 ) ∗ 170 + 32 8192 = 0.31518 \frac{(48−33)∗170+32}{8192}=0.31518 8192(4833)170+32=0.31518
跨度类4最大浪费空间的存储

此外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象,用于分配大对象。

在 Go 内存大小转换那幅图中已经标记各大小之间的转换,分别是数组:class_to_sizesize_to_classclass_to_allocnpages,这 3 个数组内容,就是跟上表的映射关系匹配的。比如 class_to_size,从上表看 class 1对应的保存对象大小为 8,所以 class_to_size[1]=8,span 大小为 8192 Byte,即 8KB,为 1 页,所以 class_to_allocnpages[1]=1

转换数组

为何不使用函数计算各种转换,而是写成数组?

有1个很重要的原因:空间换时间。上表中的转换,并不能通过简单的公式进行转换,比

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值