How Programs Remember: Stack and Heap

程式如何记忆:Stack 与 Heap

前言

学习高阶程序语言通常都会接受一个观念是:「没用到的变量会自动被垃圾回收掉」。不过越接近底层或开始探讨性能问题,发现自己对于程序语言核心的记忆体概念 Heap 与 Stack 并没有那么清楚。程序语言究竟是如何分配与管理记忆体的?所谓的垃圾回收(Garbage Collection, GC)具体来说又做了哪些事情?

为什么需要 Stack 与 Heap?

程序运行时需要将变量存放于记忆体当中,而操作系统作为程序与硬件之间协调的媒介,Stack 与 Heap 正是操作系统与语言执行环境用来管理记忆体的架构:

  • 效率:现代 CPU 为了性能会使用快取来暂存常用的记忆体片段,当记忆体内容变动时则必须同步更新快取,而这个过程本身是有成本的,从性能角度来看,资料在记忆体中越连续紧凑,CPU 快取命中率就越高,性能也越好。通过利用 Stack 可预期的记忆体分配方式,来换取极高的存取效率。
  • 灵活性:基于需要动态改变内容的数据与大量存放的需求,通过 Heap 灵活存放的特性互补 Stack 的弱项。

Stack

Stack 是一种「后进先出」的数据结构用于存放函数运行中的信息,想像一层层松饼堆叠后从最上面开始吃,最后放上去的最先被吃掉,且只能 push(推入)与 pop (弹出)来改变内容,运行原理如下:

  1. 系统会在 Stack 上建立一个新的 Stack Frame
  2. 把该函数的参数、区域变量、返回地址、暂存器状态⋯⋯等资料放进去
  3. 函数执行完毕后,整个 frame 会一次被 pop(弹出)
  • 优点:
    • 快速频繁的操作:结构上不需要找位置或判断资料还有没有在使用,可预期的结果造就高操作效率。
    • 存放紧凑:运用记忆体紧凑方便快取。
  • 缺点:
    • 无法应对对于浮动尺寸的数据:结构上很难优雅的应对浮动尺寸的数据。
    • 空间有限:结构上 Stack 需要指派固定尺寸,通常是数个 MB,可能在大量数据或递归场合下 Stack Overflow。

Heap

Process
├─ Thread A → Stack A
├─ Thread B → Stack B
└─ Heap (shared)

Heap 是一块用来存放「动态分配数据」的记忆体区域,不具备严格的进出顺序限制,可以采用不同的写入策略(First Fit、Best Fit、Worst Fit)。

基于 Stack 较小的固定尺寸且写入时只能紧凑的推入数据,Stack 通常仅保存指向 Heap 的指针,实际数据分配于 Heap,以避免 Stack 容量小且生命周期受限的问题。

  • 优点:
    • 灵活写入与释放:可动态的向 OS 申请更多空间。
    • 可存放跨多个函数存活的数据:由于 Stack 容量固定且数据生命周期受限,当数据需要跨函数存活或大小在编译期无法确定时,通常会分配于 Heap,而 Stack 仅保存指向 Heap 的指针。
  • 缺点:
    • 分配速度较慢:需要寻找合适的记忆体区域来存放数据。
    • 需要额外的管理机制:在高阶语言中,这通常由垃圾回收器(GC)负责,在 C 语言中则通过:
      • malloc / calloc / realloc:向 Heap 分配记忆体。
      • free:释放已分配的记忆体。
    • 可能产生记忆体碎片:长时间分配与释放不同大小的区块,会造成可用空间不连续。

Go 观察逃逸到 Heap 的变量

Go 编译器可以通过逃逸分析(Escape Analysis)来决定变量应该分配在 Stack 还是 Heap,当编译器无法证明变量在函数返回后不会被使用时,变量就会「逃逸」到 Heap 上:

Terminal window
go build -gcflags="-m" main.go
./heap-test.go:5:5: moved to heap: x
./heap-test.go:11:5: moved to heap: count
./heap-test.go:20:9: moved to heap: arr
./heap-test.go:27:12: 100 escapes to heap
package main
import "fmt"
// 示例 1: 返回局部变量的指针
func createPointer() *int {
x := 42 // x 原本在 stack 上
return &x // 返回指针导致 x 逃逸到 heap
}
// 示例 2: 变量被闭包捕获
func createClosure() func() int {
count := 0 // count 逃逸到 heap
return func() int {
count++
return count
}
}
// 示例 3: 变量太大
func largeArray() *[10000]int {
var arr [10000]int // 太大,逃逸到 heap
return &arr
}
// 示例 4: interface 类型
func useInterface() interface{} {
x := 100 // 赋值给 interface 导致逃逸
return x
}
func main() {
// 示例 1
ptr := createPointer()
fmt.Println("指针值:", *ptr)
// 示例 2
counter := createClosure()
fmt.Println("计数:", counter()) // 1
fmt.Println("计数:", counter()) // 2
// 示例 3
arr := largeArray()
fmt.Println("数组第一个元素:", arr[0])
// 示例 4
val := useInterface()
fmt.Println("interface 值:", val)
}

延伸阅读