好好学Go(十四):详解Go语言的内存体系

206 阅读13分钟

前言

在Go语言中,内存管理是一个复杂而精巧的系统,它直接影响到程序的性能与稳定性。无论是堆内存的高效分配与回收,还是栈内存的快速分配与释放,Go语言都通过一系列精妙的设计和优化手段,实现了内存使用的最大化效率。同时,逃逸分析作为编译器优化技术的一部分,也在Go的内存管理中扮演着举足轻重的角色。本文将逐一揭开这些神秘面纱,一起领略Go语言内存体系的魅力。

Go语言的栈内存

栈内存简述

Go语言的协程栈具有很多功能,比如:记录协程的执行路径、存放局部变量、函数传递参数、存放函数返回值等等。协程栈的功能如此重要,那么他是存放在哪个位置呢?

不同于其他语言,实际上,Go语言的协程栈是位于堆内存上的,栈上内存的释放也是通过GC来释放。而Go语言的堆内存位于操作系统的虚拟内存上面。

对于一个Go程序,其协程栈的结构如下:

  • runtime.main栈帧调用main.main栈帧,main.main栈帧调用其余函数栈帧。
  • 函数的调用后回收栈帧。

Go语言的参数传递采用的是拷贝传递,这也就是说,对于结构体而言,Go语言会拷贝全部内容,所以大结构体建议传递指针。

逃逸

默认的栈空间只有2K~4K左右的大小,当本地变量过大或者函数调用过多导致栈帧过多时,协程栈可能会不够用,此时就会出现逃逸现象。同时,当栈帧回收后,如果栈内还有需要继续使用的变量,此时也会出现逃逸现象。

逃逸可以分为三种情况:

  1. 指针逃逸。
  2. 空接口逃逸。
  3. 大变量逃逸。

指针逃逸

指针逃逸的特征是某一个Go函数返回了对象的指针,使得函数外面可以访问这个对象

我们可以查看下面的示例代码:

func a() *int {  
    // v的地址返回了,此时v逃逸到堆上  
    v := 0  
    return &v  
}  
  
func main() {  
    i := a()  
    fmt.Println(*i)  
}

在上面的代码中,我们返回了一个局部变量的地址,在C/C++中这种行为是非法的,因为函数在返回时其对应的栈地址就被释放了,此时返回的这个地址就属于非法地址。然而在Go中,我们还是能够访问这个变量,因为此时这个变量从栈上逃逸到堆上。

空接口逃逸

当某一个方法的参数中接收控制真,如果我们调用这个方法,此时这个变量就可能逃逸到堆上。

例如下方的代码:

func b() {  
    i := 0  
    // Println调用了空接口,此时i可能会逃逸到堆上  
    // 因为interface{}类型的函数往往会使用反射来查看类型,反射要求内存在堆上  
    fmt.Println(i)  
}

如何理解呢?实际上,因为接收interface{}类型形参的函数往往会使用反射来查看变量类型,而反射要求内存在堆上,所以此时变量会出现逃逸。

大变量逃逸

变量过大时更加容易导致栈空间不足,所以过大的变量也会逃逸到堆上。在64位机器中,一般超过64KB的变量会逃逸。

栈扩容

栈的初始空间只有2KB左右,因此在调用函数前,Go语言会调用morestack()函数来判断栈空间是否足够,在必要时对栈空间进行扩容。(复习一下Go语言的协程调度:基于协作的抢占式调度就是在morestack()下面放置了一个hook,在执行morestack时会判断某个协程是否被抢占,如果被抢占就直接回到schedule方法)

在Go语言中,早期(Go1.13前)使用分段栈,后期(Go1.13后)使用连续栈。分段栈使用类似链表的方式链接各个栈地址,其优点是无空间浪费,缺点是栈指针会在不连续的空间跳转。

连续栈则将栈地址连续存放,当空间不足时扩容变为原来的2倍,当空间使用率不足1/4时缩容变为原来的1/2。这种方式的优点是空间连续存放,缺点就是伸缩的时候开销比较大。

Go语言的堆内存

堆结构

基本分配单元:heapArena

这一部分我们来探索从物理内存,到操作系统的虚拟内存,到Go语言的堆内存,这每一个层级之间是怎样一种关系和存放结构。

在Go语言中,堆内存的基本单位是:heapArena。Go程序每次申请虚拟内存的基本单位都是64MB,最多申请2202^{20}个虚拟内存单元。

所有的heapArena共同构成了mheap,也就是Go语言的堆内存。我们可以用下面的示意图来表示:

Pasted image 20240806102416.png

我们可以找到Go语言内存部分的代码runtime/mheap.go:里面有一个heapArena结构体,描述了64M的内存单元。

分级:mspan

heapArena有64MB,如何使用这64MB的内存也是一个学问。传统使用方式无非两种,一是线性地一直往后分配,直到分配到这块内存用完;二是统计空闲块后组成空闲链表,然后使用链表的方式进行分配。

然而,这两种方式都容易出现内存碎片的问题。

为了解决内存碎片的问题,Go语言采用将大内存块划分为各个级别进行分配的思想,即分级分配。我们将heapArena划分为多个级别的mspan,分别从级别1到级别67。

Pasted image 20240806103510.png

一个mspan为内存管理的小单元,它表示一组小格子,而Go语言里面有67个级别的格子,示意图如下:

Pasted image 20240806103606.png

需要注意的是,每一个heapArena不是有所有级别的mspan,而是根据对象需要的级别开辟。这就出现了一个问题:假设我们有很多个heapArena,如何找到有合适的mspan的那个?

中心索引:mcentral

为了解决上面这个问题,Go语言设计了mcentral这个数据结构。Go里面有136个mcentral,其中有68个需要GC扫描的span组合以及68个不需要GC扫描的span组合,示意图如下:

image.png

我们可以在mheap数据结构里面找到central字段,这个字段内部含mcentral。每个mcentral是一组格子,可以从对应class的格子群里面找到有空闲的span然后分配。

mcentral的性能问题

你以为这就是Go语言的堆内存设计方案了吗,不要高兴得太早!mcentral实际上是中心索引,使用互斥锁保护,因此在高并发场景下,锁冲突问题尤其严重。

那怎么办?回想起协程调度模块的GMP模型,我们增加线程本地缓存来防止频繁的竞争全局协程队列。

这里也是同理,我们给每个线程P设计一个mcache,这个mcache拥有136个span,68个需要GC扫描,68个不需要,记录了分配个给个P的本地mspan。

堆内存结构小结

这种堆内存设计方式实际上是模仿的Google自己设计的TCmalloc策略。可以看出,Go语言模仿了TCmalloc建立了自己的堆内存策略,要点如下:

  • 使用heapArena向OS申请内存
  • 使用heapArena时,以mspan为单位分配,防止碎片化
  • mcentral是mspan们的中心索引
  • mcache记录了分配给各个P的本地mspan

堆内存的分配

Go语言在堆内存的分配策略上,采用“对象分级”的办法,即先将对象划分为:Tiny、Small、Large三个级别。

  • Tiny:微对象,0~16B,不能包含指针。分配到普通mspan(1-67级)中。
  • Small:小对象,16B~32KB,分配到普通的mspan中。
  • Large:大对象,32KB~+inf,分配到0级mspan。

Tiny对象分配

Tiny对象的分配步骤如下:

  1. 从线程本地的mcache中拿到2级的mspan。
  2. 将多个微对象合并为一个16B存入。

源码查看

我们可以查看runtime/malloc.go/mallocgc()方法:

  • if size <= maxSmallSize(32KB)这句话里面继续判断if size<=maxTinySize(16B),如果满足,将多个微对象合并为一个16B存入。拿的是2级的span,刚好一个块16B。
  • else:计算需要的span级别(查表),从mcache找到一个对应级别的span,调用nextFreeFast找到没被占用的地址。找不到就调用nextFree,里面会做mcache的替换。

注意:mcache中,每个级别的mspan只有1个,当mspan满了的话,会从mcentral中换一个新的。

如果mcentral对应的mspan缺少时,就需要在堆中新增heapArena

大对象分配

Large对象分配步骤:直接从heapArena开辟0级的mspan。这是一个定制的span,专门为大对象定制,可大可小。

Go堆内存分配小结

Go语言内存分配中使用了对象分级策略,将对象按照大小分成了3个级别,其中微小对象直接使用mcache内的mspan进行分配。当mcache中的mspan填满后,与mcentral交换新的mspan。

Go语言的垃圾回收

GC思路

学习过Java虚拟机的朋友们应该知道,一般来讲垃圾回收的策略无非这几种:

  • 标记-清除:第一轮标记,第二轮清除。
  • 标记-整理:先标记,后整理内存。
  • 标记-复制:JVM的分代GC在使用,将有用的内存复制到另一块,不过内存利用率不高。

Go语言由于堆内存结构的独特优势,选择最简单的标记-清除算法就够用了。

标记-清除

标记之前,我们需要定义清楚什么是“有用的对象”?

有用的对象满足下面三个特点之一:

  1. 这个对象被栈上的指针引用(如局部变量)
  2. 这个对象被全局变量指针引用
  3. 这个变量被寄存器中的指针引用

满足上述特点之一的变量也被称为Root Set,或者GC Root。而从GC Root开始进行BFS搜索,找到所有被引用的对象的算法,也被叫做可达性分析标记法。

然而,上述的算法存在一定的瓶颈。原因是上述算法执行的是串行GC,这意味着要暂停所有业务才能开始GC。这个暂停业务的过程也叫做Stop The World。在这个过程中,系统会暂停所有其他协程,然后通过可达性分析找到无用堆内存,紧接着释放堆内存,最后再恢复所有协程。

STW对性能的影响是很大的,如何优化呢?

三色标记法

三色标记法是Go语言著名的面试题了,下面我们来分析一下三色标记法的算法思路。

首先,三色指的是我们讲内存中的对象分为三种类别:

  • 黑色:表示对象有用,且已经分析扫描,结构体内的指针都已经被分析过了。
  • 灰色:表示对象有用,但是还未分析扫描。
  • 白色:表示对象没有被引用。

算法步骤如下:

  • 起初,所有对象都是白色。
  • 从gc root出发,扫描可达对象,标记为灰色。
  • 扫描灰色对象,将其引用对象标记为灰色,自身标记为黑色。
  • 清理白色对象。
  • 再次标记时,所有对象恢复为白色。

并发标记的删除问题

情形:并发标记进行中,业务将灰色节点和白色节点之间的指针释放,然后重新将白色节点和其他黑色节点相连。此时黑色节点是不会继续分析的,就会释放掉原来的白色节点(此时按理说白色节点应当保留)。

解决并发标记的删除问题:删除屏障

为了解决这个问题,我们在并发标记时需要对指针释放的白色对象设置为灰色。通过这个设计,可以杜绝GC标记中被释放的指针被清理。

这种设计思路也叫做删除屏障。

并发标记的插入问题

情形:已经扫描完某个节点后,该节点为黑色节点。此时插入一个白色节点,并且黑色节点指向它。

解决并发标记的插入问题:插入屏障。

为了解决这个问题,我们在并发标记时,对指针新指向的指针置灰。

Go语言的设计:混合屏障

混合屏障综合了上面的思路,即:

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

总结来说,并发GC的关键在于标记安全,Go语言的混合屏障机制兼顾了安全和效率。

GC优化

GC触发的时机

GC触发的时机可以分为这三类:

  1. 系统定时触发
  2. 用户显式触发
  3. 申请内存时触发

系统定时触发

  • sysmon定时检查(Go runtime背后的一个循环)
  • 如果超过2min没有GC,就会触发GC

源码位置:runtime/proc.go

  • forcegcperiod= 2*60*1e9

对于系统定时触发,我们要谨慎调整,很多时候用不到2min,分配大对象就触发了GC。

用户显式触发

即用户主动调用runtime.GC,不推荐主动调用。

申请内存的时候触发

mallocgc方法中会触发,分配大对象就触发了GC

GC优化的原则

GC优化总的来说,就是要尽量少在堆上产生垃圾,具体而言可以有下面几个思路:

  1. 内存池化。当需要频繁创建的场景,使用缓存。(例如环形缓存)
  2. 减少逃逸。
    1. 比如fmt包少用,多用log的组件。
    2. 比如如果方法返回了指针而不是拷贝,要看看是否有必要这样做。
  3. 使用空结构体。固定地址不占空间,比如用channel传送空结构体。

总结

通过本文的学习,我们可以看出:Go语言在内存管理方面的设计既高效又灵活。无论是堆内存的高效分配与回收,还是栈内存的自动管理,亦或是逃逸分析带来的编译器优化,都展现了Go语言在内存使用上的深思熟虑。

垃圾回收机制作为Go语言的一大特色,更是通过其非阻塞、并行的特性,极大地减轻了开发者在内存管理上的负担,提高了程序的稳定性和性能。