image frame

XiShng Blog

既往拼搏,守护一生所爱

Golang 内存逃逸

什么是内存逃逸?

在程序中,每个函数块都会有自己的内存区域用来存自己的局部变量(内存占用少)、返回地址、返回值之类的数据,这一块内存区域有特定的结构和寻址方式,寻址起来十分迅速,开销很少。这一块内存地址称为栈。栈是线程级别的,大小在创建的时候已经确定,当变量太大的时候,会”逃逸”到堆上,这种现象称为内存逃逸。简单来说,局部变量通过堆分配和回收,就叫内存逃逸。

有什么危害?

堆是一块没有特定结构,也没有固定大小的内存区域,可以根据需要进行调整。全局变量,内存占用较大的局部变量,函数调用结束后不能立刻回收的局部变量都会存在堆里面。变量在堆上的分配和回收都比在栈上开销大的多。对于 go 这种带 GC 的语言来说,会增加 gc 压力,同时也容易造成内存碎片。

如何判断程序是否发生了内存逃逸?

build时添加-gcflags=-m

选项可分析内存逃逸情况,比如输出./main.go:3:6: moved to heap: x

表示局部变量x逃逸到了堆上。

逃逸场景有哪些?

  • 指针逃逸:向 channel 发送指针数据。因为在编译时,不知道channel中的数据会被哪个 goroutine 接收,因此编译器没法知道变量什么时候才会被释放,因此只能放入堆中。
1
2
3
4
5
6
7
8
9
10
11
package main

func main() {
ch := make(chan int, 1)
x := 5
ch <- x // x不发生逃逸,因为只是复制的值
ch1 := make(chan *int, 1)
y := 5
py := &y
ch1 <- py // y逃逸,因为y地址传入了chan中,编译时无法确定什么时候会被接收,所以也无法在函数返回后回收y
}
  • 栈空间不足逃逸:当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。
1
2
3
4
5
6
7
8
package main

func main() {
s := make([]int, 10000, 10000)
for index, _ := range s {
s[index] = index
}
}
  • 动态类型逃逸:在 interface 类型上调用方法时会把interface变量使用堆分配, 因为方法的真正实现只能在运行时知道。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

type foo interface {
fooFunc()
}

type foo1 struct{}

func (f1 foo1) fooFunc() {}

func main() {
var f foo
f = foo1{}
f.fooFunc() // 调用方法时,f发生逃逸,因为方法是动态分配的
}
  • 闭包引用对象逃逸:局部变量在函数调用结束后还被其他地方使用,比如函数返回局部变量指针或闭包中引用包外的值。因为变量的生命周期可能会超过函数周期,因此只能放入堆中。
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func Foo () func (){
x := 5 // x发生逃逸,因为在Foo调用完成后,被闭包函数用到,还不能回收,只能放到堆上存放
return func () {
x += 1
}
}

func main() {
inner := Foo()
inner()
}

如何避免内存逃逸?

  • 对于小型的数据,使用传值而不是传指针,避免内存逃逸。
  • 避免使用长度不固定的slice切片,在编译期无法确定切片长度,只能将切片使用堆分配。
  • interface调用方法会发生内存逃逸,在热点代码片段,谨慎使用。

Golang的GC发展史

Go V1.3之前的标记-清除:

  1. 暂停业务逻辑,找到不可达的对象,和可达对象
  2. 开始标记,程序找出它所有可达的对象,并做上标记
  3. 标记完了之后,然后开始清除未标记的对象。
  4. 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束

标记-清除的缺点:

STW(stop the world):让程序暂停,程序出现卡顿

标记需要扫描整个heap,同时清除数据会产生heap碎片

为了减少STW的时间,后来对上述的第三步和第四步进行了替换。

Go V1.5 三色标记法:

  1. 把新创建的对象默认标记为白色
  2. 每次GC回收开始,然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入灰色集合
  3. 遍历灰色集合,将灰色对象引用的对象从白色集合放入到灰色集合,之后将此灰色对象放入到黑色集合
  4. 重复第三步,直到灰色中无任何对象
  5. 回收所有的白色标记的对象,也就是回收垃圾

三色标记法在不采用STW保护时会出现:

  1. 一个白色对象被黑色对象引用
  2. 灰色对象与它之间的可达关系的白色对象遭到破坏

这两种情况同时满足,会出现对象丢失

解决方案:

  1. 强三色不变式:强制性的不允许黑色对象引用白色对象(破坏1)
  2. 弱三色不变式:黑色对象可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象(破坏2)

屏障:

  1. 插入屏障:在A对象引用B对象的时候,B对象被标记为灰色(满足强三色不变式,黑色引用的白色对象会被强制转坏为灰色)。

只有堆上的对象触发插入屏障,栈上的对象不触发插入屏障。在准备回收白色前,重新遍历扫描一次栈空间。此时加STW暂停保护栈,防止外界干扰.

不足:结束时需要使用STW来重新扫描栈

  1. 删除屏障:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色(满足弱三色不变式)。

不足:回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

Go V1.8的三色标记法+混合写屏障机制:

  1. GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
  2. GC期间,任何在栈上创建的新对象,均为黑色
  3. 被删除对象标记为灰色
  4. 被添加的对象标记为灰色

满足:变形的弱三色不变式(结合了插入、删除写屏障的优点)

常见的垃圾回收机制

引用计数

对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1, 当引用计数器为0时回收该对象

  • 优点:对象可以很快被回收,不会出现内存被耗尽或达到某个阀值时才被回收
  • 缺点:不能很好的处理循环引用,而且实时的维护引用计数,也存在时间资源的耗费
  • 代表语言:Python、PHP、Swift

分代收集

在JVM虚拟机中,划分为三个代:年轻代(Young Generation)、老年代(Old Generation)和持久代(Permanent Generation)。并按照不同的生命周期将其划分至不同的代空间,不同代空间具有不同的回收算法和回收频率

  • 优点:回收性能好
  • 缺点:算法过于复杂
  • 代表语言:Java

标记-清楚

从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。

  • 优点:解决了引用计数的缺点

  • 缺点:需要STW

  • 代表语言:Golang(三色标记清除法)

    • 三色标记清除法(白、灰、黑)

      出自论文《On-the-fly Garbage Collection: An Exercise in Cooperation》,Golang团队根据该论文在Go语言中实现了垃圾回收算法

      • 不同颜色代表的含义:
        • 白色集合:允许有指针指向黑色集合
        • 黑色集合:没有任何指针指向白色集合的对象集合
        • 灰色集合:可能会有指针指向白色集合的对象

      由上述的定义可知,白色集合时最后要回收的对象集合。下面更具体的讲述该算法的步骤

      1. 所有的对象进入白色集合,此时三个集合的状态如下:
        • 白色集合:A B C D E F G H
        • 灰色集合:
        • 黑色集合:
      2. 找到根对象(根对象是指程序能直接访问的对象,比如全局变量),放入灰色集合,此步骤结束后状态如下:
        • 白色集合:B C D F G H
        • 灰色集合:A E
        • 黑色集合:
      3. 取出灰色集合的对象,并把这些对象只想的对象放入到灰色集合,自己则放入黑色集合。如此循环,知道灰色集合为空。该步骤结束后的状态如下:
        • 白色集合:F G H
        • 灰色集合:
        • 黑色集合:A E B C D
      4. 清理白色集合的对象

      Golang的垃圾回收是并行处理的,所以在不同的集合之间移动的时候要考虑读写问题。
      Golang的垃圾回收泗洪过修改器(Mutator)和写阻塞(Write Barrier)来完成的。

请我喝杯咖啡吧~

支付宝
微信