Go 中的栈与堆
Go 中栈帧与堆分配的分工:goroutine 栈可增长、堆由分配器与 GC 管理,以及正确性不依赖具体分配位置。
[!info] 关联笔记
Go 中的栈与堆
这个概念为什么出现
讨论指针、性能与“变量在哪”时,经典二分是:
- 栈(stack):函数调用自动分配/回收
- 堆(heap):动态生命周期,需显式释放——在 Go 里由 GC 回收
在 Go 中还要加上第三条:
- 每个 goroutine 有自己的栈,且实现上通常可增长/收缩
- 编译器决定值放栈还是堆(逃逸分析)
- 正确性不依赖你猜对位置(FAQ)
不先建立这张图,就无法正确理解逃逸、分配器、GC 与“为何 goroutine 初始栈很小却能递归/深调用”。
[!abstract] 一句话理解 栈服务当前 goroutine 的函数调用帧;堆服务生命周期更长或无法证明限于栈帧的对象。栈分配随返回廉价失效,堆分配经分配器与 GC——位置由实现选择,语义由语言定义。
最小可观察示例
先把示例放进可验证实验场景,再看代码:
场景:弄清「变量在哪」——性能排障的前置地图
讨论 return &x 是否分配、goroutine 初始栈为何很小却能深调用时,需要一张栈帧 vs 堆地图。
工程师关心:栈随返回廉价失效;堆经分配器与 GC;正确性不依赖猜对位置,但读 pprof/-m 必须懂分工。本实验只建立直觉,不做会计级计量。
package main
import "fmt"
// heapish:返回局部地址——引用活过函数,编译器通常把 x 放到堆(实现决策)。
// 业务类比:构造 *Session 返回给调用方长期持有。
func heapish() *int {
x := 7
return &x
}
// stackish:只返回值——调用方不持有 &y,y 通常可在栈/寄存器(实现决策)。
// 业务类比:纯计算返回小整数/小结构体。
func stackish() int {
y := 7
return y
}
func main() {
p := heapish()
fmt.Println(*p, stackish()) // 期望:7 7
}
go build -gcflags="-m" .
# 期望:heapish 中 x 常出现 moved to heap 类备注(文案随版本变化)
再观察 goroutine 栈与堆统计(粗粒度过程量):
package main
import (
"fmt"
"runtime"
)
func main() {
var st runtime.MemStats
runtime.ReadMemStats(&st)
fmt.Printf("HeapAlloc=%d StackInuse=%d\n", st.HeapAlloc, st.StackInuse)
done := make(chan struct{})
go func() {
// 每个 G 有自己的栈;大局部/深调用可能促使栈增长(实现细节)
var big [256]byte
_ = big
close(done)
}()
<-done
runtime.ReadMemStats(&st)
fmt.Printf("HeapAlloc=%d StackInuse=%d\n", st.HeapAlloc, st.StackInuse)
}
MemStats 适合建立直觉,不适合当严格会计。
结合场景再看关注点
- 栈服务调用帧,堆服务更长寿命/无法证明限于栈的对象。
- 位置由实现选择;语义由语言定义——先正确,再谈逃逸优化。
- 每 G 一栈解释了为何能开很多 goroutine,以及深调用与栈增长相关。
核心模型
两种存储的职责
flowchart TB
subgraph G["单个 goroutine"]
S["栈 frames<br/>参数·局部·返回地址"]
end
subgraph H["进程堆"]
O["对象<br/>slice 数组·map 桶·逃逸变量..."]
end
S -->|"指针/描述符"| O
O --> GC["GC 回收不可达对象"]
| 维度 | 栈 | 堆 |
|---|---|---|
| 谁分配 | 函数入口调栈(实现) | runtime 堆分配器 |
| 谁释放 | 函数返回 | GC |
| 可见范围 | 当前 G 的调用链 | 任何持有引用者 |
| 失败模式 | 栈溢出(少见,可增长) | OOM / 高 GC 压力 |
| 性能 | 极便宜 | 分配 + 后续 GC |
Goroutine 栈(实现直觉)
- 每个 G 自带栈,不是所有 G 共享一个 C 式进程栈模型。
- 初始栈较小,深度增加时可栈增长(复制到更大栈等策略,版本相关)。
- 栈上指针被运行时精确管理,GC 需要知道栈根。
- 调度切换保存的是 G 的上下文,包括栈边界信息。
这与 调度器 紧密相关。
堆
逃逸分析如何连接二者
编译器问:这个值能否只活在栈上?
- 能 → 栈/寄存器
- 不能 → 堆
指针与“悬挂”
在 C 里返回局部变量地址是未定义行为。在 Go 里,若引用在返回后仍需要,实现把对象放堆,避免悬空。你仍可能通过 unsafe 制造悬空,那是另一回事。
值传递与拷贝
大结构体在栈上传递可能发生拷贝;传指针减少拷贝却可能引起逃逸。取舍用 benchmark,不靠口号。见 值与引用语义。
规范 vs 实现分界
| 规范 / FAQ 级 | 实现细节 |
|---|---|
| 有自动内存管理(GC) | 初始栈大小、增长算法 |
| 程序员不手动 free 堆对象 | 连续栈 vs 分段栈等历史方案 |
| 栈或堆由实现选择 | 具体栈拷贝时机 |
| 语义正确性不依赖位置 | MemStats 字段精确含义随版本 |
| 数据竞争由内存模型定义 | 栈扫描实现 |
历史:Go 曾用分段栈,后改为连续栈增长等——说明栈机制可替换,应用代码不应依赖。
边界
-
大局部数组
可能直接在栈上占用巨大空间或迫使堆分配(视逃逸与大小策略)。热路径慎放巨大[N]T。 -
递归深度
栈可增长,但不是无限;极端递归仍可能耗尽内存。 -
cgo
C 栈与 Go 栈切换有额外规则与成本。 -
最终器与 ressurection
堆对象生命周期可被 finalizer 复杂化;不要当主资源管理。 -
调试器里“在栈上”
优化与寄存器分配会让“变量位置”更难直观对应源码行。
常见误区
[!warning] 认为 Go 没有栈只有堆 每个 G 都有栈;大量短命局部变量根本不进堆。
[!warning] 认为取地址一定堆分配 逃逸分析可能仍放栈。
[!warning] 用“在栈上”证明无竞态 多 G 若共享堆上数据,仍要同步;栈私有也不等于你把指针发给了别人。
[!warning] 把栈增长成本当成永远为零 增长涉及复制与写屏障相关工作(实现),极端深度调用有成本。
[!warning] 手动“预留堆”来替代良好设计 先减少泄漏与不必要存活,再谈调 GC。
工程实践
- 正确性优先:按值/指针语义与生命周期写代码,不臆测位置。
- 性能:用
-benchmem、heap profile、必要时-m。 - API:需要共享可变状态再暴露指针;文档说明所有权。
- 避免:无界 goroutine + 大闭包捕获导致的堆驻留。
- 观测:
StackInusevsHeapAlloc帮助区分“太多 G/深栈”与“堆对象爆炸”。
可验证实验
实验 A:逃逸对比
heapish vs stackish 的 -m 与 benchmark allocs。
实验 B:goroutine 数量与 StackInuse
创建 1e5 个阻塞在 channel 上的 G,读 MemStats.StackInuse 与 NumGoroutine(注意机器内存,量力而行)。
实验 C:大数组局部变量
func f() {
var a [1 << 20]byte // 1<<20 字节级
_ = a
}
对比改成 make([]byte, 1<<20) 的 heap 指标。
实验 D:指针传出
把局部 struct 地址存入包级变量,确认逃逸与生命周期延长。
本节总结
- 栈:每 G 的调用帧存储,随返回回收,实现可增长。
- 堆:跨生命周期对象,分配器 + GC。
- 逃逸分析连接二者;FAQ 明确位置不由程序员声明。
- 优化看数据;正确性看语义与同步,不看“猜在栈上”。
自测题
- 为什么 Go 返回局部变量地址通常是安全的?
- 栈与堆的释放机制有何本质不同?
GOMAXPROCS是否等于栈的个数?- 为何正确性不能依赖“变量在栈上”?
- 观察栈/堆压力时,应用层首选哪些工具?
参考答案
- 若引用在返回后仍需要,实现把变量放到堆,由 GC 管理,避免悬空。
- 栈随函数返回自动回收;堆对象在不可达后由 GC 回收。
- 否。
GOMAXPROCS与 P/并行度相关;栈个数与 goroutine 相关。 - 位置是实现决策,可随版本与优化变化;语言语义不绑定位置。
- benchmark/
-benchmem、pprof heap、runtime.MemStats、必要时-gcflags=-m。