golang 的GC原理

语言: CN / TW / HK

原文 http://alblue.cn/articles/2020/07/07/1594131614114.html#toc_h4_19

GC(garbage cycle)垃圾回收机制,是用于对申请的内存进行回收,防止内存泄露等问题的一种机制。

go的GC机制

调用方式 所在位置 代码
定时调用 runtime/proc.go:forcegchelper() gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
分配内测时调用 runtime/malloc.go:mallocgc() gcTrigger{kind: gcTriggerHeap}
手动调用 runtime/mgc.go:GC() gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})

三色标记法

以下是Golang GC算法的里程碑:

  • v1.1 STW
  • v1.3 Mark STW, Sweep 并行
  • v1.5 三色标记法
  • v1.8 hybrid write barrier(混合写屏障)

go的gc是基于 标记-清扫 算法,并做了一定改进,减少了STW的时间。

标记-清扫(Mark And Sweep)算法

此算法主要有两个主要的步骤:

  • 标记(Mark phase)
  • 清除(Sweep phase)

第一步,找出不可达的对象,然后做上标记。

第二步,回收标记好的对象。

操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 stop the world

标记-清扫(Mark And Sweep)算法存在什么问题?

标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:

  • STW,stop the world;让程序暂停,程序出现卡顿。
  • 标记需要扫描整个heap
  • 清除数据会产生heap碎片

这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。

三色并发标记法

1.首先将程序创建的对象全部标记为白色

2.gc开始扫描,并将可达的对象标记为灰色

3.再从灰色对象中找到其引用的对象,将其标记为灰色,将自身标记成黑色

重复以上2、3步骤,直至没有灰色对象

4.对所有白色对象进行清除

gc和用户逻辑如何并行操作?

标记-清除(mark and sweep)算法的STW(stop the world)操作,就是runtime把所有的线程全部冻结掉,所有的线程全部冻结意味着用户逻辑是暂停的。这样所有的对象都不会被修改了,这时候去扫描是绝对安全的。

Go如何减短这个过程呢?标记-清除(mark and sweep)算法包含两部分逻辑:标记和清除。

我们知道Golang三色标记法中最后只剩下的黑白两种对象,黑色对象是程序恢复后接着使用的对象,如果不碰触黑色对象,只清除白色的对象,肯定不会影响程序逻辑。所以: 清除操作和用户逻辑可以并发。

进程新生成对象的时候,GC该如何操作呢?不会乱吗?

Golang为了解决这个问题,引入了 写屏障 这个机制。

写屏障:该屏障之前的写操作和之后的写操作相比,先被系统其它组件感知。

通俗的讲:就是在gc跑的过程中,可以监控对象的内存修改,并对对象进行重新标记。(实际上也是超短暂的stw,然后对对象进行标记)

在上述情况中, 新生成的对象,一律都标位灰色!

那么,灰色或者黑色对象的引用改为白色对象的时候,Golang是该如何操作的?

看如下图,一个黑色对象引用了曾经标记的白色对象。

[图片上传失败...(image-484f2d-1595036887912)]

这时候,写屏障机制被触发,向GC发送信号,GC重新扫描对象并标位灰色。

因此,gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。

堆栈

内存分配中的堆和栈

栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。

堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。

堆栈缓存方式

栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。

堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。

申请到 栈内存 好处:函数返回直接释放,不会引起垃圾回收,对性能没有影响。

内存分配逃逸

所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。

在函数中申请一个新的对象:

  • 如果分配 在栈中,则函数执行结束可自动将内存回收;
  • 如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;

逃逸场景(什么情况才分配到堆中)

指针逃逸

package main

type Student struct {
    Name string
    Age  int
}

func StudentRegister(name string, age int) *Student {
    s := new(Student) //局部变量s逃逸到堆

    s.Name = name
    s.Age = age

    return s
}

func main() {
    StudentRegister("Jim", 18)
}

虽然 在函数 StudentRegister() 内部 s 为局部变量,其值通过函数返回值返回,s 本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例。

终端运行命令查看逃逸分析日志:

<pre>go build -gcflags=-m</pre>

输出

./main.go:16:6: can inline StudentRegister
./main.go:25:6: can inline main
./main.go:26:17: inlining call to StudentRegister
./main.go:16:22: leaking param: name
./main.go:17:10: new(Student) escapes to heap
./main.go:26:17: new(Student) does not escape

可见在StudentRegister()函数中,也即代码第10行显示”escapes to heap”,代表该行内存分配发生了逃逸现象。

栈空间不足逃逸(空间开辟过大)

package main

func Slice() {
    s := make([]int, 1000, 1000)

    for index, _ := range s {
        s[index] = index
    }
}

func main() {
    Slice()
}

上面代码Slice()函数中分配了一个1000个长度的切片,是否逃逸取决于栈空间是否足够大。 直接查看编译提示,如下:

./main.go:20:6: can inline main
./main.go:13:11: make([]int, 1000, 1000) does not escape

所以只是1000的长度还不足以发生逃逸现象。然后就x10倍吧

./main.go:20:6: can inline main
./main.go:13:11: make([]int, 10000, 10000) escapes to heap

当切片长度扩大到10000时就会逃逸。

实际上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

动态类型逃逸(不确定长度大小)

很多函数参数为interface类型,比如fmt.Println(a …interface{}),编译期间很难确定其参数的具体类型,也能产生逃逸。

如下代码所示:

package main

import "fmt"

func main() {
    s := "Escape"
    fmt.Println(s)
}

又或者像前面提到的例子:

func F() {
    a := make([]int, 0, 20)     // 栈 空间小
    b := make([]int, 0, 20000) // 堆 空间过大 逃逸
 
    l := 20
    c := make([]int, 0, l) // 堆 动态分配不定空间 逃逸
}

闭包引用对象逃逸

Fibonacci数列的函数:

package main

import "fmt"

func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    f := Fibonacci()

    for i := 0; i < 10; i++ {
        fmt.Printf("Fibonacci: %d\n", f())
    }
}

Fibonacci()函数中原本属于局部变量的a和b由于闭包的引用,不得不将二者放到堆上,以致产生逃逸。

逃逸分析的作用是什么呢?

  1. 逃逸分析的好处是为了减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。
  2. 逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(逃逸的局部变量会在堆上分配 ,而没有发生逃逸的则有编译器在栈上分配)。
  3. 同步消除,如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

逃逸总结:

  • 栈上分配内存比在堆中分配内存有更高的效率
  • 栈上分配的内存不需要GC处理
  • 堆上分配的内存使用完毕会交给GC处理
  • 逃逸分析目的是决定内分配地址是栈还是堆
  • 逃逸分析在编译阶段完成

函数传递指针真的比传值效率高吗?

传递指针相比值传递减少了底层拷贝,可以提高效率,但是拷贝的数据量较小,由于指针传递会产生逃逸,可能会使用堆,也可能增加gc的负担,所以指针传递不一定是高效的。

GC的bug

https://zhuanlan.zhihu.com/p/32686933

代码优化

减少对象分配所谓减少对象的分配,实际上是尽量做到,对象的重用。 比如像如下的两个函数定义:

第一个函数没有形参,每次调用的时候返回一个 []byte,第二个函数在每次调用的时候,形参是一个 buf []byte 类型的对象,之后返回读入的 byte 的数目。

第一个函数在每次调用的时候都会分配一段空间,这会给 gc 造成额外的压力。第二个函数在每次迪调用的时候,会重用形参声明。

老生常谈 string 与 []byte 转化在 stirng 与 []byte 之间进行转换,会给 gc 造成压力 通过 gdb,可以先对比下两者的数据结构:

两者发生转换的时候,底层数据结结构会进行复制,因此导致 gc 效率会变低。解决策略上,一种方式是一直使用 []byte,特别是在数据传输方面,[]byte 中也包含着许多 string 会常用到的有效的操作。另一种是使用更为底层的操作直接进行转化,避免复制行为的发生。

少量使用+连接 string由于采用 + 来进行 string 的连接会生成新的对象,降低 gc 的效率,好的方式是通过 append 函数来进行。

append操作在使用了append操作之后,数组的空间由1024增长到了1312,所以如果能提前知道数组的长度的话,最好在最初分配空间的时候就做好空间规划操作,会增加一些代码管理的成本,同时也会降低gc的压力,提升代码的效率。

https://www.cnblogs.com/maoqide/p/12355565.html

三色标记法+混合写屏障

https://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651439356&idx=2&sn=264a3141ea9a4b29fe67ec06a17aeb99&chksm=80bb1e0eb7cc97181b81ae731d0d425dda1e9a8d503ff75f217a0d77bd9d0eb451555cb584a0&scene=21#wechat_redirect

Golang内存分配逃逸分析

https://www.cnblogs.com/shijingxiang/articles/12200355.html

分享到: