概念
每个函数都有自己的内存区域来存放自己的局部变量、返回地址等,这个内存区域在栈中进行分配。当函数结束时,这段内存区域会进行释放。
但有些变量,我们想在函数结束后仍然使用它,那么就要把这个变量在堆上分配,这种从“栈”上逃逸到“堆”上的现象就是内存逃逸。
在栈上分配的内存,由系统申请和释放,不会有额外的性能开销。
而在堆上分配的内存,如果要回收掉,就需要进行GC,内存逃逸额外带来的GC会导致性能开销变大。
逃逸机制
根据变量是否被外部引用来决定是否逃逸:
- 若函数外部没有引用,则优先放入栈中
- 若函数外部存在引用,则优先放入堆中
- 若栈上放不下,则必定放到堆中
逃逸分析
通过编译参数-gcflags=-m
查看编译过程中的逃逸分析。
一般的逃逸类型:
指针逃逸
在函数中创建了一个对象,返回了这个对象的指针。此时,函数虽然推出了,但因为指针的存在,对象的内存不能随着函数结束而回收,只能逃逸到堆上。
package main
import "fmt"
type Demo struct { name string
}
func createDemo(name string) *Demo { d := new(Demo) // 局部变量 d 逃逸到堆 d.name = name return d
}
func main() { demo := createDemo("demo") fmt.Println(demo)
}
interface{}动态类型逃逸
如果函数的参数为interface{}
类型,编译期间很难确定其参数具体类型,也会发生逃逸。
func main() { demo := "demo" fmt.Println(demo)
}
demo
作为实参传给Println(i interface{}) (n int, err error)
方法,因为该函数的参数类型定义为interface{}
,因此会发生逃逸。
栈内存不足
操作系统对内核线程使用的栈空间是有大小限制的,因为栈空间通常较小,因此递归函数实现不当时,容易导致栈溢出。
对于Go语言来说,运行时(runtime)尝试在goroutine需要的时候动态地分配栈空间,goroutine的初始栈大小为2kb。
当goroutine被调度时,会绑定到内核线程执行,所以栈空间大小也不会炒股共操作系统的限制。
对于Go编译器来说,超过一定大小的局部变量将逃逸到堆上。
// 1.
func generate8192() { nums := make([]int, 8192) // = 64KB for i := 0; i < 8192; i++ { nums[i] = rand.Int() }
}
// 2.
func generate8193() { nums := make([]int, 8193) // > 64KB for i := 0; i < 8193; i++ { nums[i] = rand.Int() }
}
// 3.
func generate(n int) { nums := make([]int, n) // 不确定大小 for i := 0; i < n; i++ { nums[i] = rand.Int() }
} func main() { generate8192() generate8193() generate(1)
}
2和3均逃逸到堆上,而1没有。
说明当切片内存超过一定大小,栈空间不足时,便会逃逸到堆上。
闭包
当闭包访问到其外层的函数作用域时,会发生内存逃逸。
func Increase() func() int { n := 0 return func() int { n++ return n }
} func main() { in := Increase() fmt.Println(in())
}
Increase()
返回值是一个闭包函数,该闭包函数引用了外部变量n
,知道in()
被销毁,n
却不能随着函数退出而被回收,因此逃逸到堆上。
内存逃逸的影响
在栈上分配和回收内存的开销是很低的,只需要pop
和push
命令,分别负责释放栈空间和分配数据内存。在栈上分配内存,消耗的仅是将数据拷贝到内存的时间。
而在堆上分配内存,很大的额外开销是垃圾回收。
如果频繁发生内存逃逸,会导致程序占用过多的内存资源,影响程序的性能和稳定性。主要体现在以下几个方面:
- 内存占用增加:由于堆分配的内存不会自动释放,所以会导致程序占用的内存资源不断增加,特别是在长时间运行的程序中,可能会导致系统资源耗尽。
- 性能下降:相比于栈分配,堆分配需要更多的 CPU 和内存资源,stw,因此会导致程序的运行速度变慢。
- 程序不稳定:如果程序中存在大量的内存逃逸,可能会导致垃圾回收器频繁工作,从而影响程序的稳定性。