《Go 语言运行时原理》4.1 size class 与 mcache/mcentral/mheap

用 50 万次真实分配测出「请求 33 字节实得 48 字节」这类 size class 圆整,实测每对象字节数逐条对上 runtime 的 size class 表,并把一次小对象分配的三级路径(mcache → mcentral → mheap)钉到 go1.27.0 源码的 mallocgc、nextFree、cacheSpan 上,最后给出按 size class 选对象尺寸的决策表。

4.1 size class 与 mcache/mcentral/mheap

make([]byte, 33) 申请 33 字节,运行时会给你多少?答案不是 33,而是 48。这个多出来的 15 字节不是「浪费」,而是 Go 内存分配器用**固定尺寸档位(size class)**换取的分配速度:只要对象尺寸落在某个档位内,分配就退化成「从一个已经切好的 span 里摘一个空位」——一次指针运算,没有锁、没有搜索、没有元数据查找。

理解这套档位,能直接回答三个工程问题:为什么把结构体从 33 字节压到 32 字节会省掉整档内存;为什么大量 1 字节的小对象反而不占 1 字节;以及什么时候分配会绕过本地缓存、去抢全局锁。

本节要回答:一次小对象分配到底走了哪几级结构、实际占用多少字节?结论是:小对象按 size class 向上圆整(33→48、17→24),1 字节以下的 noscan 小对象走 tiny 分配器被合并进 16 字节块,路径是「每 P 的 mcache 空闲链表 → 全局 mcentral → 页级 mheap」,只有缓存链表耗尽时才需要触碰 mcentral 的锁。 与 /posts/golang/ 既有运行时文章的分工:那几篇讲「分配器是什么」,本节只写增量——实测的圆整数字、1.26 引入、1.27 基线默认开启的 size-specialized malloc 分派表,以及可以直接照做的选型决策表。

4.1.1 实验:一次分配到底占多少字节

复现基线:

  • Go 工具链 go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0)
  • 机器:Apple M1 Pro,10 核,32 GiB(sysctl -n hw.ncpu = 10)
  • 未开启 -race;GOGC 默认(100),GOMAXPROCS 默认(10)
  • 每次测量:先 runtime.GC(),用 runtime.MemStats.TotalAlloc 前后差值除以分配次数
  • 每个尺寸重复分配 500000 次;程序跑 3 遍取稳定值

测量程序的关键点是不要用接口装箱:把 &b[0] 存进预分配的 []unsafe.Pointer,这样每次循环只产生「一个请求 size 字节的堆对象」,外加一个 8 字节的指针槽(可预测、可扣除)。

package main

import (
	"fmt"
	"runtime"
	"unsafe"
)

func measure(n, size int) float64 {
	runtime.GC()
	var before, after runtime.MemStats
	runtime.ReadMemStats(&before)
	hold := make([]unsafe.Pointer, n)
	for i := 0; i < n; i++ {
		b := make([]byte, size)
		b[0] = byte(i)
		hold[i] = unsafe.Pointer(&b[0])
	}
	runtime.ReadMemStats(&after)
	runtime.KeepAlive(hold)
	return float64(after.TotalAlloc-before.TotalAlloc) / float64(n)
}

实测输出(每行「实测总分配B」= size class 尺寸 + 8 字节指针槽):

$ GOTOOLCHAIN=go1.27.0 go run .
请求B    sizeclass  实测总分配B         相对浪费
1      8          9.03           700.0     %
8      8          16.01          0.0       %
9      16         24.01          77.8      %
16     16         24.01          0.0       %
17     24         32.03          41.2      %
24     24         32.01          0.0       %
25     32         40.02          28.0      %
32     32         40.01          0.0       %
33     48         56.01          45.5      %
40     48         56.01          20.0      %
48     48         56.01          0.0       %
49     64         72.01          30.6      %
64     64         72.01          0.0       %
65     80         88.01          23.1      %
96     96         104.01         0.0       %
97     112        120.02         15.5      %
128    128        136.01         0.0       %
129    144        152.01         11.6      %

读这张表有三个必须注意的点:

  1. 圆整是向上取最近档位。请求 33 字节落在 48 档(相对浪费 45.5%),请求 17 字节落在 24 档(41.2%)。档位表不是线性等距的,而是「小对象密、大对象疏」:8、16、24、32、48、64、80、96、112、128……所以尺寸刚好压到档位边界(32、48、64、96、128)时收益最大。
  2. 请求 1 字节实得约 1 字节(9.03 − 8 = 1.03)。这不是 size class 表算错了,而是 tiny 分配器把多个小于 16 字节的 noscan 对象合并进同一个 16 字节块,因此摊到每个对象上接近 1 字节。这也是为什么「大量短字符串」的分配开销远低于直觉。
  3. 8 字节的偏移是测量脚手架(hold 指针槽),不是分配器开销。把它减掉,实测值与 sizeclass 列逐条吻合——这反过来验证了 size class 表就是分配的真实档位。

4.1.2 源码:三级结构与 1.27 的分派表

一次小对象分配的入口在 src/runtime/malloc.go:mallocgc。go1.27.0 在这里走一条按尺寸分派的快路径(goexperiment.SizeSpecializedMalloc 于 1.26 引入、当时默认关闭,1.27 基线默认开启):

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	if size == 0 {
		return unsafe.Pointer(&zerobase)
	}
	if sizeSpecializedMallocEnabled && size < uintptr(len(mallocNoScanTable)) {
		if typ == nil || !typ.Pointers() {
			if size >= maxTinySize {
				return mallocNoScanTable[size](size, typ, needzero)
			}
			return mallocgcTinySC2(size, typ, needzero)
		}
		...
	}

mallocNoScanTable 是一张长度 81 的函数指针表(src/runtime/malloc_tables_generated.go),索引就是请求尺寸——一次数组索引代替一串分支,这是 1.27 相对 1.26 在分配路径上的主要改动(该表在 1.26 中已存在,只是默认关闭)。判定条件写死在 src/runtime/malloc.go:

const sizeSpecializedMallocEnabled = goexperiment.SizeSpecializedMalloc && GOOS != "plan9" &&
	!asanenabled && !raceenabled && !msanenabled && !valgrindenabled

注意 !raceenabled:开 -race 时分派表被禁用,回落到通用路径。

小对象的取用发生在每 P 的 mcache 上。src/runtime/malloc.go:nextFreeFast 用 allocCache 位图找下一个空位,纯位运算、无锁:

func nextFreeFast(s *mspan) gclinkptr {
	theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
	if theBit < 64 {
		result := s.freeindex + uint16(theBit)
		if result < s.nelems {
			freeidx := result + 1
			if freeidx%64 == 0 && freeidx != s.nelems {
				return 0
			}
			s.allocCache >>= uint(theBit + 1)
			s.freeindex = freeidx
			s.allocCount++
			return gclinkptr(uintptr(result)*s.elemsize + s.base())
		}
	}
	return 0
}

allocCache 耗尽后进入 src/runtime/malloc.go:mcache.nextFree,它调用 s.nextFreeIndex();若返回 s.nelems(当前 span 已满),就调用 src/runtime/mcache.go:mcache.refill:

func (c *mcache) refill(spc spanClass) {
	s := c.alloc[spc]
	if s.allocCount != s.nelems {
		throw("refill of span with free space remaining")
	}
	...
	if s != &emptymspan {
		mheap_.central[spc].mcentral.uncacheSpan(s)
		...
	}
	s = mheap_.central[spc].mcentral.cacheSpan()
	if s == nil {
		throw("out of memory")
	}
	...
	c.alloc[spc] = s
}

这一行 mheap_.central[spc].mcentral.cacheSpan() 就是「跨级」发生的地方——它需要拿 mcentral 的锁。src/runtime/mcentral.go:mcentral.cacheSpan 优先从「已清扫的部分空闲 span」取,取不到才清扫,再取不到才向 mheap 要新页:

func (c *mcentral) cacheSpan() *mspan {
	spanBytes := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()]) * pageSize
	deductSweepCredit(spanBytes, 0)
	...
	spanBudget := 100
	sg := mheap_.sweepgen
	if s = c.partialSwept(sg).pop(); s != nil {
		goto havespan
	}
	...

spanBudget := 100 是关键的工程取舍:最多清扫 100 个 span 找空闲位,超了就宁可要一块新页,把「找空闲」的最坏时间摊薄——注释里明说这是为了把空间开销限制在 1% 左右。

mcentral 再往上是 mheap,即页级分配器。它管理的是 8 KiB 的 page(pageSize),src/runtime/mheap.go:mheap.allocSpan 负责切出一整块 span 并登记到 arena 映射里。mcache 与 mcentral 都只是 mheap 之上的「缓存层」,真正的物理内存由 mheap 向操作系统要。这条路径在小对象稳态下几乎不会被触发——这也解释了为什么「分配 1 亿个 32 字节对象」和「分配 100 万个」在 CPU 时间上不是线性关系。

tiny 分配器(src/runtime/malloc.go:mallocgcTiny)的合并策略值得单独看一眼,因为它解释了 4.1.1 里「1 字节对象只占 1 字节」的现象:

	c := getMCache(mp)
	off := c.tinyoffset
	// Align tiny pointer for required (conservative) alignment.
	if size&7 == 0 {
		off = alignUp(off, 8)
	} else if size&3 == 0 {
		off = alignUp(off, 4)
	} else if size&1 == 0 {
		off = alignUp(off, 2)
	}
	if off+size <= maxTinySize && c.tiny != 0 {
		// The object fits into existing tiny block.
		x := unsafe.Pointer(c.tiny + off)
		c.tinyoffset = off + size
		c.tinyAllocs++
		...
	}

maxTinySize = 16(gc.TinySize)。同一个小块里能塞几个对象,取决于尺寸:8 字节对象两个一组,1 字节对象最多 16 个——块内对象越小,摊薄越明显。注释里还给了设计取舍:块大小 16 字节对应「最坏 2 倍浪费」,8 字节「完全无浪费但合并机会少」,32 字节「机会多但最坏 4 倍浪费」。这是 4.1.3 决策表里「含指针小对象不走 tiny」的由来——tiny 块必须是 noscan,否则无法整块回收。

档位表本身在 src/internal/runtime/gc/sizeclasses.go,是 mksizeclasses.go 生成的,注释即表头:

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align
//     1          8        8192     1024           0     87.50%          8
//     2         16        8192      512           0     43.75%         16
//     3         24        8192      341           8     29.24%          8
//     4         32        8192      256           0     21.88%         32
//     5         48        8192      170          32     31.52%         16

max waste 一列正是 4.1.1 那张表里「相对浪费」的理论上界。注意 class 5(48 字节)的 max waste 是 31.52%,而单个对象实测最坏能到 45.5%(请求 33 字节)——max waste 是整块 span 的加权浪费,不是单对象浪费,两者不要混用。

最后,spanClass 把「size class」和「是否含指针」压进一个字节(src/runtime/mheap.go):

type spanClass uint8

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以 mcache 的 alloc[] 数组长度是 NumSizeClasses << 1——同一档位下,含指针对象与纯字节对象分开存放,GC 才能只扫描含指针的那些 span。

4.1.3 决策:按 size class 选对象尺寸

由上面的表与源码,可以落成一张可直接用的选型决策表:

观察到的现象根因(源码位置)决策
对象 33 字节,内存账单按 48 计size class 向上圆整(sizeclasses.go)把结构体压到 ≤32 字节,单对象省 16 字节;100 万实例省 16 MB
尺寸在 32/48/64/96/128 之间「悬空」档位不等距优先把热结构体调到档位边界值,压到边界就是省整档
大量 1–15 字节 noscan 小对象不占 1 字节tiny 分配器合并进 16 字节块(mallocgcTiny)短字符串、小 []byte 放心用;但含指针的小对象不走 tiny(typ.Pointers() 判定)
分配偶发变慢、runtime.mcentral 出现在 profile 里mcache.refill 抢 mcentral 锁(mcentral.cacheSpan)让热路径的分配尺寸集中在少数几个档位,减少 mcentral 争用面
开 -race 后分配变慢sizeSpecializedMallocEnabled 含 !raceenabled性能结论不要用 -race 构建测;race 构建会回落到通用路径
想要 0 分配的热路径mallocgc 对 size == 0 返回 zerobase零大小结构体(如 struct{})不占堆;用 struct{} 当信号量、集合成员

一条经验规则:先测 size class,再改字段顺序。同一个结构体,把字段从「33 字节」重排到「32 字节」带来的内存收益,往往比换一个更省内存的数据结构更直接——因为它跨过了一整个档位。

阅读导航:上一节:3.3 调度相关的性能决策 · 下一节:4.2 逃逸分析与分配决策 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练