4.2 内存逃逸分析

Kesa...大约 4 分钟golang

1. Stack and Heap

Golang 程序会在两个区域为变量分配内存:

  1. 栈(stack),每个 goroutine 持有自身独有的栈空间
  2. 堆(heap)

在栈上分配和回收内存开销很低,仅需两个CPU指令:PUSHPOP

在堆上分配内存,很大的开销来自于 GC。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一个典型耗时是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。

垃圾回收(GC)的工作原理open in new window

2. 逃逸分析

逃逸分析(escape analysis) 是编译器决定内存的分配位置的方式。

2.1 指针逃逸

在函数中创建了一个对象,函数返回了对象的指针,此时对象无法分配在栈上,函数返回时将被回收,所以只能分配在堆上。

type s struct {
	name string
}

func getS() *s {
	return &s{}
}

func escape() {
	a := getS()
	_ = a
}
$go build -gcflags "-m"  .\escape.go
.\escape.go:7:6: can inline getS
.\escape.go:11:6: can inline escape
.\escape.go:12:11: inlining call to getS
.\escape.go:8:9: &s{} escapes to heap
.\escape.go:12:11: &s{} does not escape
  • -gcflags -m:查看编译器优化决策

可以看到&s{}被逃逸到堆上。

2.3 动态类型逃逸

interface{}可以存储任意类型,编译期难以判断具体类型,此时会发生逃逸。

func escapeAny() {
	b := 1
	fmt.Println(b)
}

func readAny(v interface{}) {
	c := v
	_ = c
}
// escape analysis
.\escape.go:20:14: b escapes to heap
.\escape.go:23:14: v does not escape

可以看到此时b发生了逃逸。

2.4 栈空间不足

操作系统对内核线程使用的栈空间是有大小限制的,64 位系统上通常是 8 MB。可以使用 ulimit -a 命令查看机器上栈允许占用的内存的大小。

因为栈空间通常比较小,因此递归函数实现不当时,容易导致栈溢出。

对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小也不会超过操作系统的限制。

若局部变量超过一定大小大小不确定时,将发生逃逸。

func localEscape() {
    s1 := make([]int, 0)
    s2 := make([]int, 1024)
    s3 := make([]int, 2048)
    s4 := make([]int, 10000)
    
    n := 1
    s5 := make([]int, n)

    _ = s1
    _ = s2
    _ = s3
    _ = s4
    _ = s5
}
.\escape.go:31:12: make([]int, 0) does not escape
.\escape.go:32:12: make([]int, 1024) does not escape
.\escape.go:33:12: make([]int, 2048) does not escape
.\escape.go:34:12: make([]int, 10000) escapes to heap
.\escape.go:36:12: make([]int, n) escapes to heap

2.5 闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

闭包open in new window

func Increase() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func closure() {
	incre := Increase()

	incre()
}
.\escape.go:46:2: moved to heap: n

因为函数Increase返回了闭包函数,闭包函数访问了外部变量n,此时n将发生逃逸。

3. 根据逃逸分析提升性能

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。

传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

所以在一般情况下:

  • 对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。

  • 对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

Reference

  1. https://geektutu.com/post/hpg-escape-analysis.htmlopen in new window
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2