make分配的是header加底层数据,逃逸只看后者:slice/map/channel的header可栈分配,但底层数组/哈希桶/缓冲区因需增长或并发访问必堆分配;new(t)若指针未逃逸则全栈。

make分配的不是“对象”,而是header + 底层数据,逃逸只看后者
很多人误以为 make([]int, 10) 整个切片都“逃逸到堆”,其实不是:编译器会把 slice header(含指针、len、cap 三个字段)留在栈上,真正逃逸的是它指向的底层数组。map 和 channel 同理——make(map[string]int) 的 map header 可能栈分配,但哈希桶数组几乎必然堆分配。
关键判断依据是:该底层数据是否可能被函数外访问,或大小超出栈容量阈值(如 >64KB 或动态 size)。
-
make([]int, 2):小尺寸、固定长度 → header + 数组都可能全栈(取决于 Go 版本和逃逸分析结果) -
make([]int, n)(n 是变量):即使 n=2,只要不是编译期常量 → 底层数组必逃逸(编译器无法静态确认 size) -
make([]int, 0, 1000):capacity 已知且适中 → header 栈上,数组堆上 -
make(map[string]int, 1):map header 栈上,但哈希表结构仍需堆分配(哪怕只存 1 个 key)
为什么 make(map/slice/channel) 几乎总触发逃逸,而 new(T) 不一定
new(T) 返回 *T,分配的是一个零值 T 并取其地址;如果这个指针没被返回、没被传入可能逃逸的函数(如闭包、goroutine、全局变量赋值),整个 T 就留在栈上。例如 new(int) 在函数内未返回,通常不逃逸。
但 make 创建的是引用类型,其语义天然要求“可增长”或“可并发访问”。slice 的底层数组、map 的桶数组、channel 的缓冲区,都不满足“生命周期严格限定在当前栈帧内”的条件——哪怕你没显式返回,只要后续有 append、map assign、chan send,编译器就认为底层数据必须存活更久。
- slice:一旦
append可能扩容,就必须保证底层数组可重分配 → 初始分配即倾向堆 - map:插入操作会修改内部哈希结构 → 编译器保守判定为逃逸
- channel:发送/接收涉及 goroutine 调度与锁 → 底层 ring buffer 必须堆分配
用 -gcflags="-m" 看清真实逃逸路径,别信直觉
Go 编译器的逃逸分析不是靠规则枚举,而是基于数据流图做可达性推导。同一个 make 调用,在不同上下文里逃逸结论可能相反。比如:
func f() {
s := make([]int, 10)
// 没被返回、没传给其他函数 → 可能全栈
}
func g() []int {
s := make([]int, 10)
return s // 这里 s.header 本身栈上,但底层数组必须堆分配,否则返回后内存失效
}
运行 go build -gcflags="-m -l" main.go 才能看到实际决策。常见提示包括:
-
mov to heap: s→ 底层数组逃逸 -
leaking param: s→ 参数被外部持有,触发逃逸 -
does not escape→ 当前作用域内无逃逸
注意:-l 禁用内联后结果更稳定,否则函数内联可能掩盖真实逃逸路径。
高频踩坑点:make(..., 0) 和 make(..., 0, N) 的逃逸差异极大
make([]int, 0) 和 make([]int, 0, 10) 看似只差一个参数,但逃逸行为天壤之别:
-
make([]int, 0)→ len=0, cap=0 → 底层数组为nil→ 不分配底层数组 → 零逃逸(header 栈上) -
make([]int, 0, 10)→ cap=10 → 必须预分配 10 个 int 的数组 → 底层数组逃逸到堆
这意味着:如果你只是想初始化一个空切片用于后续 append,且不确定最终大小,make([]int, 0) 更轻量;但如果你明确知道后续要追加约 N 个元素,用 make([]int, 0, N) 能避免多次扩容 realloc,此时多一次堆分配是值得的。
真正容易被忽略的是:这种差异在 benchmark 中可能不明显,但在高并发、短生命周期 goroutine 场景下,大量 make([]int, 0, N) 会显著抬升 GC 压力——因为每个底层数组都是独立堆块,而 make([]int, 0) 完全不触发堆分配。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











