0%

Golang内存逃逸分析

什么是内存逃逸?

在了解什么是内存逃逸之前,我们先来简单地熟悉一下两个概念。栈内存和堆内存。本次主要是讲述的是Golang的内存逃逸,故而关于内存分配和垃圾回收就不做赘述了。后面小编会单独出两篇来写这个,有需要的同学可以关注小编。关于这一块,我们现在只需要了解三点。

Golang的GC主要是针对堆的,不是栈。
引用类型的全局变量分配在堆上,值类型的全局变量分配在栈上。
局部变量内存分配可能在栈上也可能在堆上。
有了前面的基础知识,那我们简单粗暴地介绍一下内存逃逸。一个对象本应该分配在栈上面,结果分配在了堆上面,这就是内存逃逸。

要了解内存逃逸的场景,首先我们要学会怎么分析内存逃逸。其实分析起来很简单,只需要一条简单的命令,即gcflags。这个是有很多参数的,此处只举一个最基本的例子。

go build -gcflags ‘-m’ main.go

我们都知道程序运行产生的数据一般都是在内存中,golang中内存分配又分为有栈(stack)和堆(heap)两种,如果原本应该分配到“栈”中的内存分配到到了“堆”上,就产生了所谓的“内存逃逸”。

栈(stack)和堆(heap)内存分配有以下区分:

  • 栈分配:分配速度快,只需要CPU的两个指令“PUSH”和“RELEASE“进行分配和释放。
  • 堆分配:分配速度较慢,首先需要找到一块大小合适的内存块,之后还需要gc垃圾回收才能释放。

写过C/C++的小伙伴应该知道,使用比较经典的malloc和new函数可以在堆上分配一块内存,这块内存的使用和回收(销毁)的任务在程序员中,处理不当,很可能会发生内存泄露。

Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。
编译器会根据变量是否被外部引用来决定是否逃逸:
如果在函数外面没有引用到,则优先放到栈区中;
如果在函数外面存在引用的可能,则就会放到堆区中;

如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%)。
堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。

常见的内存逃逸场景

1.函数内将局部变量指针返回,被外部引用,其生命周期大于栈,溢出。

1
2
3
4
5
6
7
8
9
10
type User struct {}

func NewUser() *User{
user := User{}
return &user
}

func main() {
_ = NewUser()
}
  1. 对象太大, 超过栈帧大小
1
2
3
4
func main() {
_ = make([]int, 0, 1000)
_ = make([]int, 0, 10000)
}
  1. 闭包引用逃逸
1
2
3
4
5
6
7
8
9
10
func f() func() int{
a := 1
return func() int {
return a
}
}

func main() {
f()
}
  1. 动态类型逃逸
1
2
3
4
func main() {
a := 1
fmt.Println("a逃逸,a:", a)
}

因为fmt.Println函数参数类型是interface{}, 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。

5.在切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上

1
2
3
4
func main() {
ch := make(chan *string, 1)
ch <- new(string)
}

分析内存逃逸的意义

前面给大家列举了四种内存逃逸的场景,那么问题来了,分析内存逃逸有什么用呢?简单的总结就是两点:减轻GC的压力,提高分配速度。

上文已经说过,Golang的GC主要是针对堆的,而不是栈。试想一下,如果大量的对象从栈逃逸到堆上,是不是就会增加GC的压力。在GC的过程中会占用比较大的系统开销(一般可达到CPU容量的25%)。而且目前所有的GC都有STW这个死结,而STW会造成用户直观的’卡顿’。非常影响用户体验。

此外,堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆内存分配首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少GC的压力,提高程序的运行速度。

怎么避免内存逃逸

最后说一下怎么避免内存逃逸吧。首先需要注意的是,Golang在编译的时候就可以确立逃逸,并不需要等到运行时。这样就给了咱们避免内存逃逸的机会。

首先咱们明确一点,小编认为没有任何方式能绝对避免内存逃逸。原因嘛,就是存在【动态类型】这种逃逸方式,几乎所有的库函数都是动态类型的。当然也不是说咱么要破罐子破摔,该避免还是要避免一下的,主要的原则有以下几种,分别针对上面几种场景。

尽量减少外部指针引用,必要的时候可以使用值传递。
对于自己定义的数据大小,有一个基本的预判,尽量不要出现栈空间溢出的情况。
Golang中的接口类型的方法调用是动态调度,如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型。
尽量不要写闭包函数,可读性差还逃逸。

如何避免内存逃逸
对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型。
不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
预先设定好slice长度,避免频繁超出容量,重新分配。