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
  <autogenerated>: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的len和cap时,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内存逃逸内容请搜索编程宝库以前的文章,希望以后支持编程宝库

 前言在项目实践中,有时候我们需要将struct结构体转为map映射表,然后基于map做数据裁剪或操作。那么下面我来介绍下常用的两种转换方式,以及对它们做对比,最后采用更优雅的方式 ...