Golang学习之内存逃逸分析

在开始剖析Go逃逸分析前,我们要先清楚什么是堆栈。数据结构中有堆栈,内存分配中也有堆栈,两者在定义和用途上虽不同,但也有些许关联,内存分配中栈的压栈和出栈操作,类似于数据结构中的栈的操作方式

内存分配中的堆栈

程序在运行过程中,必不可少的会使用变量、函数和数据,变量和数据在内存中存储的位置可以分为:堆区(Heap)和栈区(Stack),一般由C或C++编译的程序占用内存为:

  • 栈区
  • 堆区
  • 全局区
  • 常量区
  • 程序代码区

软件程序中的数据和变量都会被分配到程序所在的虚拟内存空间中

每个函数都有自己独立的栈空间,函数的调用参数、返回值以及局部变量大都被分配到该函数的栈空间中, 这部分内存由编译器进行管理,编译时确定分配内存的大小。栈空间有特定的结构和寻址方式,所以寻址十分迅速、开销小,只需要2条 CPU 指令,即压栈出栈 PUSH 和 RELEASE,由于函数栈内存的大小在编译时确定, 所以当局部变量数据太大就会发生栈溢出(Stack Overflow)。当函数执行完毕后, 函数的栈空间被回收, 无需手动去释放。

区别于堆空间,通过 malloc 出来的内存,函数执行完毕后需要“手动”释放,“手动”释放在有垃圾回收的语言中,表现为垃圾回收系统,比如 Golang 语言的 GC 系统,GC 系统通过标记等手段,识别出需要回收的空间。

堆空间没有特定的结构,也没有固定的大小,可以动态进行分配和调整,所以内存占用较大的局部变量会放在堆空间上,在编译时不知道该分配多少大小的变量,在运行时也会分配到堆上,在堆上分配内存开销比在栈上大,而且堆上分配的内存需要手动释放,对于 Golang 这种有 GC 机制的语言, 也会增加 GC 压力, 也容易造成内存碎片。

注:栈是线程级的,堆是进程级的

内存逃逸

所谓内存逃逸,就是本该分配于栈空间的变量,被分配到了堆空间,过多的内存逃逸会导致GC压力变大,堆空间碎片化。

Go语言中,变量不能显示的指定分配在栈空间还是堆空间,但是官方回复中大致表示了一个原则:如果局部变量被其他函数捕获,那么就分配在堆上。

逃逸分析

在编程语言的编译优化原理中,分析指针动态范围的方法称之为逃逸分析,通俗来说,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。逃逸分析有两个基本的不变性:

  • 指向栈对象的指针不能存储在堆中
  • 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)

分析工具

通过编译工具查看详细的逃逸分析过程 go build -gcflags '-m -l' xxx.go 编译参数(-gcflags):

  • -N:禁止编译优化
  • -l:禁止内联
  • -m:逃逸分析
  • -benchmem:压测时打印内存分配统计

通过逃逸分析判断一个变量到底是分配在堆上还是栈上

逃逸场景

指针逃逸

指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。

// main.go
package main

import "fmt"

type Demo struct {
	name string
}

func createDemo(name string) *Demo {
	d := new(Demo) // 局部变量 d 逃逸到堆
	d.name = name
	return d
}

func main() {
	demo := createDemo("demo")
	fmt.Println(demo)
}

在这个例子中,函数createDemo的局部变量d发生了逃逸,d作为返回值在main函数中继续使用,因此d指向的内存不能分配在栈上,只能分配在堆上,借助分析工具查看逃逸情况

    $ go build -gcflags=-m main.go 
    ./main.go:10:6: can inline createDemo
    ./main.go:17:20: inlining call to createDemo
    ./main.go:18:13: inlining call to fmt.Println
    ./main.go:10:17: leaking param: name
    ./main.go:11:10: new(Demo) escapes to heap
    ./main.go:17:20: new(Demo) escapes to heap   //指针逃逸
    ./main.go:18:13: demo escapes to heap        //interface{}动态类型逃逸
    ./main.go:18:13: main []interface {} literal does not escape
    ./main.go:18:13: io.Writer(os.Stdout) escapes to heap
    :1: (*File).close .this does not escape

escapes to heap表示逃逸到堆上了

动态反射interface{}变量

在 Go 语言中,接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。仍以上面的例子

func main() {
   demo := createDemo("demo")
   fmt.Println(demo)
}

./main.go:18:13: demo escapes to heap

demo是main函数的一个局部变量,该变量作为实参传递给fmt.Println(),但是因为fmt.Println()的参数类型是interface{},因此也发生了逃逸

解释fmt.Println 之类的底层系统函数,实现逻辑会基于interface{} 做反射,通过 reflect.TypeOf(arg).Kind() 获取接口对象的底层数据类型,创建具体类型对象时,会发生内存逃逸。由于 interface{} 的变量,编译时无法确定变量类型以及申请空间大小,所以不能在栈空间上申请内存,需要在 runtime 时动态申请,理所应当地发生内存逃逸。

申请栈空间过大

栈空间大小是有限的,如果编译时发现局部变量申请的空间过大,则会发生内存逃逸,在堆空间上给大变量分配内存

func main() {
   num := make([]int, 0, 10000)
   _ = num
}

.\main.go:404:13: make([]int, 0, 10000) escapes to heap   //发生逃逸

经过测试,num := make([]int, 0, 8193) 时刚好发生内存逃逸。在 64 位机上 int 类型为 8B,即 8192 * 8B = 64KB

func main() {
   num1 := make([]int, 0, 8192)
   _ = num1
   
   num2 := make([]int, 0, 8193)
   _ = num2
}

.\main.go:404:14: make([]int, 0, 8192) does not escape
.\main.go:407:14: make([]int, 0, 8193) escapes to heap

切片变量自身和元素的逃逸

1.未指定slice的lencap时,slice自身未发生逃逸,slice的元素发生逃逸。因此slice会动态扩容,编译器不知道容量大小,无法提前在栈空间分配内存,扩容后slice的元素可能会被分配到堆空间,所以slice容器自身也不能被分配到栈空间

type person struct {
   Name string
}

func main() {
   var num []*person
   p1 := &person{
      Name: "ss",
   }
   num = append(num, p1)
}

.\main.go:409:8: &person{...} escapes to heap

2.只指定slice的长度即array,数组本身和元素均在栈上分配,均未发生逃逸

闭包

所谓闭包,就是函数与其所处环境捆绑的组合,也就是说,闭包可以让你在一个内部函数中访问到其外部函数的作用域

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

func main() {
	in := Increase()
	fmt.Println(in()) // 1
	fmt.Println(in()) // 2
}

Increase() 返回值是一个闭包函数,该闭包函数访问了外部变量 n,那变量 n 将会一直存在,直到 in 被销毁。很显然,变量 n 占用的内存不能随着函数 Increase() 的退出而回收,因此将会逃逸到堆上。

.\main.go:408:2: moved to heap: n
.\main.go:409:9: func literal escapes to heap
.\main.go:417:13: ... argument does not escape
.\main.go:417:16: in() escapes to heap
.\main.go:418:13: ... argument does not escape
.\main.go:418:16: in() escapes to heap

逃逸分析的作用

  • 通过逃逸分析能确定哪些变量分配到栈空间,哪些分配到堆空间,对空间需要 GC 系统回收资源,GC 系统会有微秒级的 STW,降低 GC 的压力能提高系统的运行效率。
  • 栈空间的分配比堆空间更快性能更好,对于热点数据分配到栈上能提高接口的响应。
  • 栈空间分配的内存,在函数执行完毕后由系统回收资源,不需要 GC 系统参与,也不需要 GC 标记清除,可降低内存的占用

到此这篇关于Golang学习之内存逃逸分析的文章就介绍到这了,更多相关Golang内存逃逸内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

你可能感兴趣的:(Golang学习之内存逃逸分析)