编程很大程序上都是在操作内存,只是不同的编程语言(PHP、C、Go)决定使用者是否需要对内存的分配有多大的了解,或者说是有多深的了解。但是理解内存分配绝对让程序员可以写出更加高效的代码。因此,本文将从Go 的内存分配出发,谈谈内存相关的知识,加深对内存分配的理解。
在开始介绍前,读者可以问自己一个问题,Go 语言中哪些结构是分配在 Stack(栈) ,哪些结构是分配在 Heap(堆)上的?Ok,如果没有想法,那可以继续往下面看下,相信看完本篇文章可以让你轻易的回答上面这个问题。
首先要搞清楚 stack 和 heap 都是内存块,只是使用方式不一样,因此命名也不同,但本质都是存储数据的,在内存中的大致分布如图所示:
从上图可以看出,stack 和 heap 的内存地址的增长方向不一样:
接下来,我们以 Go 语言代码为例,谈谈 stack 和 heap 的分配。
func main() {
a := 1
s := []int{1, 2, 3}
}
这段简单的代码中的变量 a 在内存中的分配是怎么样的呢?首先,Go语言中每个协程都有自己的 Stack,main 函数作为入口函数,也是拥有自己的 Stack。
从上图可知,变量 a 分配在 stack上,变量 s 因为是切片,是由两部分组成:切片本身的指针结构和底层的数组结构,切片本身的指针结构具体如下:
type slice struct {
pointer *elementType // 指向底层数组的指针
length int // 切片的长度
capacity int // 切片的容量
}
我们可以简单理解成就是指针,这个指针是分配在 stack 上的,底层的数据是分配在 heap 上。那为什么是这样分配呢?主要是因为 stack 和 heap 的设计原理决定的。
栈是程序运行的基础。每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,这块内存被称为帧(frame)。
上文说过,栈是自顶向下增长的, main() 函数对应的帧作为入口,随着 main() 函数一层层调用,栈会一层层扩展;调用结束,栈又会一层层回溯,把内存释放回去。举个例子,以代码为例:
func main() {
a := 1
b := incr(a)
}
func incr(num int) int {
res := num + 1
return res
}
对应的执行 incr 函数时的栈结构如下:
对应的返回 incr 函数结果时的栈结构变化如下:
那一个函数运行时,怎么确定究竟需要多大的帧呢?
这要归功于编译器。在编译并优化代码的时候,一个函数就是一个最小的编译单元。在这个函数里,栈上要放哪些局部变量,而这些都要在编译时确定。所以编译器就需要明确每个局部变量的大小,以便于预留空间。
在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。比如,切片在程序运行过程中,底层的数据可能不断地扩容,因此整你放到栈上,因为切片底层数据在编译的时候根本无法确定。
栈上的内存分配是非常高效的,只需要改动帐指针就可以预留空间,把栈指针改动回来就就可以释放空间,不涉及额外的计算和系统调用。在 Go 语言中,栈上分配的内存是不需要 runtime 的 GC 来回收的。
Go 语言的 GC 是会影响程序性能的,特别是程序频繁地在堆上分配内存,会增加 GC 的频率,而 GC 是会有 "STW"(Stop-The-World)的机制,导致运行时线程都会被停止,最终影响性能。Go 语言中的基础类型,int、float、bool等这些固定大小的类型都是分配在 stack 上的。
这也启示我们,尽可能将内存分配在栈上或者减少堆上分配内存的频率和大小可以提升我们程序性能。
栈虽然使用起来很高效,但它的局限也显而易见。当我们需要动态大小的内存时,只能使用堆,比如可变长度的切片、字典,它们都分配在堆上,如图所示:
Go 语言分配在堆上的内存 1, 2, 3 是需要靠 runtime 的 GC 机制来回收内存的。
那么固定大小的基础类型就一定分配在 stack 上么?
其实也不是的,Go 语言中有个叫堆逃逸(heap escape)的情况,是指在函数内部创建的变量或对象逃逸到函数的外部,即超出了函数的生命周期,并在堆上分配内存空间。当一个变量或对象逃逸到堆上时,它的生命周期将由垃圾回收器来管理,而不是由函数的栈帧来管理,以代码为例:
func main() {
c := call()
}
func call() *int {
x := 2
return &x
}
代码运行过程中的栈变化如下图所示:
如果按照上图运行,变量 c 指向的是一个未知的值,叫做 "Dangling reference"(悬空引用),表示指针指向已经被释放或无效的内存位置的情况,使用悬空引用可能导致严重的问题,包括程序崩溃、数据损坏和安全漏洞。因此,在编程中应该尽量避免悬空引用的出现。
那这段 Go 代码真实运行的情况是怎么样的呢?请看下图:
那么我们有什么方式确认是不是真的如上图所示发生了堆逃逸么?可以使用以下命令:
go run -gcflags "-m -l" main.go
执行输出:
./main.go:8:2: moved to heap: x
扫码关注 了解更多