文章目录
对编程语言来说,GC 就是一个无名英雄,默默地做着贡献。打个比方,天鹅在水面优雅地游动时,实际上脚蹼却在水下拼命地划着水。GC 也是如此。在由编程语言构造的美丽的源代码这片水下,GC 在拼命地将垃圾回收再利用。
——《垃圾回收的算法与实现》
一、垃圾回收
- 什么是垃圾回收?
垃圾回收(GC,garbage collection)是自动内存管理的一种形式,通常由垃圾收集器收集并适时回收或重用不再被对象占用的内存,比如众所周知的Java语言就能很好的支持GC。后起之秀——Go语言也同样支持垃圾回收,它使得Go程序员在编写程序的时候不再需要考虑内存管理。 - 为什么需要GC?
它使得程序员在编写程序的时候不再需要考虑内存管理。
减少错误和复杂性;内存泄漏、野指针、多次释放。
解耦——减少模块之间的耦合意味着一个模块的行为不依赖于另一个模块的实现。当两个模块中同时维护了同一内存时,释放内存将会变得非常小心。这种手动分配的困难在于,难以在本地模块内做出全局的决定。
GC已经成为一个成熟稳定的高级语言的特性之一。 - 有哪些GC算法?
1、引用计数——每个单元维护一个域,保存其它单元指向它的引用数量,当引用数量为 0 时,将其回收。
2、标记清除——是第一种自动内存管理,基于追踪的垃圾收集算法。三色标记法——是对标记-清除算法的改进,能够让用户程序和 mark 并发的进行。
3、节点复制——将整个堆等分为两个半区,对象都在这一半区域,经过GC后全部复制到另一半区域。
4、分代收集——将对象按生命周期长短存放到堆上的两个(或者更多)区域。不同区域投入不同的计算资源来提高,收集的时候集中主要精力在新生代就会相对来说效率更高,STW 时间也会更短。 - go语言为什么不选择压缩GC?
go生存的压力需要更简单的GC;go自带的GC也能减少内存碎片。 - go语言为什么不选择分代GC?
目前还不支持。Go语言编译时的内存逃逸可以算是对这一功能缺陷的弥补。
二、逃逸分析
Go编译器会根据需要做逃逸分析:栈分配廉价,堆分配昂贵。栈空间会随着一个函数的结束自动释放,堆空间需要 GC 模块不断的跟踪扫描回收。 Go决定是否使用堆分配对象的过程也叫"逃逸分析"。
func stack() int {
// 变量 i 会在栈上分配
i := 10
return i
}
func heap() *int {
// 变量 j 会在堆上分配
j := 10
return &j
}
//堆分配不仅分配上逻辑比栈分配复杂,它最致命的是会带来很大的管理成本,Go 语言要消耗很多的计算资源对其进行标记回收(也就是 GC 成本)。
Go 编辑器会自动帮我们找出需要进行动态分配的变量,它是在编译时追踪一个变量的生命周期,如果能确认一个数据只在函数空间内访问,不会被外部使用,则使用栈空间,否则就要使用堆空间。需要使用堆空间则逃逸。但编译器有时会将不需要使用堆空间的变量,也逃逸掉,这里容易出现性能问题 。比如 多级间接赋值容易导致逃逸。
- 逃逸现象举例
func test(i int) {}
func testEscape(i *int) {}
func main() {
i, j, m, n := 0, 0, 0, 0
t, te := test, testEscape // 函数变量
// 直接调用
test(m) // 不逃逸
testEscape(&n) // 不逃逸
// 间接调用
t(i) // 不逃逸
te(&j) // 逃逸 func(*int),属于引用类型,参数 *int 也是引用类型
}
//------------------------------------------------------------//
func testSlice(slice []int) {}
func testMap(m map[int]int) {}
func testInterface(i interface{}) {}
func main() {
x, y, z := make([]int, 1), make(map[int]int), 100
ts, tm, ti := testSlice, testMap, testInterface
ts(x) // ts.slice = x 导致 x 逃逸
tm(y) // tm.m = y 导致 y 逃逸
ti(z) // ti.i = z 导致 z 逃逸
}
//------------------------------------------------------------//
type Data struct {
data map[int]int
slice []int
ch chan int
inf interface{}
p *int
}
func main() {
d1 := Data{}
d1.data = make(map[int]int) // GOOD: does not escape
d1.slice = make([]int, 4) // GOOD: does not escape
d1.ch = make(chan int, 4) // GOOD: does not escape
d1.inf = 3 // GOOD: does not escape
d1.p = new(int) // GOOD: does not escape
d2 := new(Data) // d2 是指针变量, 下面为该指针变量中的指针成员赋值
d2.data = make(map[int]int) // BAD: escape to heap
d2.slice = make([]int, 4) // BAD: escape to heap
d2.ch = make(chan int, 4) // BAD: escape to heap
d2.inf = 3 // BAD: escape to heap
d2.p = new(int) // BAD: escape to heap
}
//------------------------------------------------------------//
func test() {
var (
chInteger = make(chan *int)
chMap = make(chan map[int]int)
chSlice = make(chan []int)
chInterface = make(chan interface{})
a, b, c, d = 0, map[int]int{}, []int{}, 32
)
chInteger <- &a // 逃逸
chMap <- b // 逃逸
chSlice <- c // 逃逸
chInterface <- d // 逃逸
}
多级间接赋值会导致 Go 编译器出现不必要的逃逸,在一些情况下可能我们只需要修改一下数据结构就会使性能有大幅提升。这也是很多人不推荐在 Go 中使用指针的原因,因为它会增加一级访问路径,而 map
, slice
, interface{}
等类型是不可避免要用到的,为了减少不必要的逃逸,只能拿指针开刀。
- 什么时候从Heap分配对象?
很多讲解go的文章和书籍中都提到过, go会自动确定哪些对象应该放在栈上, 哪些对象应该放在堆上。
简单来说, 当一个对象的内容可能在生成该对象的函数结束后被访问, 那么这个对象就会分配在堆上。
在堆上分配对象的情况包括:
- 返回对象的指针
- 传递了对象的指针到其他函数
- 在闭包中使用了对象并且需要修改对象
- 使用new
大多数情况下,性能优化都会为程序带来一定的复杂度。建议实际项目中还是怎么方便怎么写,功能完成后通过性能分析找到瓶颈所在,再对局部进行优化。
三、Go GC【重要】
Go语言的GC演进:Go1.0仅支持串行 → Go1.1可以并行 → Go1.3 精确STW →Go1.5 三色并发+屏障→ Go1.8 混合写屏障。
1.标记清除法
起初[Go1.0版本-Go1.3版本],Go语言一直使用的垃圾回收使用的是标记-清除算法。进行垃圾回收时会STW。 因此,Go语言一直被诟病GC性能差。
STW(Stop The World)
STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,Golang进行了多次的迭代优化来解决这个问题。
GC过程中STW是不可避免的。因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。
- 标记清除算法步骤简述
第一步,暂停程序业务逻辑, 找出不可达的对象,然后做上标记。
第二步, 开始标记,程序找出它所有可达的对象,并做上标记。
第三步, 标记完了之后,然后开始清除未标记的对象 。
第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。 - 标记清除算法的缺点
STW,stop the world;让程序暂停,程序出现卡顿 (所以GC性能差)。
标记需要扫描整个heap。
清除数据会产生heap碎片。
Go1.3版本通过将STW提前, 减少STW暂停的时间范围。如下所示:
2.三色标记法
Go1.5版本开始,Golang中的垃圾回收主要应用三色标记法,GC过程和其它用户goroutine可并发运行,但仍然需要一定时间的STW(stop the world)。
三色概念只是抽象概念。
- 三色标记算法简述【实际上就是通过三个阶段的标记来确定清楚的对象都有哪些】
第一步 , 就是只要是新创建的对象,默认的颜色都是标记为“白色”。
第二步, 每次GC回收开始, 然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合。
第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合。
第四步, 重复第三步, 直到灰色中无任何对象。
第五步: 回收所有的白色标记表的对象. 也就是回收垃圾。
三色标记法也需要STW,如果不STW,如果同时出现以下两个条件,就会出现对象丢失现象!
条件1: 一个白色对象被黑色对象引用(白色被挂在黑色下)
条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色)
为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?
答案就是,那么我们只要使用一个机制——屏障机制,来破坏上面的两个条件就可以了。
3.“强-弱” 三色不变式
有两种方式可以保证在尽可能少STW的情况下保证对象不丢失。
- 强三色不变式:不存在黑色对象引用到白色对象的指针。
- 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态 【即白色对象存在灰色引用】。
为了遵循上述的两个方式,Golang团队初步得到了如下具体的两种屏障机制方式“插入屏障”, “删除屏障”。
4.屏障机制
- 插入屏障→保证强三色不变式
具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
举例:
【1】程序起初创建,全部标记为白色,将所有对象放入白色集合。
【2】遍历 Root Set(非递归形式,只遍历一次)得到灰色节点。
哪些对象可以作为根节点集合Root Set?
根对象是不需要通过其他对象就可以直接访问到的对象. 比如全局对象, 栈对象, 寄存器中的数据等. 通过Root对象, 可以追踪到其他存活的对象。
【3】遍历Grey灰色标记表。将可达的对象,从白色标记为色遍历之后的灰色,标记为黑色。
【4】由于并发特性,此刻外界向对象4添加对象8、对象1添加对象9。对象4在堆区,即将触发插入屏障机制,对象1不触发。
【5】由于插入写屏障(黑色对象添加白色,将白色改为灰色),对象8变成灰色,对象9依然为白色。
【6】继续循环上述流程进行三色标记,直到没有灰色节点。
【7】在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰(有新的白色被黑色添加)
【8】在STW中,将栈中的对象一次三色标记,直到没有灰色节点。
【9】停止STW
【10】清除白色
- 删除屏障→保证弱三色不变式
具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
满足: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)
举例:
【1】程序起初创建,全部标记为白色,将所有对象放入白色集合中。
【2】遍历 Root Set(非递归形式,只遍历一次)得到灰色节点。
【3】灰色对象1删除对象5,如果不触发删除写屏障,5-2-3路径与主链路断开,最后均会被清除。
【4】触发删除写屏障,被删除的对象5,自身被标记为灰色。
【5】遍历Grey灰色标记表将可达的对象从白色标记为灰色。遍历之后的色,标记为黑色。
【6】继续循环上述流程进行三色标记,直到没有灰色节点。
【7】清除白色。
5.混合写屏障机制
- 插入写屏障和删除写屏障的短板
插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
Go1.8版本开始支持混合写屏障(hybrid write barrier)机制。避免了对栈re-scan的过程,极大的减少了STW的时间。结合了插入写屏障和删除写屏障两者的优点。
- 混合写屏障→保证变形的弱三色不变式
具体操作:
1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。
满足: 变形的弱三色不变式。
举例:
【1】GC刚刚开始,默认都为白色。
【2】三色标记法,优先扫描全部对象将可达对象均标记为黑。
接着,有下面有四个场景。
场景1.对象被一个堆对象删除引用,成为栈对象的下游
【3】将对象7添加到对象1下游,因为栈不启动写屏障,所以直接挂在下面。
【4】对象删除对象7的引用关系,因为对象是堆区,所以触发写屏障(删除即赋新值为nu),标记被删除对象7为灰色。
场景2.对象被一个栈对象删除引用,成为另一个栈对象的下游
【3】新创建一个对象9在栈上。(混合写屏障模式中,GC过程中任何新创建的对象均标记为黑色)
【4】对象9添加下游引用栈对象3(直接添加,栈不启动屏障,无屏障效果)
【5】对象2删除对象3的引用关系。(直接删除,栈不启动屏障,无屏障效果)
场景3.对象被一个堆对象删除引用,成为另一个堆对象的下游
【3】堆对象10已经扫描标记为黑(黑色情况较特殊,其他颜色暂不考虑)
【4】堆对象10添加下游引用对象7。触发屏障机制,被添加的对象标记为灰色,对象7变成灰色。(这时对象6被保护)
【5】对象删除下游引用对象7。触发屏障机制,被删除的对象标记为被标记为灰色,对象7变成灰色。
场景4.对象从一个栈对象删除引用,成为另一个堆对象的下游
【3】栈对象1删除对栈对象2的引用。(栈空间不触发写屏障)
【4】堆对象4将之前引用对象7的关系,转移至对象2。(对象4删除对象7的引用关系)
【5】对象4在删除的时候,触发写屏障,标记被删除对象7为灰色,保护对象7下游节点。
Golang中的混合写屏障满足弱三色不变式,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。
什么时候触发GC?
在堆上分配大于 32K byte 对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行垃圾回收。GC前提:当前堆上的活跃对象大于我们初始化时候设置的 GC 触发阈值(默认4M)
1、基于调步算法,一般是当 Heap 上的内存达到一定数值后,会触发一次 GC,这个数值可以通过环境变量GOGC
或者debug.SetGCPercent()
设置,默认是100
,表示当内存增长100%
执行一次 GC。如果当前堆内存使用了10MB
,那么等到它涨到20MB
的时候就会触发 GC。
2、再就是每隔 2 分钟,如果期间内没有触发 GC,也会强制触发一次。
3、最后就是用户手动触发,也就是调用runtime.GC()
强制触发一次。
四、总结
Go 的GC一直围绕着如何缩短STW的时间在做优化与改进。
- Go1.3版本之前,使用的是普通标记清除法,整体过程需要启动STW,GC性能极差。
- Go1.3版本,使用的是简单优化的标记清除法,通过将STW的时间提前,GC性能略有提升。
- Go1.5版本, 使用改进版的标记清除算法——三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(这时需要STW),GC性能提升较大。
- Go1.8版本,三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,GC性能提升很大。
参考链接:https://www.jianshu.com/p/518466b4ee96
参考链接:https://www.cnblogs.com/zkweb/p/7880099.html
参考链接:https://www.topgoer.cn/docs/golangxiuyang/golangxiuyang-1cmee076rjgk7#f9u7gg