博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Golang 内存分配与逃逸分析
阅读量:3952 次
发布时间:2019-05-24

本文共 5865 字,大约阅读时间需要 19 分钟。

参考:

来源于 公众号: 脑子进煎鱼了 ,作者陈煎鱼。


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

但从结果上来讲你还是一知半解,这可不行,万一被人懵了呢。今天我们一起来深挖下 Go 在这块的奥妙,自己动手丰衣足食!

问题

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{…}。这个变量是分配到栈上了呢,还是分配到堆上了?

什么是堆/栈

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

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

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

什么是逃逸分析

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。

通俗地讲,逃逸分析就是确定一个变量要放堆上还是栈上,规则如下:

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

对此你可以理解为,逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。

在什么阶段确立逃逸

编译阶段确立逃逸,注意并不是在运行时。

为什么需要逃逸

这个问题我们可以反过来想,如果变量都分配到堆上了会出现什么事情?例如:

  • 垃圾回收(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 类型会导致该对象分配到堆上

案例三、泄露参数

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 是一个泄露参数。

在这个例子里面,&User是一个临时匿名对象,
结合代码可得知传给 GetUserInfo 方法后,没有做任何引用之类的涉及变量的动作,直接就把这个变量 &User返回出去了。并且返回的u 指向的内存就main函数里面,所以没有逃逸。在main函数返回后没有任何对x的引用存在,所以x这个变量可以在main函数的栈空间进行内存分配。
它的作用域还在 main() 之中,所以分配在栈上。对应下面总结的case3.

注意:逃逸分析目的是判断当前方法里的变量会不会被其它方法使用(读写),这个例子里面,直接就返回了,显然没有真正使用。

再想想

那你再想想怎么样才能让它分配到堆上?结合案例一,举一反三。修改如下:

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

注意,这里和上面例子3不一样,这里逃逸的是 u ! main函数里的匿名结构体User一定不会逃逸,因为调用GetUserInfo的时候是传的结构体值,深拷贝了!

u之所以会逃逸,是因为取地址返回了!! 这是一定会发生逃逸的,不管返回的值有没有用。
原因是函数GetUserInfo返回一个对u的引用,所以u不能在栈中分配,否则当函数返回时,引用会指向何处呢?
于是它逃逸到了堆中。其实执行完GetUserInfo返回到main函数中后,main函数丢弃了这个引用而不是解除引用,但是Go的逃逸分析还不够机智去识别这种情况。

总结

我们得出了指针必然发生逃逸的三种情况(go version go1.13.4 darwin/amd64):

在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);case1:

  1. 被已经逃逸的变量引用的指针,一定发生逃逸;
  2. 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

同时我们也得出一些必然不会逃逸的情况:case2

  1. 指针被未发生逃逸的变量引用;
  2. 仅仅在函数内对变量做取址操作,而未将指针传出;

有一些情况可能发生逃逸,也可能不会发生逃逸:case3

将指针作为入参传给别的函数
这里还是要看指针在被传入的函数中的处理过程,如果发生了上边一定逃逸的条件,则会逃逸;否则不会逃逸;

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

  • 静态分配到栈上,性能一定比动态分配到堆上好。
  • 底层分配到堆,还是栈。实际上对你来说是透明的,不需要过度关心。
  • 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)。
  • 直接通过 go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果。
  • 不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
  • 不同于jvm的运行时逃逸分析,golang的逃逸分析是在编译期完成的。
  • Golang的逃逸分析只针对指针。一个值引用变量如果没有被取址,那么它永远不可能逃逸。

这块的知识点。我的建议是适当了解,但没必要硬记,因为 Go 语言每次升级都有可能会改。靠基础知识点加命令调试观察就好了。

转载地址:http://dikzi.baihongyu.com/

你可能感兴趣的文章
git命令入门
查看>>
PreferenceActivity 全接触
查看>>
Android之PreferenceActivity
查看>>
在Ubuntu上搭建嵌入式Linux开发环境
查看>>
C语言数据类型:联合(union)
查看>>
记录每次更新到仓库
查看>>
PXA270嵌入式系统设计一:电源管理部分收藏
查看>>
PXA270嵌入式系统设计(2)—时钟及复位部分
查看>>
linux 多点触控协议
查看>>
中断服务下半部之工作队列详解
查看>>
怎样做一块好的pcb板
查看>>
内核中的 likely() 与 unlikely()
查看>>
platform设备添加流程
查看>>
理解“统一编址与独立编址、I/O端口与I/O内存”
查看>>
Linux驱动的platform机制
查看>>
Linux内核中的platform机制
查看>>
寄存器编址
查看>>
在Ubuntu上搭建ssh和samba服务器
查看>>
Linux设备模型 学习总结682057749
查看>>
Udev 内核机制(kobject_uevent) 性能优化
查看>>