gc垃圾回收算法原理
1.三色标记法
以下是Golang GC算法的里程碑:
- v1.1 STW
- v1.3 Mark STW, Sweep 并行
- v1.5 三色标记法
- v1.8 三色标记法+混合写屏障
go的gc是基于 标记-清扫
算法,并做了一定改进,减少了STW的时间。
1.标记-清扫(Mark And Sweep)算法
此算法主要有两个主要的步骤:
- 标记(Mark phase)
- 清除(Sweep phase)
第一步,找出不可达的对象,然后做上标记。
第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 stop the world
。
2.标记-清扫(Mark And Sweep)算法存在什么问题?
标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:
- STW,stop the world;让程序暂停,程序出现卡顿。
- 标记需要扫描整个heap
- 清除数据会产生heap碎片
这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。
3.三色并发标记法
1.创建3个集合,白色,灰色,黑色
2.开始STW暂停协程
3.将全部对象全部放入白色集合(标记白色)
4.遍历root对象,把遍历到的对象,从白色集合放入灰色集合(标记灰色),灰色都是root的根对象
5.遍历灰色集合,把灰色对象引用的对象也放入灰色集合(标记灰色),自己放入黑色集合(标记黑色)。无论怎么样,遍历灰色对象之后,自己就变成黑色,有引用的话,引用的对象就变成灰色。
6.重复5步骤,直到没有灰色为止
7.清除白色对象
root对象(根对象)包括:全局变量+指向堆内存的栈指针
全局变量
执行栈:每个协程都包涵自己的执行栈,这些执行栈指向指向堆上的内存
寄存器:可能是指针
STW(写屏障):目的:防止GC扫描时内存变化引起混乱,写屏障让协程与GC同时运行的手段
通俗的讲:就是在gc跑的过程中,可以监控对象的内存修改,并对对象进行重新标记。(实际上也是超短暂的stw,然后对对象进行标记),在gc过程中产生的新对象,一律变为灰色
4.gc和用户逻辑如何并行操作?
标记-清除(mark and sweep)算法的STW(stop the world)操作,就是runtime把所有的线程全部冻结掉,所有的线程全部冻结意味着用户逻辑是暂停的。这样所有的对象都不会被修改了,这时候去扫描是绝对安全的。
Go如何减短这个过程呢?标记-清除(mark and sweep)算法包含两部分逻辑:标记和清除。
我们知道Golang三色标记法中最后只剩下的黑白两种对象,黑色对象是程序恢复后接着使用的对象,如果不碰触黑色对象,只清除白色的对象,肯定不会影响程序逻辑。所以:清除操作和用户逻辑可以并发。
5.进程新生成对象的时候,GC该如何操作呢?不会乱吗?
Golang为了解决这个问题,引入了 写屏障
这个机制。
写屏障:该屏障之前的写操作和之后的写操作相比,先被系统其它组件感知。
通俗的讲:就是在gc跑的过程中,可以监控对象的内存修改,并对对象进行重新标记。(实际上也是超短暂的stw,然后对对象进行标记)
在上述情况中,新生成的对象,一律都标位灰色!
6.那么,灰色或者黑色对象的引用改为白色对象的时候,Golang是该如何操作的?
看如下图,一个黑色对象引用了曾经标记的白色对象。
这时候,写屏障机制被触发,向GC发送信号,GC重新扫描对象并标位灰色。
因此,gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。
2.堆栈
1.内存分配中的堆和栈
栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
2.堆栈缓存方式
栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。
堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。
申请到 栈内存 好处:函数返回直接释放,不会引起垃圾回收,对性能没有影响。
3.Golang 的GC触发时机是什么?
峰值:默认内存扩大一倍,启动gc。(当前堆内存占用是上次gc后的两倍)
定期:默认2min触发一次gc,src/runtime/proc.go:forcegcperiod
手动:runtime.gc()
设置GC:
// 将 GC 触发阈值设置为 50%
runtime.SetGCPercent(50)
4.Golang的内存模型,为什么小对象多了会造成gc压力
Golang 的内存模型是基于逃逸分析的。当一个对象在编译时无法确定其存活范围时,就会被认为是逃逸对象,会被分配在堆上。相反,如果一个对象的生命周期可以确定在函数内部,就会被分配在栈上。
对于小对象来说,其分配和回收的开销相对较小。但当有大量小对象时,就会出现一些问题:
1.大量小对象占用的内存空间会比较大,导致 GC 扫描的范围增加,GC 压力增大。
2.小对象的分配和回收比较频繁,也会增加 GC 的负担。
3.大量小对象的内存碎片化会影响内存的利用率。
所以在设计 Golang 程序时,要尽量避免产生大量小对象,比如通过对象池、Sync.Pool 等技术来复用对象。同时也要关注内存使用情况,适当增加内存限制,避免 GC 压力过大。
5.查看GC相关的性能报告,线程数,协程数等资源
//可以使用 pprof 工具收集 GC 相关的性能数据,并生成报告进行分析,例如:
//访问:http://localhost:6060/debug/pprof
import _ "net/http/pprof"
func main() {
// 启动 pprof 服务
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// 你的程序逻辑
}