本文转载自微信公众号《Golang梦工厂》,作者AsongGo。转载本文请联系Golang梦工厂公众号。前言大家好,我是asong。最近无聊看Go语言的面试套路,发现面试官都喜欢问内存逃逸的话题。在此总结一下,以供日后参考和学习。什么是内存逃逸?第一次看到这个题目的时候,一头雾水。为什么还有内存逃逸?什么是内存逃逸?接下来我们就来看看什么是内存逃逸。我们都知道程序一般都保存在rom或者Flash中,运行时需要拷贝到内存中执行。内存会分别存储不同的信息。内存空间包含两个最重要的区域:堆区(Stack)和栈区(Heap),对于我这个C语言背景的人来说,我对堆内存和栈内存的理解比较深刻。在C语言中,栈区会存放函数参数、局部变量等,栈的地址是从内存高地址向低地址递增,而堆内存正好相反。堆地址是从内存低地址向高地址递增的,但是如果我们要在堆区分配内存,就需要手动调用malloc函数在堆区申请内存分配,然后就需要使用后手动释放它。如果不释放,会造成内存泄漏。写过C语言的朋友应该知道,C语言函数不能返回局部变量地址(尤其是存放在栈区的局部变量地址),除非是局部静态变量地址、字符串常量地址、动态分配地址。原因是一般的局部变量的作用域只在函数内部,它的存储位置在栈区。当程序调用完函数后,局部变量会随函数一起释放。其地址指向的内容是未知的(原始值可能保持不变,也可能发生变化)。局部静态变量地址和字符串常量地址存放在数据区,动态分配的地址存放在堆区。函数运行后,只会释放栈区的内容,数据区和堆区不会发生变化。所以在C语言中,当我们要在函数中返回局部变量的地址时,有3种正确的方式:返回静态局部变量的地址,返回字符串常量的地址,返回动态分配的地址堆,因为不在栈区,即使函数被释放,其内容也不会受到影响。下面以返回堆上的内存地址为例来看一段代码:#include"stdio.h"#include"stdlib.h"//returnadynamicallyallocatedaddressint*f1(){inta=9;int*pa=(int*)malloc(8);*pa=a;returnpa;}intmain(){int*pb;pb=f1();printf("after:*pb=%d\tpb=%p\n",*pb,pb);free(pb);return1;}通过上面的例子我们知道,C语言中动态内存的分配和释放完全掌握在程序员手中。这会让我们在写程序的时候如履薄冰。好处是我们可以完全控制内存。缺点是我们一不小心就会造成内存泄漏。因此,很多现代语言都有GC机制。Go是一种具有垃圾收集功能的语言。真正解放了我们程序员的双手。我们不需要像写C语言那样去考虑能不能返回局部变量的地址。内存管理交给编译器。编译器会通过逃逸分析合理分配变量来“纠正”。这个地方。说了这么多,我们可以简单总结一下什么是内存逃逸:在程序中,每个函数都会有自己的内存区域,用来存放自己的局部变量、返回地址等,而这些内存会由编译器在栈上分配.每个函数都会分配一个栈帧,并在函数运行后销毁,但是我们要在函数运行后使用一些变量,所以需要把这个变量分配在堆上,这样就出现了逃出“栈”去的现象“堆”变成了内存转义。什么是逃逸分析?以上我们知道什么是内存逃逸。我们先来看看什么是逃逸分析。上面我们提到,C语言使用malloc在堆上动态分配内存后,需要手动调用free释放内存。如果不释放,会造成内存泄漏的风险。Go语言中堆内存的分配和释放完全不需要我们去管理。Go语言引入了GC机制,自动管理位于堆上的对象。当一个对象不可达时(即没有对象引用它时),它就会被回收再利用。虽然GC的引入可以让开发者减轻内存管理的精神负担,但是GC也会给程序带来性能损失。当堆内存中有大量的堆内存对象需要扫描时,会给GC带来过大的压力,虽然Go语言使用了mark-clear算法,并在此基础上提出了三色标记法和writebarrier技术是用来提高效率的,但是如果我们的程序仍然在堆上分配大量的内存,这种依赖会造成GC不可忽视的压力。因此,为了减少GC带来的压力,Go语言引入了逃逸分析,即思想是尽量减少堆上的内存分配,把能分配到栈上的变量留在栈上越多越好。总结逃逸分析:逃逸分析是指在编译阶段,根据代码中的数据流,静态分析代码中哪些变量需要分配在栈上,哪些变量需要分配在堆上的方法。与栈相比,堆适合于不可预测大小的内存分配。但是你为此付出的代价是更慢的分配和内存碎片。栈内存分配会很快。栈分配内存只需要两条CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配的内存首先需要找到合适大小的内存块,然后通过垃圾回收释放。因此,逃逸分析可以实现更好的内存分配,提高程序的运行速度。Go语言的逃逸分析Go语言的逃逸分析一共实现了两个版本:1.13版本之前是第一个版本1.13版本之后是第二版本粗略看下逃逸分析的代码,大概有1500+行(go1.15.7)。我没有仔细看代码,但是我仔细看了注释。评论还是很详细的。代码路径:src/cmd/compile/internal/gc/escape.go,注释可自行阅读,其逃逸分析原理如下:栈对象的指针不能存入堆:指向a的指针stackobjectcannotbestoredintheheappointerstoastackobjectcannotliveoutlivethatobject:pointerstoastackobjectcannotoutlivethatobject,也就是说指针不能在被销毁的堆栈对象中存活。(例子:声明的函数返回并销毁对象的栈帧,或者在循环迭代中为逻辑上不同的变量复用)我们大概知道它的分析标准是什么,以及如何进行逃逸分析了,有兴趣的同学可以自己动手自己研究基于源代码。由于逃逸分析是在编译阶段进行的,所以我们可以通过gobuild-gcflags'-m-m-l'命令查看逃逸分析的结果,我们在分析内联优化时使用的-gcflags'-m-m',可以看到所有的编译器优化,这里使用-l禁用内联优化,只关注逃逸优化。既然我们也知道了逃逸分析,那么我们来看几个逃逸分析的例子。逃逸分析的几个例子1.函数返回一个局部指针变量先看例子:#include"stdio.h"#include"stdlib.h"//返回动态分配的地址int*f1(){inta=9;int*pa=(int*)malloc(8);*pa=a;returnpa;}intmain(){int*pb;pb=f1();printf("after:*pb=%d\tpb=%p\n",*pb,pb);free(pb);return1;}查看逃逸分析结果:gobuild-gcflags="-m-m-l"./test1.go#command-line-arguments./test1.go:6:9:&resescapestoheap./test1.go:6:9:from~r2(return)at./test1.go:6:2./test1.go:4:2:movedtoheap:res分析结果很清楚,函数返回的局部变量是指针变量。当函数Add执行时,对应的栈帧会被销毁,但是函数外已经返回引用了。如果我们从外部对该地址进行解引用,就会导致程序访问到非法内存,就像上面C语言的例子一样,所以编译器在逃逸分析后,会在堆上分配内存。2、看一个接口类型逃逸的例子:funcmain(){str:="asongissocool"fmt.Printf("%v",str)}查看逃逸分析结果:gobuild-gcflags="-m-m-l"./test2.go#command-line-arguments./test2.go:9:13:strescapestoheap./test2.go:9:13:from...argument(argto...)at./test2.go:9:13./test2.go:9:13:from*(...argument)(间接)at./test2.go:9:13./test2.go:9:13:from...argument(passedtocall[argumentcontentescapes])at./test2.go:9:13./test2.go:9:13:main...argumentdoesnotescapestr是main函数中的局部变量,传给fmt.Println()后转义function,这是因为fmt.Println()函数的入参是interface{}类型。如果函数参数是interface{},编译时很难确定其参数的具体类型,也会发送escape。观察这个分析的结果,我们可以看到并没有movedtoheap:str,也就是说str变量并没有分配到堆上,而是它存储的值逃逸到了堆上,也就是任何引用的对象str必须分配在堆上。如果我们把代码改成这样:funcmain(){str:="asongissohandsome"fmt.Printf("%p",&str)}查看逃逸分析结果:gobuild-gcflags="-m-m-l"./test2.go#command-line-arguments./test2.go:9:18:&strescapestoheap./test2.go:9:18:from...argument(argto...)at./test2.go:9:12./test2.go:9:18:from*(...argument)(间接)在./test2.go:9:12./test2.go:9:18:from...argument(passedtocall[argumentcontentescapes])在./test2.go:9:12./test2.go:9:18:&strescapestoheap./test2.go:9:18:from&str(interface-converted)at./test2.go:9:18。/test2.go:9:18:from...argument(argto...)at./test2.go:9:12./test2.go:9:18:from*(...argument)(间接)at./test2.go:9:12./test2.go:9:18:from...argument(passedtocall[argumentcontentescapes])at./test2.go:9:12./test2.go:8:2:movedtoheap:str./test2.go:9:12:main...argumentdoesnotescape这次str也逃逸到堆上,在堆上分配内存。这是因为我们访问的是str的地址,因为入参是接口类型,所以将变量str的地址作为实参传递给fmt.Printf,然后装箱到一个interface{}参数变量中。装箱参数变量的值必须分配在堆上,但是一个栈上的地址,也就是str的地址,堆上的对象不能存放栈上的地址,所以str同样逃逸到堆上,在堆上分配内存(这里注意一个知识点:Go语言的参数传递只传值)3.转义funcIncrease()func()int{n:=0returnfunc()int{n++returnn}}funcmain(){in由闭包生成:=Increase()fmt.Println(in())//1}查看逃逸分析结果:gobuild-gcflags="-m-m-l"./test3.go#command-line-arguments./test3.go:10:3:Increase.func1capturingbyref:n(addr=trueassign=truewidth=8)./test3.go:9:9:funcliteralescapestoheap./test3.go:9:9:from~r0(assigned)at./test3.go:7:17./test3.go:9:9:funcliteralescapestoheap./test3.go:9:9:from&(funcliteral)(address-of)at./test3.go:9:9./test3.go:9:9:from~r0(assigned)at./test3.go:7:17./test3.go:10:3:&nescapestoheap./test3.go:10:3:fromfuncliteral(capturedbyaclosure)at./test3.go:9:9./test3.go:10:3:from&(funcliteral)(address-of)at./test3.go:9:9./test3.go:10:3:from~r0(分配)在。/test3.go:7:17./test3.go:8:2:movedtoheap:n./test3.go:17:16:in()escapestoheap./test3.go:17:16:from...参数(argto...)at./test3.go:17:13./test3.go:17:16:from*(...argument)(indirection)at./test3.go:17:13./test3.go:17:16:从...argument(passedtocall[argumentcontentescapes])at./test3.go:17:13./test3.go:17:13:main...argumentdoesnotescape因为函数也是指针类型,匿名函数作为返回值转义,在匿名函数中使用外部变量n,这个变量n一直存在直到in被销毁,所以n变量转义到堆中4.变量大小不确定,栈空间不足导致逃逸。我们先用ulimit-a查看操作系统的栈空间:ulimit-a-t:cputime(seconds)unlimited-f:filesize(blocks)unlimited-d:datasegsize(kbytes)unlimited-s:stacksize(kbytes)8192-c:corefilesize(blocks)0-v:addressspace(kbytes)unlimited-l:locked-in-memorysize(kbytes)unlimited-u:processes2784-n:filedescriptors256我电脑的栈空间大小是8192,所以我们写个测试基于此的案例:packagemainimport("math/rand")funcLessThan8192(){nums:=make([]int,100)//=64KBfori:=0;i
