前言
在Go语言中,内存管理是一个复杂而精巧的系统,它直接影响到程序的性能与稳定性。无论是堆内存的高效分配与回收,还是栈内存的快速分配与释放,Go语言都通过一系列精妙的设计和优化手段,实现了内存使用的最大化效率。同时,逃逸分析作为编译器优化技术的一部分,也在Go的内存管理中扮演着举足轻重的角色。本文将逐一揭开这些神秘面纱,一起领略Go语言内存体系的魅力。
Go语言的栈内存
栈内存简述
Go语言的协程栈具有很多功能,比如:记录协程的执行路径、存放局部变量、函数传递参数、存放函数返回值等等。协程栈的功能如此重要,那么他是存放在哪个位置呢?
不同于其他语言,实际上,Go语言的协程栈是位于堆内存上的,栈上内存的释放也是通过GC来释放。而Go语言的堆内存位于操作系统的虚拟内存上面。
对于一个Go程序,其协程栈的结构如下:
runtime.main
栈帧调用main.main
栈帧,main.main
栈帧调用其余函数栈帧。- 函数的调用后回收栈帧。
Go语言的参数传递采用的是拷贝传递,这也就是说,对于结构体而言,Go语言会拷贝全部内容,所以大结构体建议传递指针。
逃逸
默认的栈空间只有2K~4K
左右的大小,当本地变量过大或者函数调用过多导致栈帧过多时,协程栈可能会不够用,此时就会出现逃逸现象。同时,当栈帧回收后,如果栈内还有需要继续使用的变量,此时也会出现逃逸现象。
逃逸可以分为三种情况:
- 指针逃逸。
- 空接口逃逸。
- 大变量逃逸。
指针逃逸
指针逃逸的特征是某一个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,最多申请个虚拟内存单元。
所有的heapArena
共同构成了mheap
,也就是Go语言的堆内存。我们可以用下面的示意图来表示:
我们可以找到Go语言内存部分的代码runtime/mheap.go
:里面有一个heapArena
结构体,描述了64M的内存单元。
分级:mspan
heapArena有64MB,如何使用这64MB的内存也是一个学问。传统使用方式无非两种,一是线性地一直往后分配,直到分配到这块内存用完;二是统计空闲块后组成空闲链表,然后使用链表的方式进行分配。
然而,这两种方式都容易出现内存碎片的问题。
为了解决内存碎片的问题,Go语言采用将大内存块划分为各个级别进行分配的思想,即分级分配。我们将heapArena划分为多个级别的mspan,分别从级别1到级别67。
一个mspan为内存管理的小单元,它表示一组小格子,而Go语言里面有67个级别的格子,示意图如下:
需要注意的是,每一个heapArena不是有所有级别的mspan,而是根据对象需要的级别开辟。这就出现了一个问题:假设我们有很多个heapArena,如何找到有合适的mspan的那个?
中心索引:mcentral
为了解决上面这个问题,Go语言设计了mcentral
这个数据结构。Go里面有136个mcentral,其中有68个需要GC扫描的span组合以及68个不需要GC扫描的span组合,示意图如下:
我们可以在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对象的分配步骤如下:
- 从线程本地的
mcache
中拿到2级的mspan。 - 将多个微对象合并为一个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语言由于堆内存结构的独特优势,选择最简单的标记-清除算法就够用了。
标记-清除
标记之前,我们需要定义清楚什么是“有用的对象”?
有用的对象满足下面三个特点之一:
- 这个对象被栈上的指针引用(如局部变量)
- 这个对象被全局变量指针引用
- 这个变量被寄存器中的指针引用
满足上述特点之一的变量也被称为Root Set
,或者GC Root
。而从GC Root
开始进行BFS搜索,找到所有被引用的对象的算法,也被叫做可达性分析标记法。
然而,上述的算法存在一定的瓶颈。原因是上述算法执行的是串行GC,这意味着要暂停所有业务才能开始GC。这个暂停业务的过程也叫做Stop The World
。在这个过程中,系统会暂停所有其他协程,然后通过可达性分析找到无用堆内存,紧接着释放堆内存,最后再恢复所有协程。
STW对性能的影响是很大的,如何优化呢?
三色标记法
三色标记法是Go语言著名的面试题了,下面我们来分析一下三色标记法的算法思路。
首先,三色指的是我们讲内存中的对象分为三种类别:
- 黑色:表示对象有用,且已经分析扫描,结构体内的指针都已经被分析过了。
- 灰色:表示对象有用,但是还未分析扫描。
- 白色:表示对象没有被引用。
算法步骤如下:
- 起初,所有对象都是白色。
- 从gc root出发,扫描可达对象,标记为灰色。
- 扫描灰色对象,将其引用对象标记为灰色,自身标记为黑色。
- 清理白色对象。
- 再次标记时,所有对象恢复为白色。
并发标记的删除问题
情形:并发标记进行中,业务将灰色节点和白色节点之间的指针释放,然后重新将白色节点和其他黑色节点相连。此时黑色节点是不会继续分析的,就会释放掉原来的白色节点(此时按理说白色节点应当保留)。
解决并发标记的删除问题:删除屏障
为了解决这个问题,我们在并发标记时需要对指针释放的白色对象设置为灰色。通过这个设计,可以杜绝GC标记中被释放的指针被清理。
这种设计思路也叫做删除屏障。
并发标记的插入问题
情形:已经扫描完某个节点后,该节点为黑色节点。此时插入一个白色节点,并且黑色节点指向它。
解决并发标记的插入问题:插入屏障。
为了解决这个问题,我们在并发标记时,对指针新指向的指针置灰。
Go语言的设计:混合屏障
混合屏障综合了上面的思路,即:
- 被删除的堆对象标记为灰色
- 被添加的堆对象标记为灰色
总结来说,并发GC的关键在于标记安全,Go语言的混合屏障机制兼顾了安全和效率。
GC优化
GC触发的时机
GC触发的时机可以分为这三类:
- 系统定时触发
- 用户显式触发
- 申请内存时触发
系统定时触发
sysmon
定时检查(Go runtime背后的一个循环)- 如果超过2min没有GC,就会触发GC
源码位置:runtime/proc.go
forcegcperiod= 2*60*1e9
对于系统定时触发,我们要谨慎调整,很多时候用不到2min,分配大对象就触发了GC。
用户显式触发
即用户主动调用runtime.GC
,不推荐主动调用。
申请内存的时候触发
mallocgc
方法中会触发,分配大对象就触发了GC
GC优化的原则
GC优化总的来说,就是要尽量少在堆上产生垃圾,具体而言可以有下面几个思路:
- 内存池化。当需要频繁创建的场景,使用缓存。(例如环形缓存)
- 减少逃逸。
- 比如
fmt
包少用,多用log
的组件。 - 比如如果方法返回了指针而不是拷贝,要看看是否有必要这样做。
- 比如
- 使用空结构体。固定地址不占空间,比如用
channel
传送空结构体。
总结
通过本文的学习,我们可以看出:Go语言在内存管理方面的设计既高效又灵活。无论是堆内存的高效分配与回收,还是栈内存的自动管理,亦或是逃逸分析带来的编译器优化,都展现了Go语言在内存使用上的深思熟虑。
垃圾回收机制作为Go语言的一大特色,更是通过其非阻塞、并行的特性,极大地减轻了开发者在内存管理上的负担,提高了程序的稳定性和性能。