程序占用的内存可以分为栈区、堆区、静态区、文字常量区和程序代码区。占用的栈区由编译器自动分配释放,程序员不用关心管理问题。堆区的内容一般由需要程序员手动管理,手动申请和释放。例如C/C++语言,调用malloc在堆上可以分配一块内存,释放需要调用free或delete操作。如果申请后没有释放就会导致严重内存泄露问题,这在实际开发的产品中是不允许的。所以对堆上内存的申请和释放要非常小心。但是在Go语言中,我们并不需要非常关心一个对象到底是申请在栈上还是堆上,因为Go的编译器会确定对象的真正分配位置,如果一个变量或对象需要分配在堆上时,会自动将其分配在堆上而不是栈上,使用new创建的对象也不一定是分配在堆上。堆和栈的界限变得比较模糊,Go采用逃逸分析技术确定一个对象是分配在堆上还是栈上。
❝In compiler optimization, escape analysis is a method for determining the dynamic scope of pointers – where in the program a pointer can be accessed. It is related to pointer analysis and shape analysis.
❞
上面是来至维基百科的定义,大意是说在编译器优化中,内存逃逸用来动态确定指针的作用域范围。如果子程序分配了一个对象并返回一个指向它的指针,则可以根据返回的指针来访问对象,这时对象不能直接存放在栈上。如果指针存在在全局变量或其他数据结构中,而这些数据结构又会在当前过程中逃逸,则他们可以发送逃逸。逃逸分析可以确定指针对象存储的位置,以及是否可以证明指针的生命周期仅限于当前过程或线程。
在C/C++中,动态分配的内存需要手动进行释放,一不小心就会导致内存泄露,导致写程序的心智负担很重。Go中有垃圾回收机制帮助我们自动回收不用的内存,让我们可以专注于业务,高效地编写代码。逃逸分析的作用是合理分配变量在该去的地方,即“找准自己的位置”。就算用new申请内存,如果退出函数后发现不在没用,会把它分配到栈上。毕竟,堆栈上的内存分配比堆上的内存分配要快得多。相反,即使你只是表面上的一个普通变量,经过escape分析,发现退出函数之后还有其他引用,会将其分配到堆中。真正做到“按需分配”。如果将变量分配在堆上,堆不会像堆栈一样自动清理。会导致Go频繁做垃圾回收,垃圾回收会占用大量的系统开销。
堆与栈相比,堆适用于不可预测大小的内存分配。但是这样做的代价是分配速度慢,会形成内存碎片。栈内存分配非常快。栈只需要两条 CPU 指令:“push”和“release”来分配和释放内存。堆内存分配首先需要找到一个合适大小的内存块,在使用完后还需要通过垃圾回收来释放。
通过逃逸分析,可以将不需要分配到堆中的变量直接分配到栈中。如果堆上的变量少了,分配堆内存的开销就会减少,GC的压力也会降低,程序的运行速度也会提高。
Go逃逸分析的基本原理是如果函数返回的是对一个变量的引用,它就会进行逃逸。编译器会对代码中的变量类型、引用关系和生命周期进行分析。只有在编译器能够证明函数返回之后不会存在对变量再次引用的情况下才会被分配到栈中。在其他情况下,它们会被分配到堆中。Go中并没有提供将变量分配到堆或栈上的关键字和函数。也就是程序员可以不关心变量实际分配在什么地方。在Go程序编译期间,编译器会通过逃逸分析方法来确定变量的分配位置。可寻址的变量通常来说会分配在堆上,但是经过逃逸分析,观察到这个变量在函数返回后不会被引用,还是会分配到栈上。总结起来,编译器会根据变量是否被外部引用来决定是否逃逸。
func escapeDemo1() *int {
i := 10
return &i
}
上面的函数写在main.go文件中,然后执行go build -gcflags="-m -l" main.go, 得到输出如下
./main.go:49:2: moved to heap: i
moved to heap: i 提示说明对象i移动到堆上了。这里函数返回了局部变量i的地址,调用该函数的逻辑拿到返回的地址可以做其他处理。如果将i申请在栈上,当函数escapeDemo1调用完之后,i的内存可能被其他函数调用覆盖,所以需要将i分配到堆上,才能避免被覆盖的情况
func escapeDemo2_1() {
// 8191不会逃逸
s := make([]int, 8191, 8191)
for i := 0; i < len(s); i++ {
s[i] = i
}
}
func escapeDemo2_2() {
// 8192会逃逸
s := make([]int, 8192, 8192)
for i := 0; i < len(s); i++ {
s[i] = i
}
}
先执行下go build -gcflags="-m -l" main.go看下输出情况,
./main.go:61:11: make([]int, 8191, 8191) does not escape
./main.go:69:11: make([]int, 8192, 8192) escapes to heap
咦?escapeDemo2_1中的s没有发生逃逸,escapeDemo2_2中的s发生了逃逸。为什么会这样呢?下面在执行以下 go build -gcflags="-m -m -l" main.go看下更详细的输入,这里传入了两个-m.
./main.go:61:11: make([]int, 8191, 8191) does not escape
./main.go:69:11: make([]int, 8192, 8192) escapes to heap:
./main.go:69:11: flow: {heap} = &{storage for make([]int, 8192, 8192)}:
./main.go:69:11: from make([]int, 8192, 8192) (too large for stack) at ./main.go:69:11
./main.go:69:11: make([]int, 8192, 8192) escapes to heap
现在来分析一下原因,上面的输出给出最直接的原因,too large for stack, 也就是在栈上分配的内存太大了。一个int占8个字节,8192个int占用的内存是64k.那为啥大内存要分配到堆上呢?因为在Go中,函数的执行是在goroutine中的,goroutine是一种用户态线程,其调用栈内存被称为用户栈,与之对应的是系统线程。在GMP模型中,一个M对应一个系统栈(M的g0栈),M上的多个goroutine会共享系统栈。在x86_64架构下,系统栈的大小为8M。用户栈(goroutine)开始的大小为2k,这也是一个Go程序可以轻松开上万个goroutine的原因。为了不造成栈溢出和频繁的扩容或缩容,大的对象分配到堆上更合理。通过escapeDemo2_1和escapeDemo2_2可以看到,64K是一个临界值,小于64K的会分配在栈上,大于等于64K的对象会分配到堆上。
func escapeDemo3() {
v := 1
s := make([]int, v)
for i := 0; i < len(s); i++ {
s[i] = i
}
}
执行 go build -gcflags="-m -m -l" main.go可以看到s发生了逃逸.虽然这里v是1,申请的切片s占的空间很小,但v是一个变量,也将其分配到了堆上,可能是为了保证绝对的安全。放在堆上反正有gc回收,不会存在泄漏,也是没有问题的。
./main.go:78:11: make([]int, v) escapes to heap:
./main.go:78:11: flow: {heap} = &{storage for make([]int, v)}:
./main.go:78:11: from make([]int, v) (non-constant size) at ./main.go:78:11
./main.go:78:11: make([]int, v) escapes to heap
还是先来看一个例子,如下,下面的变量会发生内存逃逸吗?执行go build -gcflags="-m -l" main.go可以看到&User还真是发生了逃逸,为啥呢?&User跟fmt.Println有啥关系,这里传入的是u是User的地址。先跳转到fmt.Println内部,看看它的具体实现。
type User struct {
UserName string
PassWord string
Age int
}
func escapeDemo5() {
u := &User{"mingyong", "123", 12}
fmt.Println(u)
}
执行逃逸分析后输出
./main.go:91:7: &User literal escapes to heap
./main.go:92:13: ... argument does not escape
fmt.Println内部调用了Fprintln(os.Stdout, a...), Fprintln内部调用了下面的代码。p是一个对象的引用,根据上面第一个实例,可以知道p对象是分配在堆上的,p.doPrintln(a)将a赋值给p的一个字段上,这里的a也就是fmt.Println的传入参数。扩大了a的范围,将其分配在了堆上。这个也比较好理解,因为p在堆上,p的局部字段的内容生命周期也要跟p一样长,否则就出现p还存在,它的局部变量已不存在的情况。所以a不能分配在栈上。这里的a即传入的u,u是执行User的地址,所以&User要分配在堆上。
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}
为了进一步验证上面的分析,下面的例子与上面的稍有不同,进行对比分析,加深理解。
func escapeDemo6() {
u := &User{"mingyong", "123", 12}
call(u)
}
func call(u *User) {
u.UserName = "mingyong2"
}
执行逃逸分析,输出结果为
./main.go:96:7: &User literal does not escape
可以看到,上面的&User没有发生内存逃逸。因为call内部并没有放大u的范围,u是外部传入的,call内部也没有将其赋值给一个堆上的对象。所以&User并没有发生逃逸。下面将call中的u赋值给一个堆上对象的,看看是不是跟前面分析的一样,会发生逃逸。
type User2 struct {
uers *User
other string
}
func newUser2() *User2 {
return new(User2)
}
func call2(u *User) {
u2 := newUser2()
u2.uers = u
}
func escapeDemo7() {
u := &User{"mingyong", "123", 12}
call2(u)
}
执行逃逸分析输出可以看到&User发生了逃逸,已预期的一致
./main.go:101:7: &User literal escapes to heap
下面分析切片元素是否发生逃逸,对一个切片类型来说,它底层是有3个字段构成,分别是是真正存储切片数据的地址DData unsafe.Pointer,当前切片中的元素个数Len int, 切片的容量Cap int。一个切片类型的变量除了要考虑切片变量自身在哪里存储,还要考虑切片中元素的在哪里存储。下面的代码保存在escape.go文件中。
package main
import (
"reflect"
"unsafe"
)
func printHeader(header *[]int) {
sh := (*reflect.SliceHeader)(unsafe.Pointer(header))
println("slice data addr ", unsafe.Pointer(sh.Data))
}
func escapeDemo8() {
var s []int
println("s addr ", &s)
printHeader(&s)
s = append(s, 1)
println("add 1 to s")
printHeader(&s)
s = append(s, 2)
println("add 2 to s")
printHeader(&s)
}
func escapeDemo9() {
var s = make([]int, 0, 4)
println("\ns addr ", &s)
printHeader(&s)
s = append(s, 1)
println("add 1 to s")
printHeader(&s)
s = append(s, 2)
println("add 2 to s")
printHeader(&s)
s = append(s, 3)
println("add 3 to s")
printHeader(&s)
s = append(s, 4)
println("add 4 to s")
printHeader(&s)
s = append(s, 5)
println("add 5 to s")
printHeader(&s)
}
func escapeDemo10() *[]int {
var s = make([]int, 0, 4)
println("\ns addr ", &s)
printHeader(&s)
s = append(s, 1)
println("add 1 to s")
printHeader(&s)
return &s
}
func main() {
escapeDemo8()
escapeDemo9()
escapeDemo10()
}
执行go build -gcflags="-m -l" escape.go得到如下输出, 可以看到escapeDemo10中的s发生了逃逸,很容易理解,因为函数返回的s的引用。其他两个函数中的s都没有发送逃逸。
# command-line-arguments
./escape.go:8:18: header does not escape
./escape.go:26:14: make([]int, 0, 4) does not escape
./escape.go:53:6: moved to heap: s
./escape.go:53:14: make([]int, 0, 4) escapes to heap
执行go run escape.go运行下程序,输出如下. escapeDemo8中输入s的地址为0xc00009af48,s中数据data中第一个数据地址为0xc0000aa000,这时地址差异很大,是两个不同的区域。因为s没有逃逸,所以它的地址在栈上,根据地址信息,data是分配在堆上。在escapeDemo9中申请的是一个4个元素大小的切片。根据打印的地址信息s本身的地址(0xc00009aeb8)和数据地址(0xc00009ae90)是很相近的。所以可判断开始他们都分配在栈区。当向里面append第5个元素后,输出数据地址变了,此时为0xc0000ac000,可知分配的数据地址发生了变化。跟s的地址差异很大,可以判断此时数据分配在了堆上,并将之前栈上的数据拷贝到新分配的堆上。escapeDemo10中切片自身和数据都分配在堆上。
s addr 0xc00009af48
slice data addr 0x0
add 1 to s
slice data addr 0xc0000aa000
add 2 to s
slice data addr 0xc0000aa010
s addr 0xc00009aeb8
slice data addr 0xc00009ae90
add 1 to s
slice data addr 0xc00009ae90
add 2 to s
slice data addr 0xc00009ae90
add 3 to s
slice data addr 0xc00009ae90
add 4 to s
slice data addr 0xc00009ae90
add 5 to s
slice data addr 0xc0000ac000
s addr 0xc00009af60
slice data addr 0xc00009af20
add 1 to s
slice data addr 0xc00009af20
上面分析的是切片元素为值类型的切片情况,下面分析切片中的元素是指针类型的情况。还是先看一个实例,用事实说话。
func escapeDemo11() {
s := make([]*int, 0, 4)
v := 10
s = append(s, &v)
}
执行下逃逸检查看下输出,得到的输出为
# command-line-arguments
./escape2.go:5:2: moved to heap: v
./escape2.go:4:11: make([]*int, 0, 4) does not escape
输出的结果有点反常识,切片s没有逃逸,但是v发生了逃逸。虽然不是什么问题,因为v最后是会被GC回收的,按照我们通常的理解,v是不会发生逃逸的,因为引用它的切片并没有逃逸。那为什么会这样呢?继续看下面这个例子,看完你可能觉得有道理了。下面的代码将切片s传给了调用函数,然后在函数内部做了append操作。这里的s是没有逃逸的,但v是逃逸的,因为在callee被调用函数执行完成之后,在调用函数里面是可以访问s中的数据的,此时数据是要存在的。如果v不发生逃逸,当callee执行完后,回到调用函数,获取切片中数据地址是已经被回收了的(因为函数已出栈)。所以对于切片中是*T数据,都处理成逃逸,我猜测是考虑到了下面的情况,做了简化统一处理。虽然escapeDemo11可以继续分析s的作用域进一步决定v是否逃逸,但处理起来比较复杂。
func escapeDemo12() {
s := make([]*int, 0, 4)
callee(s)
}
func callee(s []*int) {
v := 10
s = append(s, &v)
}
对于元素为*T的切片,切片中的变量会逃逸,类似的在map和chan中也是这样。
虽然Go中逃逸分析算法已经很强大,但也很难做非常准确。对于不是很明确的情况,逃逸分析处理最保险的做法是分配到堆上,虽然会牺牲一点性能,但能保证程序的正确性。那对于某个变量,我很清楚它分配在栈上肯定没有问题,有没有方法人为阻止将其分配在堆上呢?有一个函数noescape,可以切断逃逸分析算法,阻止将变量分配在堆上。noescape在src/runtime/stubs.go中,下面是它的实现,这里抽出来放在这里。可以看到该函数是非导出的,也就是我们不能调用它。毕竟是黑科技,写业务程序的用不上,是给Go标准库和运行时实现用的,这里就不做深究了。
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
根据上面的实例分析,可以总结出几个必然的情况。下面的几种情况必然发生逃逸
在堆上分配内存比在栈上静态分配内存开销要大不少,因为堆上的内存要靠GC回收,GC是有代价的。逃逸分析算法非常复杂,工作中不确定变量到底分配在哪里,直接执行 go build -gcflags="-m" gofile可以观察到变量是否发生逃逸。Go中的逃逸分析是在编译阶段完成的,不是在运行时,这点与Java不太一样。
Detailed explanation of the mechanism of golang escape analysis[1]通过实例理解Go逃逸分析[2]
Detailed explanation of the mechanism of golang escape analysis: https://developpaper.com/detailed-explanation-of-the-mechanism-of-golang-escape-analysis/
[2]通过实例理解Go逃逸分析: https://blog.csdn.net/bigwhite20xx/article/details/117236072