2

详解Go语言中的内存逃逸

 3 years ago
source link: https://segmentfault.com/a/1190000040450335
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

原文链接:## 面试官:小松子来聊一聊内存逃逸

哈喽,大家好,我是asong。最近无聊看了一下Go语言的面试八股文,发现面试官都喜欢问内存逃逸这个话题,这个激起了我的兴趣,我对内存逃逸的了解很浅,所以找了很多文章精读了一下,在这里做一个总结,方便日后查阅、学习。

什么是内存逃逸

初次看到这个话题,我是懵逼的,怎么还有内存逃逸,内存逃逸到底是干什么的?接下来我们一起来看看什么是内存逃逸。

我们都知道一般情况下程序存放在rom或者Flash中,运行时需要拷贝到内存中执行,内存会分别存储不同的信息,内存空间包含两个最重要的区域:堆区(Heap)和栈区(Stack),对于我这种C语言出身的人,对堆内存和栈内存的了解还是挺深的。在C语言中,栈区域会专门存放函数的参数、局部变量等,栈的地址从内存高地址往低地址增长,而堆内存正好相反,堆地址从内存低地址往高地址增长,但是如果我们想在堆区域分配内存需要我们手动调用malloc函数去堆区域申请内存分配,然后我使用完了还需要自己手动释放,如果没有释放就会导致内存泄漏。写过C语言的朋友应该都知道C语言函数是不能返回局部变量地址(特指存放于栈区的局部变量地址),除非是局部静态变量地址,字符串常量地址、动态分配地址。其原因是一般局部变量的作用域只在函数内,其存储位置在栈区中,当程序调用完函数后,局部变量会随此函数一起被释放。其地址指向的内容不明(原先的数值可能不变,也可能改变)。而局部静态变量地址和字符串常量地址存放在数据区,动态分配地址存放在堆区,函数运行结束后只会释放栈区的内容,而不会改变数据区和堆区。

所以在C语言中我们想在一个函数中返回局部变量地址时,有三个正确的方式:返回静态局部变量地址、返回字符串常量地址,返回动态分配在堆上的地址,因为他们都不在栈区,即使释放函数,其内容也不会受影响,我们以在返回堆上内存地址为例看一段代码:

#include "stdio.h"
#include "stdlib.h"
//返回动态分配的地址 
int* f1()
{
    int a = 9;
    int *pa = (int*) malloc(8);
    *pa = a;
    return pa;
}

int main()
{
    int *pb;
    pb = f1();
    printf("after : *pb = %d\tpb = %p\n",*pb, pb);
    free(pb);
    return 1;
}

通过上面的例子我们知道在C语言中动态内存的分配与释放完全交与程序员的手中,这样就会导致我们在写程序时如履薄冰,好处是我们可以完全掌控内存,缺点是我们一不小心就会导致内存泄漏,所以很多现代语言都有GC机制,Go就是一门带垃圾回收的语言,真正解放了我们程序员的双手,我们不需要在像写C语言那样考虑是否能返回局部变量地址了,内存管理交与给编译器,编译器会经过逃逸分析把变量合理的分配到"正确"的地方。

说到这里,可以简单总结一下什么是内存逃逸了:

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸。

什么是逃逸分析

上面我们知道了什么是内存逃逸,下面我们就来看一看什么是逃逸分析?

上文我们说到C语言使用malloc在堆上动态分配内存后,还需要手动调用free释放内存,如果不释放就会造成内存泄漏的风险。在Go语言中堆内存的分配与释放完全不需要我们去管了,Go语言引入了GC机制,GC机制会对位于堆上的对象进行自动管理,当某个对象不可达时(即没有其对象引用它时),他将会被回收并被重用。虽然引入GC可以让开发人员降低对内存管理的心智负担,但是GC也会给程序带来性能损耗,当堆内存中有大量待扫描的堆内存对象时,将会给GC带来过大的压力,虽然Go语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率,但是如果我们的程序仍在堆上分配了大量内存,依赖会对GC造成不可忽视的压力。因此为了减少GC造成的压力,Go语言引入了逃逸分析,也就是想法设法尽量减少在堆上的内存分配,可以在栈中分配的变量尽量留在栈中。

小结逃逸分析:

逃逸分析就是指程序在编译阶段根据代码中的数据流,对代码中哪些变量需要在栈中分配,哪些变量需要在堆上分配进行静态分析的方法。堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。所以逃逸分析更做到更好内存分配,提高程序的运行速度。

Go语言中的逃逸分析

Go语言的逃逸分析总共实现了两个版本:

  • 1.13版本前是第一版
  • 1.13版本后是第二版

粗略看了一下逃逸分析的代码,大概有1500+行(go1.15.7)。代码我倒是没仔细看,注释我倒是仔细看了一遍,注释写的还是很详细的,代码路径:src/cmd/compile/internal/gc/escape.go,大家可以自己看一遍注释,其逃逸分析原理如下:

  • pointers to stack objects cannot be stored in the heap:指向栈对象的指针不能存储在堆中
  • pointers to a stack object cannot outlive that object:指向栈对象的指针不能超过该对象的存活期,也就说指针不能在栈对象被销毁后依旧存活。(例子:声明的函数返回并销毁了对象的栈帧,或者它在循环迭代中被重复用于逻辑上不同的变量)

我们大概知道它的分析准则是什么就好了,具体逃逸分析是怎么做的,感兴趣的同学可以根据源码自行研究。

既然逃逸分析是在编译阶段进行的,那我们就可以通过go build -gcflags '-m -m -l'命令查看到逃逸分析的结果,我们之前在分析内联优化时使用的-gcflags '-m -m',能看到所有的编译器优化,这里使用-l禁用掉内联优化,只关注逃逸优化就好了。

现在我们也知道了逃逸分析,接下来我们就看几个逃逸分析的例子。

几个逃逸分析的例子

1. 函数返回局部指针变量

先看例子:

func Add(x,y int) *int {
    res := 0
    res = x + y
    return &res
}

func main()  {
    Add(1,2)
}

查看逃逸分析结果:

go build -gcflags="-m -m -l" ./test1.go
# command-line-arguments
./test1.go:6:9: &res escapes to heap
./test1.go:6:9:         from ~r2 (return) at ./test1.go:6:2
./test1.go:4:2: moved to heap: res

分析结果很明了,函数返回的局部变量是一个指针变量,当函数Add执行结束后,对应的栈桢就会被销毁,但是引用已经返回到函数之外,如果我们在外部解引用地址,就会导致程序访问非法内存,就像上面的C语言的例子一样,所以编译器经过逃逸分析后将其在堆上分配内存。

2. interface类型逃逸

先看一个例子:

func main()  {
    str := "asong太帅了吧"
    fmt.Printf("%v",str)
}

查看逃逸分析结果:

go build -gcflags="-m -m -l" ./test2.go 
# command-line-arguments
./test2.go:9:13: str escapes to heap
./test2.go:9:13:        from ... argument (arg to ...) at ./test2.go:9:13
./test2.go:9:13:        from *(... argument) (indirection) at ./test2.go:9:13
./test2.go:9:13:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:13
./test2.go:9:13: main ... argument does not escape

strmain函数中的一个局部变量,传递给fmt.Println()函数后发生了逃逸,这是因为fmt.Println()函数的入参是一个interface{}类型,如果函数参数为interface{},那么在编译期间就很难确定其参数的具体类型,也会发送逃逸。

观察这个分析结果,我们可以看到没有moved to heap: str,这也就是说明str变量并没有在堆上进行分配,只是它存储的值逃逸到堆上了,也就说任何被str引用的对象必须分配在堆上。如果我们把代码改成这样:

func main()  {
    str := "asong太帅了吧"
    fmt.Printf("%p",&str)
}

查看逃逸分析结果:

go build -gcflags="-m -m -l" ./test2.go
# command-line-arguments
./test2.go:9:18: &str escapes to heap
./test2.go:9:18:        from ... argument (arg to ...) at ./test2.go:9:12
./test2.go:9:18:        from *(... argument) (indirection) at ./test2.go:9:12
./test2.go:9:18:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
./test2.go:9:18: &str escapes to heap
./test2.go:9:18:        from &str (interface-converted) at ./test2.go:9:18
./test2.go:9:18:        from ... argument (arg to ...) at ./test2.go:9:12
./test2.go:9:18:        from *(... argument) (indirection) at ./test2.go:9:12
./test2.go:9:18:        from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
./test2.go:8:2: moved to heap: str
./test2.go:9:12: main ... argument does not escape

这回str也逃逸到了堆上,在堆上进行内存分配,这是因为我们访问str的地址,因为入参是interface类型,所以变量str的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,装箱的形参变量的值要在堆上分配,但是还要存储一个栈上的地址,也就是str的地址,堆上的对象不能存储一个栈上的地址,所以str也逃逸到堆上,在堆上分配内存。(这里注意一个知识点:Go语言的参数传递只有值传递

3. 闭包产生的逃逸

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

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

查看逃逸分析结果:

go build -gcflags="-m -m -l" ./test3.go
# command-line-arguments
./test3.go:10:3: Increase.func1 capturing by ref: n (addr=true assign=true width=8)
./test3.go:9:9: func literal escapes to heap
./test3.go:9:9:         from ~r0 (assigned) at ./test3.go:7:17
./test3.go:9:9: func literal escapes to heap
./test3.go:9:9:         from &(func literal) (address-of) at ./test3.go:9:9
./test3.go:9:9:         from ~r0 (assigned) at ./test3.go:7:17
./test3.go:10:3: &n escapes to heap
./test3.go:10:3:        from func literal (captured by a closure) at ./test3.go:9:9
./test3.go:10:3:        from &(func literal) (address-of) at ./test3.go:9:9
./test3.go:10:3:        from ~r0 (assigned) at ./test3.go:7:17
./test3.go:8:2: moved to heap: n
./test3.go:17:16: in() escapes to heap
./test3.go:17:16:       from ... argument (arg to ...) at ./test3.go:17:13
./test3.go:17:16:       from *(... argument) (indirection) at ./test3.go:17:13
./test3.go:17:16:       from ... argument (passed to call[argument content escapes]) at ./test3.go:17:13
./test3.go:17:13: main ... argument does not escape

因为函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量n会一直存在直到in被销毁,所以n变量逃逸到了堆上。

4. 变量大小不确定及栈空间不足引发逃逸

我们先使用ulimit -a查看操作系统的栈空间:

ulimit -a
-t: cpu time (seconds)              unlimited
-f: file size (blocks)              unlimited
-d: data seg size (kbytes)          unlimited
-s: stack size (kbytes)             8192
-c: core file size (blocks)         0
-v: address space (kbytes)          unlimited
-l: locked-in-memory size (kbytes)  unlimited
-u: processes                       2784
-n: file descriptors                256

我的电脑的栈空间大小是8192,所以根据这个我们写一个测试用例:

package main

import (
    "math/rand"
)

func LessThan8192()  {
    nums := make([]int, 100) // = 64KB
    for i := 0; i < len(nums); i++ {
        nums[i] = rand.Int()
    }
}


func MoreThan8192(){
    nums := make([]int, 1000000) // = 64KB
    for i := 0; i < len(nums); i++ {
        nums[i] = rand.Int()
    }
}


func NonConstant() {
    number := 10
    s := make([]int, number)
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}

func main() {
    NonConstant()
    MoreThan8192()
    LessThan8192()
}

查看逃逸分析结果:

go build -gcflags="-m -m -l" ./test4.go
# command-line-arguments
./test4.go:8:14: LessThan8192 make([]int, 100) does not escape
./test4.go:16:14: make([]int, 1000000) escapes to heap
./test4.go:16:14:       from make([]int, 1000000) (non-constant size) at ./test4.go:16:14
./test4.go:25:11: make([]int, number) escapes to heap
./test4.go:25:11:       from make([]int, number) (non-constant size) at ./test4.go:25:11

我们可以看到,当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。

同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

参考文章(建议大家阅读一遍)

本文到这里结束了,这篇文章我们一起分析了什么是内存逃逸以及Go语言中的逃逸分析,上面只列举了几个例子,因为发生的逃逸的情况是列举不全的,我们只需要了解什么是逃逸分析,了解逃逸的策略就可以了,后面在实战中可以根据具体代码具体分析,写出更优质的代码。

最后对逃逸做一个总结:

  • 逃逸分析在编译阶段确定哪些变量可以分配在栈中,哪些变量分配在堆上
  • 逃逸分析减轻了GC压力,提高程序的运行速度
  • 栈上内存使用完毕不需要GC处理,堆上内存使用完毕会交给GC处理
  • 函数传参时对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能
  • 根据代码具体分析,尽量减少逃逸代码,减轻GC压力,提高性能

欢迎关注公众号:【Golang梦工厂】

推荐往期文章:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK