Go 内存分配
Go 程序创建一个值,不一定就要在堆上分配一个对象。编译器会根据这个值的使用方式,决定能否把它留在寄存器或当前 goroutine 的栈上;需要堆空间时,再由运行时分配器提供内存。
面试中解释内存管理,要把这两步分开。new、make 描述创建什么值,不能直接决定值存放在哪里;mcache、mcentral、mheap 描述的是堆分配路径,也不能用来解释所有局部变量。本文的实现细节以 Go 1.22.12 为准。
1. 面试核心
先记住这几条,再读正文- 栈主要承载当前调用链需要的执行状态,堆承载需要独立管理生命周期的对象。Go 的指针值可以指向栈上或堆上的对象,返回指针也不意味着返回了一个即将失效的栈地址;具体存储位置由编译器分析决定。卡 · 栈、堆与变量
- 小对象通常先从当前 P 的
mcache取得空闲位置,不是每次都向操作系统申请内存。缓存需要补充时,才进入按类别管理 span 的mcentral,再由mheap提供页;大对象直接按页分配。卡 · 堆分配器 - 对象请求的字节数不一定等于实际分配的空间。尺寸类别会向上取整,极小无指针对象可以合并分配,带指针对象还需要考虑 GC 的扫描信息。卡 · 页、span 与尺寸类别
- 普通 goroutine 使用可以增长、也可以在满足条件时缩小的栈。Go 1.22.12 的基础最小栈是 2 KiB,但平台附加空间、栈复用、自适应初始大小和实际调用都会影响观察结果,不能把它当作一个 goroutine 的固定总内存。卡 · goroutine 栈
2. 栈、堆与变量
2.1 存储位置与生命周期
调用栈保存函数调用需要的栈帧,包括部分参数、局部变量和返回所需的状态。普通 goroutine 有自己的栈,调用深入时需要更多空间,函数返回后对应栈帧就不再承担这次调用的工作。
堆是运行时管理对象存储的区域。一个对象如果需要在创建它的调用结束后继续使用,就不能依赖已经结束的那次调用的栈帧。编译器必须安排足够长的生命周期,GC 再根据对象是否仍然可达决定回收。
这不是按语法给变量分类:
| 写法或对象 | 能确定什么 | 不能直接推出什么 |
|---|---|---|
| 函数内声明的局部变量 | 名字的作用域位于函数内 | 对应存储一定在栈上 |
new(T) 或 &T{} | 得到指向 T 值的指针 | T 一定在堆上 |
make([]T, n) | 创建可用的切片及其底层存储 | 切片描述信息与底层数组必须在同一位置 |
| 包级变量 | 变量可以在程序运行期间持续存在 | 变量自身与它指向的对象都属于同一块堆内存 |
例如,包级指针变量自身有静态存储,它指向的动态对象可以在堆上。切片变量保存的是描述信息,底层数组另有存储;把切片复制一份,不会同时复制底层数组,见 Go 切片。
逃逸分析是编译器判断对象的引用会流向哪里、能否安全放在栈上的分析。生命周期之外,对象大小、编译期能否确定所需空间等条件也会影响分配;没有逃逸不等于一定产生一个可见的栈对象,编译器还可能把值放进寄存器或消除它。
Go 允许安全地返回局部变量的指针。编译器会保证被引用的值在使用期间有效;内联后分析的调用范围也可能改变,所以“返回指针一定有一次堆分配”同样不能当作语言规则。值传递的含义见 Go 类型与值传递。
2.2 new 和 make 不指定栈或堆
下面用 testing.AllocsPerRun 观察三段代码的堆分配次数。//go:noinline 用于固定这个示例的函数边界,避免内联改变对照条件;它不是业务代码中的优化建议。
package main
import (
"fmt"
"testing"
)
type Cell struct{ values [4]int64 }
var number int64
var saved *Cell
//go:noinline
func local() int64 {
p := new(Cell)
p.values[0] = 7
return p.values[0]
}
//go:noinline
func localMap() int64 {
m := make(map[int]int64)
m[1] = 7
return m[1]
}
//go:noinline
func retained() *Cell {
p := new(Cell)
p.values[0] = 7
return p
}
func main() {
a := testing.AllocsPerRun(1000, func() { number = local() })
b := testing.AllocsPerRun(1000, func() { number = localMap() })
c := testing.AllocsPerRun(1000, func() { saved = retained() })
fmt.Println(a, b, c)
fmt.Println(number, saved.values[0])
}
在 Go 1.22.12、darwin/arm64、默认编译配置下,输出为 0 0 1 和 7 7。local 中的 new(Cell) 没有造成堆分配,只有一个元素的 localMap 也没有;retained 返回的对象被保存在 saved 中,每次调用产生一次堆分配。
这里的 0 只说明没有观察到堆分配,不能据此判断每个值究竟占用了几个栈字节。map 增加元素后可能需要新的桶,代码被内联、改写或换用其他版本后,结果也可能不同。new、make 的语言语义不随这些优化改变。
AllocsPerRun 会先预热一次,测量期间临时把 GOMAXPROCS 设为 1,再恢复原值。这个版本用整数除法计算平均次数,因此它适合检查这里稳定的零次、一次分配,不应把输出解释为任意精度的分配率;它也没有测量操作耗时或进程常驻内存。
3. 堆分配器
3.1 页、span 与尺寸类别
span 是运行时管理的一段连续虚拟地址空间,由若干运行时页组成。Go 1.22.12 的运行时页大小为 8 KiB;它不等于所有平台的操作系统页大小,也不意味着物理内存连续。
小对象并不是各自占一页。运行时把一个 span 划成多个同样大小的槽位,尺寸类别就是这些槽位的大小档次。例如,申请 17 字节的无指针对象,可以使用 24 字节的槽位;申请 25 字节,则要进入 32 字节的档次。
这样做可以快速找到合适的空位,也便于回收后复用。代价是槽位内可能留有未使用的空间:17 字节对象占用 24 字节槽位,不能把剩余 7 字节随意交给另一个普通对象。
尺寸之外,span 还区分对象是否包含需要扫描的指针。源码把尺寸类别和 noscan 标志组合成 spanClass;无指针对象的内容不需要被 GC 当作引用继续扫描。存储槽位的大小与扫描职责是两个维度。
3.2 mcache、mcentral 与 mheap
这三层分别解决本地快速分配、同类 span 补充和全局页管理的问题。
| 层次 | 管理的内容 | 什么时候参与 |
|---|---|---|
mcache | 当前 P 持有的、按 spanClass 区分的可分配 span,以及 tiny 分配状态等 | 小对象优先从已有 span 取空槽位 |
mcentral | 每个 spanClass 下的 span,区分可用空间与清扫状态 | mcache 需要补充 span 时 |
mheap | 运行时堆的页与 span | 上层需要更多页,或分配大对象时 |
当前 P 的分配状态由正在持有这个 P 的执行者使用,因此常见的小对象分配不必每次竞争一个全局分配锁。这里的本地是 P 本地,不是每个 goroutine 一份,也不是对象只能被创建它的 goroutine 使用。P 与线程的关系见 Go 调度器。
以一个需要堆存储的 24 字节无指针对象为例,运行时先选择对应的尺寸类别与 noscan 类别,再从 mcache 当前 span 的空槽位中分配。如果这个 span 已满,就向相应 mcentral 获取可用 span;后者可能利用已经清扫的空闲槽位、先完成必要的清扫,或者向 mheap 申请新的页。
mheap 可以使用已经管理的空闲页,确有需要时再扩展堆并向操作系统申请空间。因此,“分配对象”不等于“执行一次系统调用”,而“本地缓存命中”也不表示完全没有成本:对象清零、GC 分配记账以及必要时协助 GC,仍可能参与分配路径。
3.3 极小对象与大对象
Go 1.22.12 对小于 16 字节且不包含指针的对象使用 tiny 分配路径,尝试把多个对象放进同一个 16 字节块。具体能否拼进当前块,还受剩余空间和对齐约束影响。
它减少了极小对象分别占用槽位的开销,但这些小对象共享一个回收单元:块里还有对象被引用时,整块就不能回收。包含指针的对象不能直接套用这个规则;开启 race detector 时,tiny 块复用行为也与默认构建不同。
较大的对象跳过小对象缓存的槽位分配,直接向 mheap 申请足够的运行时页,一个大对象占用自己的 span。例如,32769 字节的无指针对象需要向上取整到 5 个 8 KiB 页,也就是 40960 字节的 span 空间。
“32 KiB 以下都是小对象”可以帮助记忆大致分界,但并不精确。Go 1.22.12 默认启用分配头实现,mallocgc 使用 size <= 32768 - 8 进入小对象分支;部分带指针对象还要加上 8 字节头部,再选择尺寸类别。修改实验配置可能改变这条具体边界。面试中重点是说明按尺寸分配、取整和大对象独占 span,不需要把版本常量当成 API 承诺。
4. goroutine 栈
4.1 小栈与按需增长
普通 goroutine 不需要一开始就为深层调用准备一块很大的栈。它从较小的栈开始,在执行过程中检查剩余空间,不够时再扩大。
Go 1.22.12 的 stackMin 是 2048 字节。实际初始分配还要考虑平台所需的 stackSystem 并按规则取整;这个版本也会根据 GC 观察到的栈使用情况调整 startingStackSize,在复用 G、重新配栈等路径中使用。因此,2 KiB 是理解小栈设计的起点,不能概括所有 goroutine 在所有时刻的占用。
需要增长时,运行时通常从原大小的两倍开始,必要时继续扩大,保证新的栈帧有空间。随后把仍在使用的栈内容复制过去,并根据指针信息调整指向旧栈内部的引用,再恢复执行。普通 Go 指针仍然能正确引用原来的值,程序无需自己修正这些引用。
下面的递归让每层调用保留一个局部数组,并在更深层调用返回后继续读取它:
package main
import "fmt"
//go:noinline
func walk(n int) int {
var frame [1024]byte
for i := range frame {
frame[i] = byte(n + i)
}
result := 0
if n > 0 {
result = walk(n - 1)
}
return result + int(frame[0]) + int(frame[len(frame)-1])
}
func main() {
done := make(chan int)
go func() { done <- walk(64) }()
fmt.Println(<-done)
}
输出为 4351。在本篇的 Go 1.22.12 验证中,frame 没有逃逸到堆,编译产物保留了递归栈帧,另一个保留相同规模递归帧的独立实验,通过只读运行时观察器确认了栈容量增长。这个输出说明调用结果正确,本身不能测出栈大小;也不能仅凭代码里写了一个数组,就断言每个编译器都会保留完全相同的栈帧。
4.2 缩小、移动与限制
栈可以缩小,但不会在每次函数返回时立刻按使用量缩小。Go 1.22.12 会在 GC 等运行时路径中,确认栈使用量较低且可以安全移动后尝试缩小;它还要考虑最小栈、当前执行状态和调试开关。
增长也有上限。无限递归并不会因为栈可增长就变得安全,超过运行时限制仍会发生栈溢出。一个 goroutine 的总成本还包括 G 对象、它引用的堆对象和其他运行时状态,不能用 goroutine 数量乘以 2 KiB 来估算整个程序的内存。
由于栈可能移动,把指针转成 uintptr 后长期保存,不能当作保存了一个能被运行时追踪的 Go 指针。正常的类型安全代码应保留真正的指针;不要为了规避逃逸或追求地址不变而绕过编译器和 GC 的规则。
5. 分配次数与内存占用
分配优化要同时看创建了多少对象、这些对象存活多久,以及它们占用了多大的底层存储。只让一个指标下降,未必能让程序更省内存。
- 高频创建短命堆对象,会增加分配和 GC 的工作;可以先检查是否存在无必要的中间对象。
- 一个小切片也可能一直引用很大的底层数组。减少表面上的元素数量,不代表释放了底层存储,见 Go 切片。
- 复用缓冲区可以减少重复分配,但保留过大的容量也会增加存活内存。
sync.Pool还允许运行时移除缓存项,不能保证每次复用成功,见 sync.Pool。
runtime.MemStats 的 TotalAlloc 是累计堆分配字节数,HeapAlloc 描述当前已分配堆对象的字节数,其中也可能有尚未被清扫的不可达对象。它们都不是进程 RSS:栈、运行时元数据、空闲但尚未归还操作系统的页等,不能只靠“活着的业务对象”解释。操作系统层面的指标见 虚拟内存。
所以,观察到一次 GC 后 RSS 没有立刻下降,不能直接证明内存泄漏;观察到局部代码没有堆分配,也不能直接证明它耗时更短。先确认对象引用和实际指标,再判断是存活对象、容量保留、分配压力,还是其他内存来源。
6 · 面试题卡片
本页题目与参考回答
6.1 Go 中的变量如何分配在栈或堆上?new 和 make 能决定吗?
考察重点:变量作用域、对象生命周期与存储位置的区别;创建语法和编译器分析的分工。
相关内容:栈、堆与变量
参考回答
不能仅凭 new、make 或“局部变量”判断栈堆归属,要看编译器对对象生命周期、引用流向和空间需求的分析。
不需要在当前调用之外存活且满足其他栈分配条件的对象,可能留在栈上,也可能被优化到寄存器或消除。需要独立生命周期的对象通常放在堆上,由 GC 管理。返回局部变量指针是合法的,编译器必须保证引用有效;内联等优化也会影响最终分配。
new(T) 返回指向零值 T 的指针,make 初始化切片、map 或 channel,两者都不是指定栈或堆的开关。切片描述信息、底层数组、包级指针变量和它指向的对象,也要分别判断。
6.2 Go 的堆分配器如何使用 mcache、mcentral 和 mheap?
考察重点:按 P 缓存、按类别补充 span、全局页管理之间的关系;小对象与大对象的分配路径。
相关内容:堆分配器
参考回答
小对象通常从当前 P 的 mcache 取可用槽位,空间不足时通过 mcentral 补充同类 span,再由 mheap 提供页。 大对象直接按页向 mheap 分配独占的 span,不走普通小对象槽位缓存。
尺寸类别把对象放进适合的槽位,spanClass 还区分是否需要扫描指针。按 P 缓存减少了常见路径对共享分配状态的竞争,但补充 span、清扫、清零和 GC 记账仍有成本。
mheap 可以复用自己管理的空闲页,必要时才继续向操作系统申请,所以不能把每次堆对象分配都理解为一次系统调用。
6.3 申请 17 字节的对象,为什么可能占用 24 字节?
考察重点:尺寸类别、内部碎片和极小对象分配条件;逻辑大小与实际分配空间的区别。
相关内容:页、span 与尺寸类别、极小对象与大对象
参考回答
小对象按尺寸类别分配,17 字节的无指针对象在 Go 1.22.12 中使用 24 字节槽位。 向上取整让分配和复用更简单,剩余空间则形成槽位内部的浪费。
不能据此把所有对象都按同一公式计算。小于 16 字节的无指针对象可以参与 tiny 块合并;需要扫描指针的对象还涉及扫描信息和部分对象的分配头;大对象按运行时页取整。源码中的对象大小、槽位大小、span 大小和进程内存指标不是同一个概念。
6.4 goroutine 的栈为什么可以较小?能一直增长吗?
考察重点:小栈起步、检查与复制增长、引用调整、缩小条件,以及初始大小与总内存的区别。
相关内容:goroutine 栈
参考回答
普通 goroutine 的栈可以按需增长,因此不必一开始就为最深的调用链分配足够空间。 空间不足时,运行时分配更大的栈,复制有效内容并调整指向栈内部的引用,再继续执行。
Go 1.22.12 的基础最小栈是 2 KiB,但平台附加空间、栈复用、自适应初始大小和已经发生的调用都会影响实际大小。它不是一个 goroutine 的固定总内存。
满足安全条件和低使用量等要求时,运行时也会尝试缩栈;这不是每次返回都发生的操作。增长有上限,无限递归仍可能导致栈溢出。
7. 参考资料
- Go 1.22.12:mallocgc 与堆分配说明
- Go 1.22.12:尺寸类别
- Go 1.22.12:mcache 与大对象分配
- Go 1.22.12:mcentral
- Go 1.22.12:mheap
- Go 1.22.12:分配头与指针扫描信息
- Go 1.22.12:默认实验配置
- Go 1.22.12:栈增长、缩小与初始大小
- Go 1.22.12:G 创建与栈复用
- Go 1.22.12:逃逸分析说明
- Go 1.22.12:AllocsPerRun
- Go 1.22.12:MemStats
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明