Go 语言较之 C 语言一个很大的优势就是自带 GC 功能,可 GC 并不是没有代价的。写 C 语言的时候,在一个函数内声明的变量,在函数退出后会自动释放掉,因为这些变量分配在栈上。如果你想要变量的数据能在函数退出后还能访问,就需要调用 malloc
方法在堆上申请内存,如果程序不再需要这块内存了,再调用 free
方法释放掉。Go 语言不需要你主动调用 malloc
来分配堆空间,编译器会自动分析,找出需要 malloc
的变量,使用堆内存。编译器的这个分析过程就叫做逃逸分析。
所以你在一个函数中通过 dict := make(map[string]int)
创建一个 map 变量,其背后的数据是放在栈空间上还是堆空间上,是不一定的。这要看编译器分析的结果。
可逃逸分析并不是百分百准确的,它有缺陷。有的时候你会发现有些变量其实在栈空间上分配完全没问题的,但编译后程序还是把这些数据放在了堆上。如果你了解 Go 语言编译器逃逸分析的机制,在写代码的时候就可以有意识的绕开这些缺陷,使你的程序更高效。
关于堆栈
Go 语言虽然在内存管理方面降低了编程门槛,即使你不了解堆栈也能正常开发,但如果你要在性能上较真的话,还是要掌握这些基础知识。
这里不对堆内存和栈内存的区别做太多阐述。简单来说就是,栈分配廉价,堆分配昂贵。栈空间会随着一个函数的结束自动释放,堆空间需要 GC 模块不断的跟踪扫描回收。如果对这两个概念有些迷糊,建议阅读下面 2 个文章:
这里举一个小例子,来对比下堆栈的差别:
func stack() int {
// 变量 i 会在栈上分配
i := 10
return i
}
func heap() *int {
// 变量 j 会在堆上分配
j := 10
return &j
}
stack
函数中的变量 i
在函数退出会自动释放;而 heap
函数返回的是对变量i
的引用,也就是说 heap()
退出后,表示变量 i
还要能被访问,它会自动被分配到堆空间上。
他们编译出来的代码如下:
// go build --gcflags '-l' test.go
// go tool objdump ./test
TEXT main.stack(SB) /tmp/test.go
test.go:7 0x487240 48c74424080a000000 MOVQ $0xa, 0x8(SP)
test.go:7 0x487249 c3 RET
TEXT main.heap(SB) /tmp/test.go
test.go:9 0x487250 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX
test.go:9 0x487259 483b6110 CMPQ 0x10(CX), SP
test.go:9 0x48725d 7639 JBE 0x487298
test.go:9 0x48725f 4883ec18 SUBQ $0x18, SP
test.go:9 0x487263 48896c2410 MOVQ BP, 0x10(SP)
test.go:9 0x487268 488d6c2410 LEAQ 0x10(SP), BP
test.go:10 0x48726d 488d05ac090100 LEAQ 0x109ac(IP), AX
test.go:10 0x487274 48890424 MOVQ AX, 0(SP)
test.go:10 0x487278 e8f33df8ff CALL runtime.newobject(SB)
test.go:10 0x48727d 488b442408 MOVQ 0x8(SP), AX
test.go:10 0x487282 48c7000a000000 MOVQ $0xa, 0(AX)
test.go:11 0x487289 4889442420 MOVQ AX, 0x20(SP)
test.go:11 0x48728e 488b6c2410 MOVQ 0x10(SP), BP
test.go:11 0x487293 4883c418 ADDQ $0x18, SP
test.go:11 0x487297 c3 RET
test.go:9 0x487298 e8a380fcff CALL runtime.morestack_noctxt(SB)
test.go:9 0x48729d ebb1 JMP main.heap(SB)
// ...
TEXT runtime.newobject(SB) /usr/share/go/src/runtime/malloc.go
malloc.go:1067 0x40b070 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX
malloc.go:1067 0x40b079 483b6110 CMPQ 0x10(CX), SP
malloc.go:1067 0x40b07d 763d JBE 0x40b0bc
malloc.go:1067 0x40b07f 4883ec28 SUBQ $0x28, SP
malloc.go:1067 0x40b083 48896c2420 MOVQ BP, 0x20(SP)
malloc.go:1067 0x40b088 488d6c2420 LEAQ 0x20(SP), BP
malloc.go:1068 0x40b08d 488b442430 MOVQ 0x30(SP), AX
malloc.go:1068 0x40b092 488b08 MOVQ 0(AX), CX
malloc.go:1068 0x40b095 48890c24 MOVQ CX, 0(SP)
malloc.go:1068 0x40b099 4889442408 MOVQ AX, 0x8(SP)
malloc.go:1068 0x40b09e c644241001 MOVB $0x1, 0x10(SP)
malloc.go:1068 0x40b0a3 e888f4ffff CALL runtime.mallocgc(SB)
malloc.go:1068 0x40b0a8 488b442418 MOVQ 0x18(SP), AX
malloc.go:1068 0x40b0ad 4889442438 MOVQ AX, 0x38(SP)
malloc.go:1068 0x40b0b2 488b6c2420 MOVQ 0x20(SP), BP
malloc.go:1068 0x40b0b7 4883c428 ADDQ $0x28, SP
malloc.go:1068 0x40b0bb c3 RET
malloc.go:1067 0x40b0bc e87f420400 CALL runtime.morestack_noctxt(SB)
malloc.go:1067 0x40b0c1 ebad JMP runtime.newobject(SB)
逻辑的复杂度不言而喻,上面的汇编中可看到, heap()
函数调用了 runtime.newobject()
方法,它会调用 mallocgc
方法从 mcache
上申请内存,申请的内部逻辑前面文章已经讲述过。堆内存分配不仅分配上逻辑比栈空间分配复杂,它最致命的是会带来很大的管理成本,Go 语言要消耗很多的计算资源对其进行标记回收(也就是 GC 成本)。
不要以为使用了堆内存就一定会导致性能低下,使用栈内存会带来性能优势。因为实际项目中,系统的性能瓶颈一般都不会出现在内存分配上。千万不要盲目优化,找到系统瓶颈,用数据驱动优化。
逃逸分析
Go 编辑器会自动帮我们找出需要进行动态分配的变量,它是在编译时追踪一个变量的生命周期,如果能确认一个数据只在函数空间内访问,不会被外部使用,则使用栈空间,否则就要使用堆空间。
我们在 go build
编译代码时,可使用 -gcflags '-m'
参数来查看逃逸分析日志。
go build -gcflags '-m -m' test.go
以上面的两个函数为例,编译的日志输出是:
/tmp/test.go:11:9: &i escapes to heap
/tmp/test.go:11:9: from ~r0 (return) at /tmp/test.go:11:2
/tmp/test.go:10:2: moved to heap: i
/tmp/test.go:16:18: heap() escapes to heap
/tmp/test.go:16:18: from ... argument (arg to ...) at /tmp/test.go:16:13
/tmp/test.go:16:18: from *(... argument) (indirection) at /tmp/test.go:16:13
/tmp/test.go:16:18: from ... argument (passed to call[argument content escapes]) at /tmp/test.go:16:13
/tmp/test.go:16:13: main ... argument does not escape
日志中的 &i escapes to heap
表示该变量数据逃逸到了堆上。
逃逸分析的缺陷
需要使用堆空间则逃逸,这没什么可争议的。但编译器有时会将不需要使用堆空间的变量,也逃逸掉。这里是容易出现性能问题的大坑。网上有很多相关文章,列举了一些导致逃逸情况,其实总结起来就一句话:
多级间接赋值容易导致逃逸。
这里的多级间接指的是,对某个引用类对象中的引用类成员进行赋值。Go 语言中的引用类数据类型有 func
, interface
, slice
, map
, chan
, *Type(指针)
。
记住公式 Data.Field = Value
,如果 Data
, Field
都是引用类的数据类型,则会导致 Value
逃逸。这里的等号 =
不单单只赋值,也表示参数传递。
根据公式,我们假设一个变量 data
是以下几种类型,相应的可得出结论:
[]interface{}
:data[0] = 100
会导致100
逃逸map[string]interface{}
:data["key"] = "value"
会导致"value"
逃逸map[interface{}]interface{}
:data["key"] = "value"
会导致key
和value
都逃逸map[string][]string
:data["key"] = []string{"hello"}
会导致切片逃逸map[string]*int
: 赋值时*int
会 逃逸[]*int
:data[0] = &i
会使i
逃逸func(*int)
:data(&i)
会使i
逃逸func([]string)
:data([]{"hello"})
会使[]string{"hello"}
逃逸chan []string
:data <- []string{"hello"}
会使[]string{"hello"}
逃逸- 以此类推,不一一列举了
下面给出一些实际的例子:
函数变量
如果变量值是一个函数,函数的参数又是引用类型,则传递给它的参数都会逃逸。
func test(i int) {}
func testEscape(i *int) {}
func main() {
i, j, m, n := 0, 0, 0, 0
t, te := test, testEscape // 函数变量
// 直接调用
test(m) // 不逃逸
testEscape(&n) // 不逃逸
// 间接调用
t(i) // 不逃逸
te(&j) // 逃逸
}
./test.go:4:17: testEscape i does not escape
./test.go:11:5: &j escapes to heap
./test.go:11:5: from te(&j) (parameter to indirect call) at ./test.go:11:4
./test.go:7:5: moved to heap: j
./test.go:14:13: main &n does not escape
上例中 te
的类型是 func(*int)
,属于引用类型,参数 *int
也是引用类型,则调用 te(&j)
形成了为 te
的参数(成员) *int
赋值的现象,即 te.i = &j
会导致逃逸。代码中其他几种调用都没有形成多级间接赋值情况。
同理,如果函数的参数类型是 slice
, map
或 interface{}
都会导致参数逃逸。
func testSlice(slice []int) {}
func testMap(m map[int]int) {}
func testInterface(i interface{}) {}
func main() {
x, y, z := make([]int, 1), make(map[int]int), 100
ts, tm, ti := testSlice, testMap, testInterface
ts(x) // ts.slice = x 导致 x 逃逸
tm(y) // tm.m = y 导致 y 逃逸
ti(z) // ti.i = z 导致 z 逃逸
}
./test.go:3:16: testSlice slice does not escape
./test.go:4:14: testMap m does not escape
./test.go:5:20: testInterface i does not escape
./test.go:8:17: make([]int, 1) escapes to heap
./test.go:8:17: from x (assign-pair) at ./test.go:8:10
./test.go:8:17: from ts(x) (parameter to indirect call) at ./test.go:10:4
./test.go:8:33: make(map[int]int) escapes to heap
./test.go:8:33: from y (assign-pair) at ./test.go:8:10
./test.go:8:33: from tm(y) (parameter to indirect call) at ./test.go:11:4
./test.go:12:4: z escapes to heap
./test.go:12:4: from ti(z) (parameter to indirect call) at ./test.go:12:4
匿名函数的调用也是一样的,它本质上也是一个函数变量。有兴趣的可以自己测试一下。
间接赋值
type Data struct {
data map[int]int
slice []int
ch chan int
inf interface{}
p *int
}
func main() {
d1 := Data{}
d1.data = make(map[int]int) // GOOD: does not escape
d1.slice = make([]int, 4) // GOOD: does not escape
d1.ch = make(chan int, 4) // GOOD: does not escape
d1.inf = 3 // GOOD: does not escape
d1.p = new(int) // GOOD: does not escape
d2 := new(Data) // d2 是指针变量, 下面为该指针变量中的指针成员赋值
d2.data = make(map[int]int) // BAD: escape to heap
d2.slice = make([]int, 4) // BAD: escape to heap
d2.ch = make(chan int, 4) // BAD: escape to heap
d2.inf = 3 // BAD: escape to heap
d2.p = new(int) // BAD: escape to heap
}
./test.go:20:16: make(map[int]int) escapes to heap
./test.go:20:16: from d2.data (star-dot-equals) at ./test.go:20:10
./test.go:21:17: make([]int, 4) escapes to heap
./test.go:21:17: from d2.slice (star-dot-equals) at ./test.go:21:11
./test.go:22:14: make(chan int, 4) escapes to heap
./test.go:22:14: from d2.ch (star-dot-equals) at ./test.go:22:8
./test.go:23:9: 3 escapes to heap
./test.go:23:9: from d2.inf (star-dot-equals) at ./test.go:23:9
./test.go:24:12: new(int) escapes to heap
./test.go:24:12: from d2.p (star-dot-equals) at ./test.go:24:7
./test.go:13:16: main make(map[int]int) does not escape
./test.go:14:17: main make([]int, 4) does not escape
./test.go:15:14: main make(chan int, 4) does not escape
./test.go:16:9: main 3 does not escape
.