主题
Go 内存分配:一次申请怎样变成可用空间
在 Go 里写 new(T),并不表示每次都向操作系统申请一块内存;对象不再使用,也不表示进程的 RSS 会马上下降。中间隔着编译器的分配决策、运行时的对象分配器、垃圾回收和向操作系统归还内存的过程。
这篇文章沿着一次申请往下走,解释 mcache / mcentral / mheap、size class、tiny 分配器和 goroutine 栈。读完应该能区分三个问题:程序申请了多少对象,运行时拿了多少空间,以及操作系统当前让多少页驻留在内存里。以下实现固定到 Go 1.27.2、Linux/amd64;阈值、内部结构和快路径都不是 Go 语言的永久承诺。[1][2]
先分层,别把几种“内存大小”相加
| 层次 | 在回答什么 | 常见观察值 | 容易混淆的地方 |
|---|---|---|---|
| Go 值 | 这个值本身有多大? | unsafe.Sizeof | slice、string 等只计算描述符,不含它引用的数据 |
| 堆对象 | 目前还有多少尚未释放的对象字节? | HeapAlloc | 包括可达对象,也可能包括尚未回收的垃圾 |
| span / 运行时 | 为对象、栈和管理结构准备了多少空间? | HeapInuse、StackInuse、Sys | 有空槽、保留空间和元数据,不能只数业务对象 |
| 进程虚拟地址空间 | 哪些地址范围已经映射或保留? | 操作系统映射信息 | 有地址不等于对应物理页已全部驻留 |
| 物理驻留 | 进程当前占用哪些驻留页? | RSS 等 OS 指标 | 与 Go 堆不是同一口径,还受共享页、非 Go 内存等影响 |
前三行的统计口径来自运行时文档;虚拟地址与驻留页的区分来自操作系统接口。排查内存时先挑对指标,否则 HeapAlloc 明显下降而 RSS 没动,会被误认成“GC 没运行”。[2:1][3][4]
Go 的栈和堆也是运行时内存中的不同用途,不是“栈不占内存、只有堆才占内存”。编译器能证明值只在当前调用范围使用时,可能把它放在栈上、寄存器里,甚至直接消掉;需要更长生命周期的值,或不适合栈分配的对象,才需要相应的堆路径。new 的语法本身不能决定最后的位置。[5]
从对象到 span:按大小分组是在节省什么
一个 span 是一段连续的运行时页。Go 1.27.2 的这套分配器以 8 KiB 运行时页为单位管理堆空间;这不是在说操作系统页一定也是 8 KiB。用于小对象的 span 会切成等大的槽位,一个 span 服务一种 span class。span class 不仅区分对象尺寸,也区分对象是否包含需要 GC 扫描的指针。[1:1][6][7]
假设每个请求都保存一个 17 字节、不含指针的数据块。若每次申请都要从所有空闲地址中搜索一段正好 17 字节的空间,分配路径会很复杂。按 size class 分组后,17 字节落入 24 字节槽位,分配器从对应 span 的空闲槽中取一个就行。代价是每个槽有一部分暂时用不上,这叫内部碎片。[1:2][6:1]
| 本次关注的请求大小 | 当前对应槽位 | 单个 span 的用途 | 取舍 |
|---|---|---|---|
| 小于 16 字节,且不含指针 | 可能先进入 tiny 合并块 | 多个微小对象共享一个 16 字节块中的不同位置 | 少占整槽,但回收以合并块为单位 |
| 17 字节,无指针 | 24 字节 size class | 管理多个 24 字节槽 | 每个请求浪费 7 字节槽内空间 |
| 33 字节,无指针 | 48 字节 size class | 管理多个 48 字节槽 | 每个请求浪费 15 字节槽内空间 |
| 超出小对象路径的请求 | 按运行时页申请 span | 大对象使用自己的页范围 | 减少通用小对象分类,但仍有页级取整成本 |
size class 表的最大尺寸是 32768 字节。不要因此断言“所有恰好 32 KiB 的请求都走同一条小对象路径”:本版通用 mallocgc 的分支还考虑分配头部,判断使用 maxSmallSize - MallocHeaderSize。带指针对象的元数据和不同编译快路径也影响细节。业务代码通常无需依赖这个临界值;读源码时则要看实际分支,而不只抄文件开头的概括。[1:3][6:2]
mcache、mcentral、mheap 各自省掉哪一步
正在绘制图示…
查看图源代码
flowchart TD
R["一个需要堆空间的申请"] --> K{"小对象路径?"}
K -->|是,确定 span class| C["当前 P 的 mcache<br/>持有可分配的 span"]
C -->|有空槽| O["取槽、必要时清零<br/>建立对象与 GC 元数据"]
C -->|当前 span 满了| N["对应 mcentral<br/>寻找可用 span,必要时清扫"]
N -->|找到 span| C
N -->|需要新 span| H["mheap<br/>按页管理堆空间"]
K -->|大对象| H
H -->|给出页范围| S["构造或交付 span"]
S --> N
S -->|大对象| O
H -->|可用页不足| OS["向操作系统取得更多空间"]
OS --> H
图中省略了若干分配快路径和 GC 协作,但保留了职责关系。mcache 是 每个 P 的缓存,不是每个 goroutine 一份,也不是永远属于某个线程。持有这个 P 的执行线程可以在常见路径上直接使用它。[1:4][8]
| 层 | 管什么 | 解决的问题 | 要付出的成本 |
|---|---|---|---|
mcache | 当前 P 可直接使用的不同 span class 的 span | 大多数小对象申请不必争全局资源 | 每个 P 保有缓存,空闲空间可能分散 |
mcentral | 某个 span class 的 span 集合,并区分可用性与清扫状态 | 在不同 P 之间补给、归还整批槽位 | 补给要访问共享结构,可能触发清扫 |
mheap | 页、span 与堆地址范围 | 管理大块空间,服务大对象和 span 补给 | 管理成本更高,必要时要和 OS 交互 |
这套层级把常见的小申请留在近处,把较贵的操作摊到一整个 span 上。它不意味着每次申请都依次经过三层,更不意味着三个名字都代表一把全局大锁。当前 mcentral 使用 span 集合,内部有自己的同步方式;“取一个对象”与“补充一个 span”不是同样的成本。[1:5][9]
看一眼实际分配:17 字节为什么记成 24 字节
程序先建好用于保留指针的 slice,再记录分配统计,尽量不把这些 slice 的开销混入对象循环。随后分别保留一万个 1、17、33 字节数组。以 17 字节这一段为例:
go
small = make([]*[17]byte, count)
runtime.ReadMemStats(&before)
for i := range small {
small[i] = new([17]byte)
}
runtime.ReadMemStats(&after)完整程序本次输出:
text
1 byte: objects=10000 bytes=10000
class=16 count=625
17 bytes: objects=10000 bytes=240000
class=24 count=10000
33 bytes: objects=10000 bytes=480000
class=48 count=10000objects 是 MemStats.Mallocs 的增量,bytes 是 TotalAlloc 的增量,class 行来自 BySize。17 字节对象消耗了 10000 × 24 字节,33 字节对象消耗了 10000 × 48 字节,正好对应上面的 size class。这个程序关闭了由 GOGC 百分比触发的自动回收,所有数组也都保留着;它是分配计数实验,不是吞吐基准。[2:2][6:3][10]
这些都是整个进程的计数器,不是给某条 new 单独记账。其他运行时分配、开始测量时 tiny 块已经用了多少空间,以及执行期间切换 P,都可能让另一轮的精确增量不同。这里的 625、240000、480000 是本次小规模对照的观察结果,不是每条语句必然产生的计数契约。[2:3][11]
1 字节那组更有意思。它仍然是语言层面相互独立的一万个数组,但这次分配器只申请了 625 个 16 字节 tiny 块,合计一万字节。独立对象不必各占一个 size class 槽。Mallocs 与 BySize.Mallocs 的统计方式也不同:运行时专门把 tiny 子分配补入总分配计数,不能要求两列简单一一对应。[11:1][2:4]
tiny 合并只适用于无指针的小对象。若把含 Go 指针的对象也随意塞到同一个小块里,就会复杂化扫描与回收。当前 tiny 块要等其中所有仍存活的子对象都不再需要时才能整体回收,所以“只留一个很小的对象”也可能暂时留住其所在块。它用这个有限的空间代价换取更少的小块分配。[11:2]
Go 1.27 的尺寸特化分配:层级还在,入口变了
旧文章常把所有堆对象的路径画成“编译器调用 newobject,再到 mallocgc”。这能解释通用路径,却不能完整描述当前生成的机器码。Go 1.27 默认增加了尺寸特化分配:编译器知道某些小对象的尺寸和扫描属性时,可以直接调用更专门的分配函数,减少运行时重复判断。[12][13]
对上面这个程序执行 go build -gcflags='-S',本次汇编输出中实际出现:
text
CALL runtime.mallocgcTinySC2(SB)
CALL runtime.mallocgcSmallNoScanSC3(SB)
CALL runtime.mallocgcSmallNoScanSC5(SB)它们分别出现在分配 [1]byte、[17]byte、[33]byte 的循环。SC 对应当前尺寸类别;这些函数仍然使用缓存、span 和慢路径,并没有绕开整套堆管理。运行时还会按 race、sanitizer 等构建条件选择可用路径,不能拿普通构建的汇编去描述 race 构建。[13:1]
这里验证的是“生成了什么调用”,没有做 Go 1.26 对照,也没有测出自己的百分比提速。发布说明给出的收益范围属于官方评估,不能直接移植到这个程序或业务服务。
goroutine 栈为什么既能长大,也可能缩小
Go 的普通 goroutine 用户栈从较小的空间开始。函数进入时的栈检查发现不够用,运行时会安排更大的栈、复制仍在使用的内容,并调整相关指针。这使得大量只使用浅层调用的 goroutine 不必一开始都占一块很大的固定栈;代价是在增长时要搬运栈和处理指针。[14][15]
正在绘制图示…
查看图源代码
flowchart LR
A["调用更深的函数"] --> B{"现有栈是否足够?"}
B -->|足够| C["继续执行"]
B -->|不足| D["申请更大栈<br/>复制内容、调整指针"]
D --> C
C --> E["深层调用返回<br/>实际使用量减少"]
E --> F{"后续安全时机满足缩栈条件?"}
F -->|是| G["复制到较小的栈"]
F -->|否| H["暂时保留现有栈"]
“初始 2 KiB”可以作为这个平台的最小栈背景,但不能写成每个新 G 永远分到固定 2 KiB。当前源码会根据 GC 扫描到的栈使用情况调整 startingStackSize;缩栈也要检查安全条件和使用量,不是每次函数返回都执行。[14:1]
文末的递归程序每层保留一个 1024 字节数组,最深处读一次 StackInuse,返回后强制一次 GC 再读。数组在递归返回后仍参与校验,避免把它写成可以直接丢掉的无用局部变量。本次结果:
text
checksum: 32897
StackInuse before/deepest/after GC: 294912 2392064 1441792这个指标统计整个运行时的栈空间,不是当前 G 精确的栈大小;读统计本身和其他运行时 G 也会影响它。这里能看到深调用阶段明显增长、返回并 GC 后下降,但没有回到起点。它支持“栈可以伸缩”的观察,不支持用这些差值计算每层调用恰好用了多少字节。[2:5][14:2]
局部变量声明在函数里,也不意味着它一定属于这条可伸缩栈。若变量逃逸,它可能实际在堆上;栈上只留下引用。这就是栈管理和逃逸分析必须分开理解的原因。[5:1]
对象没用了,为什么 RSS 还在
回收可以分成几步,观察到的指标也会依次变化:
| 阶段 | 发生的事 | 此时还不能推出什么 |
|---|---|---|
| 引用断开 | 从根出发不再能到达对象 | 不能推出对象已经被回收 |
| GC 标记与清扫 | 识别垃圾,让相应槽位可复用 | 不能推出整个 span 都空了 |
| span 中没有存活对象 | 页可以回到运行时的页管理层 | 不能推出页已经归还 OS |
| scavenger 归还适合释放的页 | 运行时尝试减少保留的物理内存资源 | 不能推出 RSS 与 HeapAlloc 严格同步 |
一个 span 里只要还留着存活对象,就可能继续占着整页;已空闲的页也可能先留给后续分配复用。RSS 还包含非 Go 堆来源,并受操作系统释放机制影响。因此排查时至少一起看 HeapAlloc、HeapInuse、HeapReleased 与 OS 指标,必要时再看堆 profile。[16][17][2:6][4:1]
debug.FreeOSMemory 会强制 GC,并尝试尽量向 OS 归还内存;后台任务平时也会做归还。它适合特定诊断或明确的生命周期节点,不应未经测量就在每个请求结束后调用。后者会把集中回收和重新申请的成本加回热路径;这个取舍是由调用行为推得的工程建议,不是本文做过的性能对照。[18]
当内存表现不对时,先回答“是哪一层没降”。对象仍可达,要找谁持有引用;对象已释放而 span 仍占用,要看布局和碎片;Go 指标已下降但 RSS 仍高,则继续查 OS 与非 Go 来源。调一个分配器阈值通常不是第一步。
依据与验证边界
本次运行了普通构建、分配计数、递归栈观察、汇编输出,以及文末测试、race 和 vet。分配统计与栈值是该进程的一次观察;没有测生产 RSS,没有模拟内存压力,也没有比较历史版本或 NUMA。race 构建用于功能检查,正文分配数字来自普通构建。
完整代码与复现
将下面文件按名称放好。所有示例仅使用标准库。先确认工具链为 Go 1.27.2;统计值尤其是栈空间不要求每次相等。
go.mod
go.mod
module example.com/runtime/allocation
go 1.27main.go
代码块 · 50 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
const count = 10000
var tiny []*[1]byte
var small []*[17]byte
var next []*[33]byte
func report(label string, a, b runtime.MemStats) {
fmt.Printf("%s: objects=%d bytes=%d\n", label, b.Mallocs-a.Mallocs, b.TotalAlloc-a.TotalAlloc)
for i := range a.BySize {
if n := b.BySize[i].Mallocs - a.BySize[i].Mallocs; n > 0 {
fmt.Printf(" class=%d count=%d\n", b.BySize[i].Size, n)
}
}
}
func main() {
old := debug.SetGCPercent(-1)
defer debug.SetGCPercent(old)
tiny = make([]*[1]byte, count)
small = make([]*[17]byte, count)
next = make([]*[33]byte, count)
var a, b, c, d runtime.MemStats
runtime.ReadMemStats(&a)
for i := range tiny {
tiny[i] = new([1]byte)
}
runtime.ReadMemStats(&b)
for i := range small {
small[i] = new([17]byte)
}
runtime.ReadMemStats(&c)
for i := range next {
next[i] = new([33]byte)
}
runtime.ReadMemStats(&d)
report("1 byte", a, b)
report("17 bytes", b, c)
report("33 bytes", c, d)
runtime.KeepAlive(tiny)
runtime.KeepAlive(small)
runtime.KeepAlive(next)
}main_test.go
go
package main
import "testing"
func TestIndependentObjects(t *testing.T) {
a, b := new([1]byte), new([1]byte)
a[0] = 9
if b[0] != 0 || a == b {
t.Fatal("distinct nonzero-sized objects must not alias")
}
}stack/main.go
代码块 · 42 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"runtime"
)
var deepest uint64
//go:noinline
func checksum(p *[1024]byte) int {
n := 0
for _, v := range p {
n += int(v)
}
return n
}
//go:noinline
func descend(depth int) int {
var frame [1024]byte
frame[0] = byte(depth)
frame[1023] = 1
n := 0
if depth > 0 {
n = descend(depth - 1)
} else {
var m runtime.MemStats
runtime.ReadMemStats(&m)
deepest = m.StackInuse
}
return n + checksum(&frame)
}
func main() {
var a, b runtime.MemStats
runtime.ReadMemStats(&a)
result := descend(256)
runtime.GC()
runtime.ReadMemStats(&b)
fmt.Println("checksum:", result)
fmt.Println("StackInuse before/deepest/after GC:", a.StackInuse, deepest, b.StackInuse)
}执行命令
bash
go version
go build -o allocation .
./allocation
go run ./stack
go test -count=1 -v ./...
go test -count=1 -race -v ./...
go vet ./...
go build -gcflags='-S' -o allocation-asm . 2> assembly.txt
grep 'CALL.*mallocgc' assembly.txtGo 1.27.2 runtime/malloc.go,分配层级、
mallocgc、小对象/大对象分支。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎Go 1.27.2 runtime/mstats.go,
MemStats字段定义及 tiny 计数汇总。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎Linux 内核文档:proc 文件系统,
status、statm、smaps中虚拟空间、驻留与映射指标的含义。 ↩︎ ↩︎Go 1.27.2 编译器逃逸分析;HeapAllocReason,生命周期、大小和对齐限制。 ↩︎ ↩︎
Go 1.27.2 internal/runtime/gc/sizeclasses.go,尺寸类别、页尺寸与 tiny 常量。 ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/mheap.go,
mspan、spanClass与扫描属性。 ↩︎Go 1.27.2 runtime/mcache.go,per-P 缓存。 ↩︎
Go 1.27.2 runtime/mcentral.go,span 集合与
cacheSpan。 ↩︎Go 1.27.2 debug.SetGCPercent,负值关闭百分比触发,但软内存限制仍可触发 GC。 ↩︎
Go 1.27.2 malloc.go:mallocgcTiny,合并、无指针条件与块生命周期;尺寸特化入口见下一条。 ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/malloc_generated.go,尺寸特化入口与慢路径;malloc.go,
sizeSpecializedMallocEnabled;默认构建配置。 ↩︎ ↩︎Go 1.27.2 runtime/stack.go,
newstack、copystack、shrinkstack、gcComputeStartingStackSize。 ↩︎ ↩︎ ↩︎Go 1.27.2 runtime/mgcsweep.go,对象清扫与 span 回收。 ↩︎
Go 1.27.2 runtime/mgcscavenge.go,向 OS 归还页的工作。 ↩︎