我要在栈上。不,你应该在堆上

image

原文地址:我要在栈上。不,你应该在堆上html

前言

咱们在写代码的时候,有时候会想这个变量到底分配到哪里了?这时候可能会有人说,在栈上,在堆上。信我准没错...git

但从结果上来说你仍是只知其一;不知其二,这可不行,万一被人懵了呢。今天咱们一块儿来深挖下 Go 在这块的奥妙,本身动手丰衣足食github

问题

type User struct {
    ID     int64
    Name   string
    Avatar string
}

func GetUserInfo() *User {
    return &User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"}
}

func main() {
    _ = GetUserInfo()
}

开局就是一把问号,带着问题进行学习。请问 main 调用 GetUserInfo 后返回的 &User{...}。这个变量是分配到栈上了呢,仍是分配到堆上了?golang

什么是堆/栈

在这里并不打算详细介绍堆栈,仅简单介绍本文所需的基础知识。以下:函数

  • 堆(Heap):通常来说是人为手动进行管理,手动申请、分配、释放。通常所涉及的内存大小并不定,通常会存放较大的对象。另外其分配相对慢,涉及到的指令动做也相对多
  • 栈(Stack):由编译器进行管理,自动申请、分配、释放。通常不会太大,咱们常见的函数参数(不一样平台容许存放的数量不一样),局部变量等等都会存放在栈上

今天咱们介绍的 Go 语言,它的堆栈分配是经过 Compiler 进行分析,GC 去管理的,而对其的分析选择动做就是今天探讨的重点性能

什么是逃逸分析

在编译程序优化理论中,逃逸分析是一种肯定指针动态范围的方法,简单来讲就是分析在程序的哪些地方能够访问到该指针学习

通俗地讲,逃逸分析就是肯定一个变量要放堆上仍是栈上,规则以下:优化

  1. 是否有在其余地方(非局部)被引用。只要有可能被引用了,那么它必定分配到堆上。不然分配到栈上
  2. 即便没有被外部引用,但对象过大,没法存放在栈区上。依然有可能分配到堆上

对此你能够理解为,逃逸分析是编译器用于决定变量分配到堆上仍是栈上的一种行为ui

在什么阶段确立逃逸

在编译阶段确立逃逸,注意并非在运行时spa

为何须要逃逸

这个问题咱们能够反过来想,若是变量都分配到堆上了会出现什么事情?例如:

  • 垃圾回收(GC)的压力不断增大
  • 申请、分配、回收内存的系统开销增大(相对于栈)
  • 动态分配产生必定量的内存碎片

其实总的来讲,就是频繁申请、分配堆内存是有必定 “代价” 的。会影响应用程序运行的效率,间接影响到总体系统。所以 “按需分配” 最大限度的灵活利用资源,才是正确的治理之道。这就是为何须要逃逸分析的缘由,你以为呢?

怎么肯定是否逃逸

第一,经过编译器命令,就能够看到详细的逃逸分析过程。而指令集 -gcflags 用于将标识参数传递给 Go 编译器,涉及以下:

  • -m 会打印出逃逸分析的优化策略,实际上最多总共能够用 4 个 -m,可是信息量较大,通常用 1 个就能够了
  • -l 会禁用函数内联,在这里禁用掉 inline 能更好的观察逃逸状况,减小干扰
$ go build -gcflags '-m -l' main.go

第二,经过反编译命令查看

$ go tool compile -S main.go

注:能够经过 go tool compile -help 查看全部容许传递给编译器的标识参数

逃逸案例

案例一:指针

第一个案例是一开始抛出的问题,如今你再看看,想一想,以下:

type User struct {
    ID     int64
    Name   string
    Avatar string
}

func GetUserInfo() *User {
    return &User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"}
}

func main() {
    _ = GetUserInfo()
}

执行命令观察一下,以下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:54: &User literal escapes to heap

经过查看分析结果,可得知 &User 逃到了堆里,也就是分配到堆上了。这是否是有问题啊...再看看汇编代码肯定一下,以下:

$ go tool compile -S main.go                
"".GetUserInfo STEXT size=190 args=0x8 locals=0x18
    0x0000 00000 (main.go:9)    TEXT    "".GetUserInfo(SB), $24-8
    ...
    0x0028 00040 (main.go:10)    MOVQ    AX, (SP)
    0x002c 00044 (main.go:10)    CALL    runtime.newobject(SB)
    0x0031 00049 (main.go:10)    PCDATA    $2, $1
    0x0031 00049 (main.go:10)    MOVQ    8(SP), AX
    0x0036 00054 (main.go:10)    MOVQ    $13746731, (AX)
    0x003d 00061 (main.go:10)    MOVQ    $7, 16(AX)
    0x0045 00069 (main.go:10)    PCDATA    $2, $-2
    0x0045 00069 (main.go:10)    PCDATA    $0, $-2
    0x0045 00069 (main.go:10)    CMPL    runtime.writeBarrier(SB), $0
    0x004c 00076 (main.go:10)    JNE    156
    0x004e 00078 (main.go:10)    LEAQ    go.string."EDDYCJY"(SB), CX
    ...

咱们将目光集中到 CALL 指令,发现其执行了 runtime.newobject 方法,也就是确实是分配到了堆上。这是为何呢?

分析结果

这是由于 GetUserInfo() 返回的是指针对象,引用被返回到了方法以外了。所以编译器会把该对象分配到堆上,而不是栈上。不然方法结束以后,局部变量就被回收了,岂不是翻车。因此最终分配到堆上是理所固然的

再想一想

那你可能会想,那就是全部指针对象,都应该在堆上?并不。以下:

func main() {
    str := new(string)
    *str = "EDDYCJY"
}

你想一想这个对象会分配到哪里?以下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:12: main new(string) does not escape

显然,该对象分配到栈上了。很核心的一点就是它有没有被做用域以外所引用,而这里做用域仍然保留在 main 中,所以它没有发生逃逸

案例二:未肯定类型

func main() {
    str := new(string)
    *str = "EDDYCJY"

    fmt.Println(str)
}

执行命令观察一下,以下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:13: str escapes to heap
./main.go:6:12: new(string) escapes to heap
./main.go:9:13: main ... argument does not escape

经过查看分析结果,可得知 str 变量逃到了堆上,也就是该对象在堆上分配。但上个案例时它还在栈上,咱们也就 fmt 输出了它而已。这...到底发生了什么事?

分析结果

相对案例一,案例二只加了一行代码 fmt.Println(str),问题确定出在它身上。其原型:

func Println(a ...interface{}) (n int, err error)

经过对其分析,可得知当形参为 interface 类型时,在编译阶段编译器没法肯定其具体的类型。所以会产生逃逸,最终分配到堆上

若是你有兴趣追源码的话,能够看下内部的 reflect.TypeOf(arg).Kind() 语句,其会形成堆逃逸,而表象就是 interface 类型会致使该对象分配到堆上

案例3、泄露参数

type User struct {
    ID     int64
    Name   string
    Avatar string
}

func GetUserInfo(u *User) *User {
    return u
}

func main() {
    _ = GetUserInfo(&User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}

执行命令观察一下,以下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:18: leaking param: u to result ~r1 level=0
./main.go:14:63: main &User literal does not escape

咱们注意到 leaking param 的表述,它说明了变量 u 是一个泄露参数。结合代码可得知其传给 GetUserInfo 方法后,没有作任何引用之类的涉及变量的动做,直接就把这个变量返回出去了。所以这个变量实际上并无逃逸,它的做用域还在 main() 之中,因此分配在栈上

再想一想

那你再想一想怎么样才能让它分配到堆上?结合案例一,触类旁通。修改以下:

type User struct {
    ID     int64
    Name   string
    Avatar string
}

func GetUserInfo(u User) *User {
    return &u
}

func main() {
    _ = GetUserInfo(User{ID: 13746731, Name: "EDDYCJY", Avatar: "https://avatars0.githubusercontent.com/u/13746731"})
}

执行命令观察一下,以下:

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:9: &u escapes to heap
./main.go:9:18: moved to heap: u

只要一小改,它就考虑会被外部所引用,所以妥妥的分配到堆上了

总结

在本文我给你介绍了逃逸分析的概念和规则,并列举了一些例子加深理解。但实际确定远远不止这些案例,你须要作到的是掌握方法,遇到再看就行了。除此以外你还须要注意:

  • 静态分配到栈上,性能必定比动态分配到堆上好
  • 底层分配到堆,仍是栈。实际上对你来讲是透明的,不须要过分关心
  • 每一个 Go 版本的逃逸分析都会有所不一样(会改变,会优化)
  • 直接经过 go build -gcflags '-m -l' 就能够看到逃逸分析的过程和结果
  • 处处都用指针传递并不必定是最好的,要用对

以前就有想过要不要写 “逃逸分析” 相关的文章,直到最近看到在夜读里有人问,仍是有写的必要。对于这块的知识点。个人建议是适当了解,但不必硬记。靠基础知识点加命令调试观察就行了。像是曹大以前讲的 “你琢磨半天逃逸分析,一压测,瓶颈在锁上”,彻底不必过分在乎...

参考