Skip to content

Go 内存分配:一次申请怎样变成可用空间 ​

在 Go 里写 new(T),并不表示每次都向操作系统申请一块内存;对象不再使用,也不表示进程的 RSS 会马上下降。中间隔着编译器的分配决策、运行时的对象分配器、垃圾回收和向操作系统归还内存的过程。

这篇文章沿着一次申请往下走,解释 mcache / mcentral / mheap、size class、tiny 分配器和 goroutine 栈。读完应该能区分三个问题:程序申请了多少对象,运行时拿了多少空间,以及操作系统当前让多少页驻留在内存里。以下实现固定到 Go 1.27.2、Linux/amd64;阈值、内部结构和快路径都不是 Go 语言的永久承诺。[1][2]

先分层,别把几种“内存大小”相加 ​

层次在回答什么常见观察值容易混淆的地方
Go 值这个值本身有多大?unsafe.Sizeofslice、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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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=10000

objects 是 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["暂时保留现有栈"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.27

main.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.txt

  1. Go 1.27.2 runtime/malloc.go,分配层级、mallocgc、小对象/大对象分支。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. Go 1.27.2 runtime/mstats.go,MemStats 字段定义及 tiny 计数汇总。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Go 1.27.2 unsafe.Sizeof。 ↩︎

  4. Linux 内核文档:proc 文件系统,status、statm、smaps 中虚拟空间、驻留与映射指标的含义。 ↩︎ ↩︎

  5. Go 1.27.2 编译器逃逸分析;HeapAllocReason,生命周期、大小和对齐限制。 ↩︎ ↩︎

  6. Go 1.27.2 internal/runtime/gc/sizeclasses.go,尺寸类别、页尺寸与 tiny 常量。 ↩︎ ↩︎ ↩︎ ↩︎

  7. Go 1.27.2 runtime/mheap.go,mspan、spanClass 与扫描属性。 ↩︎

  8. Go 1.27.2 runtime/mcache.go,per-P 缓存。 ↩︎

  9. Go 1.27.2 runtime/mcentral.go,span 集合与 cacheSpan。 ↩︎

  10. Go 1.27.2 debug.SetGCPercent,负值关闭百分比触发,但软内存限制仍可触发 GC。 ↩︎

  11. Go 1.27.2 malloc.go:mallocgcTiny,合并、无指针条件与块生命周期;尺寸特化入口见下一条。 ↩︎ ↩︎ ↩︎

  12. Go 1.27 发布说明:Faster memory allocation。 ↩︎

  13. Go 1.27.2 runtime/malloc_generated.go,尺寸特化入口与慢路径;malloc.go,sizeSpecializedMallocEnabled;默认构建配置。 ↩︎ ↩︎

  14. Go 1.27.2 runtime/stack.go,newstack、copystack、shrinkstack、gcComputeStartingStackSize。 ↩︎ ↩︎ ↩︎

  15. Go 1.27.2 runtime/HACKING.md:Stacks。 ↩︎

  16. Go 1.27.2 runtime/mgcsweep.go,对象清扫与 span 回收。 ↩︎

  17. Go 1.27.2 runtime/mgcscavenge.go,向 OS 归还页的工作。 ↩︎

  18. Go 1.27.2 debug.FreeOSMemory。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准