Go语言虽然语法上类似C语言,但是也是一种“高级语言”,有一套内存管理系统,不需要向C语言去动态malloc/free堆内存,而是语言编译时根据具体使用情况来决定使用栈还是使用堆,堆内存也不需要程序员手动free内存,后台有一套gc机制,根据内存对象的生命周期(引用关系)决定是否回收内存。Go语言默认使用栈内存,在一些特定的情况会内存逃逸使用堆内存,本文会重点介绍内存逃逸以及GC机制。

Go在如下情况会使用堆内存,然后由GC完成内存回收。
| 逃逸类型 | 产生原因 | 具体场景/示例 |
|---|---|---|
| 指针逃逸 | 函数返回了局部变量的指针,其生命周期需在函数外部延续 | func f() *int { x := 10; return &x } |
| 接口类型逃逸 | 将值赋值给 interface{}类型,编译期无法确定其动态类型 | fmt.Println(123)或 var i interface{} = "hello" |
| 闭包引用逃逸 | 闭包函数引用了外部变量,该变量的生命周期需与闭包一致 | func() func() int { n:=0; return func() int { n++; return n } }() |
| 栈空间不足逃逸 | 变量过大,超过当前栈帧的承载能力 | s := make([]int, 0, 100000) |
| 动态分配逃逸 | 切片或数组的长度在编译期无法确定 | s := make([]int, n)(n为变量) |
| 发送指针到 channel | 指针被发送到 channel,其生命周期可能跨越 goroutine | ch <- &myStruct{} |
| 在集合中存储指针 | 在 map 或 slice 等集合中存储指针,且集合本身发生逃逸 | m["key"] = &value |
Go 编译器在编译阶段会进行逃逸分析,并提供了编译选项可以查看分析结果。
查看逃逸信息:在构建或运行 Go 代码时,使用 -gcflags='-m'选项即可。为了获得更详细的信息,通常还会加上 -l选项来禁止内联优化:
go build -gcflags='-m -l' main.go
命令执行后,编译器会输出代码的逃逸分析信息,如果看到某行代码提示 escapes to heap或 moved to heap,就表明该处的变量发生了内存逃逸。
内存逃逸最直接的影响是性能。堆分配比栈分配慢,因为涉及更复杂的内存管理。同时,堆上的对象需要垃圾回收器(GC)来管理,过多的逃逸会增加 GC 的压力,可能导致程序出现延迟。虽然无法也必要完全避免内存逃逸,但在编写高性能代码时,可以有所优化:
Go的堆内存分配器借鉴了TCMalloc的思想,采用多级缓存模式,这种设计通过本地缓存(mcache) 实现了绝大多数情况下无锁的快速分配,并通过尺寸规格(size class) 精细化管理,有效减少了内存碎片。
Go 将对象按大小分为三类:
GC机制可能会导致业务暂停,即Stop-The-World,那么Go语言是如何如何在保证内存安全的同时,最大限度地减少垃圾回收对程序性能的影响?Go使用并发三色标记清除算法,构建一个低延迟、并发执行、三色标记的垃圾回收器,将 STW 时间从早期版本的几百毫秒降低到亚毫秒级别。
runtime.GC()标记阶段会停止所有用户 goroutine,启动标记 worker进行扫描。首先会将所有对象视为白色,然后从根对象(如全局变量、Goroutine栈上的变量等)开始遍历,其标记过程可以概括为:
为了保证在标记过程中,因用户程序并发执行导致对象引用关系变化时不会错误地回收仍被引用的对象,Go使用了写屏障技术。详细步骤如下:
这是最耗时的阶段,但与用户程序并发执行:
这是第二个 STW 阶段,主要工作:
当标记阶段完成后,清除阶段会遍历堆内存,将标记为白色的不可达对象所占用的内存回收,以便后续分配使用。这个清除工作也是与用户程序并发执行的。
Go 提供了几个重要的 GC 调优参数,控制 GC 触发的频率,默认值为 100:
# 设置GOGC为200,减少GC频率但增加内存使用 export GOGC=200 # 设置GOGC为50,增加GC频率但减少内存使用 export GOGC=50 # 禁用GC(仅用于测试) export GOGC=off