乐趣区

关于golang:详解Go语言中的内存逃逸

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

前言

哈喽,大家好,我是 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

咱们能够看到,当栈空间足够时,不会产生逃逸,然而当变量过大时,曾经齐全超过栈空间的大小时,将会产生逃逸到堆上分配内存。

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

参考文章(倡议大家浏览一遍)

  • https://driverzhang.github.io…
  • https://segmentfault.com/a/11…
  • https://tonybai.com/2021/05/2…
  • https://cloud.tencent.com/dev…
  • https://geektutu.com/post/hpg…

总结

本文到这里完结了,这篇文章咱们一起剖析了什么是内存逃逸以及 Go 语言中的逃逸剖析,下面只列举了几个例子,因为产生的逃逸的状况是列举不全的,咱们只须要理解什么是逃逸剖析,理解逃逸的策略就能够了,前面在实战中能够依据具体代码具体分析,写出更优质的代码。

最初对逃逸做一个总结:

  • 逃逸剖析在编译阶段确定哪些变量能够调配在栈中,哪些变量调配在堆上
  • 逃逸剖析加重了 GC 压力,进步程序的运行速度
  • 栈上内存应用结束不须要 GC 解决,堆上内存应用结束会交给 GC 解决
  • 函数传参时对于须要批改原对象值,或占用内存比拟大的构造体,抉择传指针。对于只读的占用内存较小的构造体,间接传值可能取得更好的性能
  • 依据代码具体分析,尽量减少逃逸代码,加重 GC 压力,进步性能

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

举荐往期文章:

  • 学习 channel 设计:从入门到放弃
  • 编程模式之 Go 如何实现装璜器
  • Go 语言中 new 和 make 你应用哪个来分配内存?
  • 源码分析 panic 与 recover,看不懂你打我好了!
  • 空构造体引发的大型打脸现场
  • [面试官:你能聊聊 string 和[]byte 的转换吗?](https://mp.weixin.qq.com/s/jz…
  • 面试官:两个 nil 比拟后果是什么?
  • 面试官:你能用 Go 写段代码判断以后零碎的存储形式吗?
退出移动版