什么是逃逸分析
逃逸分析是指编译器在执行静态代码分析后,对内存管理进行的优化和简化。
在编译原理中,分析指针动态范围的方法被称为逃逸分析。通俗来讲,当一个对象的指针被多个方法或线程引用时,则称这个指针发生了逃逸。逃逸分析决定一个变量是分配在堆上还是分配在栈上。
逃逸分析有什么作用
逃逸分析把变量合理地分配到它该去的地方,“找准自己的位置”。即使是使用 new 函数申请到的内存,如果编译器发现这块内存在退出函数后就没有使用了,那就分配到栈上,毕竟栈上的内存分配比堆上快很多;反之,既是表面上只是一个普通的变量,但是经过编译器的逃逸分析后发现,在函数之外还有其他的地方在引用,那就分配到堆上。真正做到 “按需分配”。
如果变量都分配到堆上,堆不像栈可以自动清理。就会引起 Go 频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销。
堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片;栈内存分配则非常快。栈分配内存只需通过 PUSH 指令,并且会被自动释放;而堆分配首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。
通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量变少了,会减轻堆内存分配的开销,同时也会减少垃圾回收(Garbage Collction,GC)的压力,提高程序运行速度。
逃逸分析是怎么完成的
Go 语言逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么这个变量就会发生逃逸。
编译器会分析代码的特征和代码的生命周期,Go 中的变量只有在编译器可以证明在函数返回后不再被引用,才分配到栈上,其他情况下都是直接分配到堆上。
Go 语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上。相反,编译器通过分析代码来决定将变量分配到何处。
对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考虑到在函数返回后,此变量不会被引用,那么还是可能分配到栈上。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:
- 如果变量在函数外部没有被引用,则优先放到栈上。
- 如果变量在函数外部存在引用,则必定放在堆上。
- 针对第一条,放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。
变量分配位置
- 值类型,
通常
在栈区分配 - 引用类型,
通常
在堆区分配
不通常会分配在哪?
Go语言逃逸分析最基本的原则是:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。
编译器会根据变量是否被外部引用来决定是否逃逸:
- 如果在函数外面没有引用到,则优先放到栈区中;
- 如果在函数外面存在引用的可能,则就会放到堆区中;
Go与C++内存分配的区别
Go的垃圾回收,让堆和栈对程序员保持透明。把那些内存管理的复杂机制交给编译器,而程序员可以去享受生活。
在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:
- 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
- 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全;
与悬挂指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i 被错误返回:
int *dangling_pointer() {
int i = 2;
return &i;
}
当 dangling_pointer 函数返回后,它的本地变量会被编译器回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法时,这种问题在大型项目中是比较难以发现和定位的。
在编译器优化中,逃逸分析是用来决定指针动态作用域的方法5。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new、make 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
堆和栈区别
堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别:
(1)管理方式不同。栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;
(2)空间大小不同。每个进程拥有的栈大小要远远小于堆大小。理论上,进程可申请的堆大小为虚拟内存大小,进程栈的大小 64bits 的 Windows 默认 1MB,64bits 的 Linux 默认 10MB;
(3)生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。
(4)分配方式不同。堆都是动态分配的,没有静态分配的堆。栈有 2 种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca()函数分配,但是栈的动态分配和堆是不同的,它的动态分配是由操作系统进行释放,无需我们手工实现。
(5)分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
(6)存放内容不同。栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者BSS段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。
从以上可以看到,堆和栈相比,由于大量malloc()/free()或new/delete的使用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。栈相比于堆,在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。
无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。
如何确定是否发生逃逸分析
Go 提供了相关的命令,可以查看变量是否发生了逃逸。例子如下:
package main
import "fmt"
func foo() *int {
t := 3
return &t
}
func main() {
x := foo()
fmt.Println(*x)
}
foo 函数返回一个局部变量的指针,使用 main 函数里变量 x 接收它。执行如下命令:
go build -gcflags '-m-l' main.go
其中 -gcflags 参数用于启用编译器支持的额外标志。例如, -m 用于输出编译器的优化细节(包括使用逃逸分析这种优化),相反可以使用 -N 来关闭编译器优化;而 -l 则用于禁用 foo 函数的内联优化,防止逃逸被编译器通过内联优化彻底的抹除。得到如下输出:
### command-line-arguments
src/main.go:7:9: &t escapes to heap
src/main.go:6:7: moved to heap: t
src/main.go:12:14: *x escapes to heap
src/main.go:12:13: main ... argument does not escape
foo 函数里的变量 t 逃逸了,和预想的一致,不解的是为什么 main 函数里的 x 也逃逸了?这是以为有些函数的参数为 interface 类型,比如 fmt.Println(a …interface{}) ,编译期间很难确定其参数的具体类型,也会发生逃逸。