Golang GC

常见垃圾回收机制

引用计数

对每个对象维护一个引用计数,当引用对象的对象被销毁时,引用计数-1,如果引用计数 为0,则进行垃圾回收
优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
代表语言:Python、PHP、Swift

标记-清除

从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回 收。
优点:解决了引用计数的缺点。
缺点:需要STW,即要暂时停掉程序运行。
代表语言:Golang(其采用三色标记法)

分代收集

按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不能的回收算法和回收频率。
优点:回收性能好
缺点:算法复杂
代表语言: JAVA

Golang的GC演变

Go V1.3之前的标记-清除(mark and sweep)算法

主要流程:

1、暂停业务逻辑(启动STW);
2、开始标记可达对象;
3、清除未标记对象;
4、停止STW,让程序继续跑。
将STW的步骤提前了异步,因为在Sweep清除的时候,可以不需要STW停止,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题。但是无论怎么优化,Go V1.3都面临这个一个重要问题,就是mark-and-sweep 算法会暂停整个程序。Go V1.3都面临这个一个重要问题,就是mark-and-sweep 算法会暂停整个程序。

Go V1.5的三色并发标记法

三色分别指的是:
白色标记表:所在的span的gcmarkBits中对应的bit为0
灰色标记表:所在的span的gcmarkBits中对应的bit为1,并且对象在标记队列中
黑色标记表: 所在的span的gcmarkBits中对应的bit为1,并且对象已经从标记队列中取出并处理

三色标记的过程
1.开始标记,只要是新创建的对象,默认颜色都是标记为"白色",以上图为例,当前对象1- 7都是白色
2.从程序(对象根节点)出发,即从上图的程序出发,开始遍历所有对象,把遍历到的对象从白色集合放入到灰色集合,当前对象1,对象4为灰,其余为白
3.遍历灰色集合,将灰色对象应用的对象从白色集合放入灰色集合,之后将原灰色集合放 入黑色集合,当前1,4为黑,2,7为灰,3,5,6为白

  1. 重复第三步,直到灰色表中无任何对象
  2. 回收所有白色标记的对象,也就是回收垃圾

没有STW的三色标记法

基于上述的三色并发标记法来说, 它是一定要依赖STW的. 因为如果不暂停程序, 程序的逻辑 改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。来看看 一个场景,如果三色标记法, 标记过程不使用STW将会发生什么事情?
最后发现,本来是对象4合法引用的对象3,却被GC给“误杀”回收掉了。 可以看出,有两种情况,在三色标记法中,是不希望被发生的。

  1. 条件1: 一个白色对象被黑色对象引用(白色被挂在黑色下)
  2. 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色)
    如果当以上两个条件同时满足时,就会出现对象丢失现象! 并且,如图所示的场景中,如果示例中的白色对象3还有很多下游对象的话, 也会一并都清 理掉。 为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用 关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。 那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢? 答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。

屏障机制

GC的触发

memstats.heap_live >= memstats.gc_trigger //heap_live is the number of bytes considered live by the GC
其中memstats.gc_trigger的计算公式是:
trigger = unit64(float64(memstats.heap_marked)*(1+triggerRatio))//heap_marked
is the number of bytes marked by the previous GC tiggerRatio 与goalGrowthRatio正相关 
goalGrowthRatio = float64(gcpercent)/100

公式中的goalGrowthRatio“目标Heap增长率”通过设置环境变量GOGC(gcpercent)调整,默认值为100。 比如当前程序使用4M堆内存,即memstats.heap_marked内存为4M,当程序占用的内存 上升到memstats.heap_marked*(1+GOGC/100)=8M时候,gc就会被触发,开始进行相关的gc操作。
因此,可以通过GOGC 来做参数调优。该参数主要控制的是下一次 gc开始的时候的内存使用量。如何对 GOGC 的参数进行设置,要根据生产情况中的实际场景来定,比如 GOGC 参数提升,来减少 GC 的频率。

主动:默认2min触发一次gc,src/runtime/proc.go:forcegcperiod
被动:runtime.gc()

性能优化

1、能返回实例值的函数就别返回指针。 go里有独特的逃逸机制,指针的返回值一定会发生变量逃逸,逃逸的行为被理解为从栈中 跑到了堆中,而堆中的gc是相对较慢的。返回值类型,会在内联处理后销毁,返回指针类型,会逃逸到堆上,增加gc压力。 这不仅应用于函数返回值情况,如果有可能的话,尽量程序中还是要少用指针,因为指针变量在gc的时候会导致二次遍历,使得整个gc变慢。
逃逸分析:
一般来说,在程序中全局变量、内存占用大的局部变量、发生了逃逸的局部变量存在的地方就是堆,这一块内存没有特定的结构,也没有固定的大小,可以根据需要进行调整。 简单来说有大量数据要存的时候,就存在堆里面。堆是进程级别的。当一个变量需要分配在堆上的时候,开销会比较大,对于go这种带GC的语言来说,也会增加gc压力,同时也容易造成内存碎片。
golang逃逸分析最基本的原则是:如果一个函数返回的是一个(局部)变量的地址,那么 这个变量就发生逃逸 。
在golang里面,变量分配在何处和是否使用new无关,意味着程序猿无法手动指定某个变 量必须分配在栈上或者堆上,所以我们需要通过一些方法来确定某个变量到底是分配在了 栈上还是堆上。

package main
func main() {
a := f1()
*a++ }

//go:noinline func f1() *int {
i := 1
return &i }

附带一个逃逸分析的指令: go build -gcflags '-m' main.go 以上逃逸分析结果:
go build -gcflags '-m' escape.go # command-line-arguments
./escape.go:3:6: can inline main
./escape.go:11:9: &i escapes to heap
./escape.go:10:2: moved to heap: i

能引起变量逃逸到堆上的典型情况

  1. 在方法内把局部变量指针返回: 局部变量原本应该在栈中分配,在栈中回收。但是由于 返回时被外部引用,因此其生命周期大于栈,则溢出。
  2. 发送指针或带有指针的值到 channel 中。 在编译时是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  3. 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  4. slice的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充就会在堆上分配。
  5. 在 interface 类型上调用方法。在 interface 类型上调用方法都是动态调度的 —— 方 法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

逃逸分析

package main import "fmt" 
func main() {
}
ch := make(chan *int, 10) f1(ch)
f2(ch)
func f1(ch chan *int) { a := 1
ch <- &a }
func f2(ch chan *int) { data := <- ch
fmt.Println(data)
}

./escape.go:7:4: &a escapes to heap 验证2 ./escape.go:8:4: data escapes to heap 验证5
###对象重用
深度很大的循环或递归中,防止爆栈。
for i:=1;i<100000;i++{ tmp := t{}
} return

var tmp t
for i:=1;i<100000;i++{
tmp = t{} }
return
在循环和递归里,声明临时变量会被存进栈中,只要不涉及到指针的逃逸,栈上的内存在该函数return后就会释放。那么如果是已知深度比较浅的循环(递归)内部,是不介意出现:= 操作的。
但是在无法预估for 或者递归的深度时,如果深度很大,循环了千万次,重点推荐第二种方法,虽然函数return后释放该函数在栈上开辟的变量,但是降低了爆栈的风险。 原因很简单,对于GC开发者不可能让一个对象占用100M内存跟一万个对象占用100M 内存同样消耗性能,显然那一个占用100M内存的对象,当发现它不需要回收的话,我就不需要做什么事情了,而那一万个对象,我需要逐个检查是否还有被引用,所以该场景下内存大小不是关键,对象数量才是关键。

总而言之就是尽量减少对象分配,尽量做到对象的重用。
对于做到对象重用这一点,为了减少GC,golang提供了对象重用的机制,也就是 sync.Pool对象池。 sync.Pool是可伸缩的,并发安全的。其大小仅受限于内存的大小,可以被看作是一个存放可重用对象的值的容器。 设计的目的是存放已经分配的但是暂时不用的对象,在需要用到的时候直接从pool中取。 任何存放区其中的值可以在任何时候被删除而不通知,在高负载下可以动态的扩容,在不活跃时对象池会收缩。

合理的选择数据结构

我们知道在有数据频繁插入和删除的场景下,slice可以扩容但是要收缩就比较麻烦了,于是想到了链表,链表要删除单个节点的时候,只需要把节点从链表上断开,不需要复制数 据,效率高于数组结构。这里直观的表示一下两种数据结构的区别:

type MyData1 struct { 
next *MyData
Id int
Name string }

var mydata1 *MyData1

type MyData2 struct { 
Id int
Name string }

var mydata2 []MyData2

面示例代码的mydata1用的是链表结构,每个节点都有一个指向下一个节点的指针,想像下存储1万个对象到mydata1,是不是需要创建1万个MyData1类型的对象。 示例中的mydata2用的是slice结构,一个slice就是一个对象,其中的元素都是这一块内存中的值,而不是对象,需要注意 []MyData2 和 []*MyData2 是不一样的,如果换用第二种 写法,那么每个元素一样都是一个对象,因为这时候slice存的不是值而是指向对象的指针,而这些指针每一个都分别指到一个对象。
所以有些情况下,采用 []MyData2这种方式比使用链表会大大减少对象数目,虽然可能带来内存占用增大,但是会大大降低GC带来的性能损耗。

排查和定位GC问题

GODEBUG gctrace =1 go run main.go

你可能感兴趣的:(Golang GC)