03. Size Class 分级机制

55 阅读18分钟

8 字节到 32KB 的对象,只需要 67 个"规格"就能几乎零浪费地分配——本文拆解 size class 的约束、查表算法与生成器


版本说明:基于 Go ~1.24+ 源码,含 GreenTeaGC / size-specialized malloc 等实验特性


1. 为什么需要分级?

想象一个仓库要存放大小不一、几乎无限多种尺寸的货物。最朴素的方案是每块地都按最大尺寸划分——但这会浪费大量空间(小货物占大格子);另一个极端是精确切割——但每次都去精确搜索合适的内存块,分配会变得很慢。

Go 的折中方案:只提供 67 种"标准尺寸"(size class),把任意请求向上取整到最近的规格。

// 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

— sizeclasses.go:6-9

例如申请 20 字节,会被向上取整到 24 字节(class 3)的槽位。虽然多花了 4 字节,但换来的是:

收益说明
分配 O(1)每种大小有独立的空闲链表,直接取头,不用精确搜索
碎片可控浪费率被生成器限制在 ~12.5% 以内(见下一节)
对象遍历span 内对象等大小,地址 = base + index * size,可用魔法数除法直接算出下标
GC 友好GC 扫描时能精确计算对象边界,指针位图按固定粒度布局

一句话理解:size class = 用「少量规格 + 向上取整」换取「O(1) 分配 + 可预测的浪费」,是 TCMalloc 家族分配器的共同基石。


2. 67 个 size class 的设计约束

size class 不是随便拍的,mksizeclasses.go 的开头注释写明了三条设计约束:

// The size classes are chosen so that rounding an allocation
// request up to the next size class wastes at most 12.5% (1.125x).
//
// Each size class has its own page count that gets allocated
// and chopped up when new objects of the size class are needed.
// That page count is chosen so that chopping up the run of
// pages into objects of the given size wastes at most 12.5% (1.125x)
// of the memory. It is not necessary that the cutoff here be
// the same as above.

— mksizeclasses.go:9-17

三条约束翻译成人话:

  1. 取整浪费 ≤ 12.5%:从一个 size class 向上取整到下一个,多花的空间不超过 1.125 倍。例如 class 1(8B)到 class 2(16B)跨度很大,是因为对齐约束压不下来(见下)。
  2. 切页浪费 ≤ 12.5%:每个 size class 有自己的页数(1~10 页/span)。把若干页切成等大小对象时,尾部剩余(tail waste) 也不超过 span 的 1/8。这是决定 span 页数的直接因素。
  3. 两类浪费相乘:理论上最坏情况是 1.125 × 1.125 ≈ 26.6% 开销。但生成器注释指出,实际中两个浪费很少同时出现——< 512B 主要浪费在取整,> 512B 主要浪费在切页。
// The two sources of waste multiply, so the worst possible case
// for the above constraints would be that allocations of some
// size might have a 26.6% (1.266x) overhead.
// In practice, only one of the wastes comes into play for a
// given size (sizes < 512 waste mainly on the round-up,
// sizes > 512 waste mainly on the page chopping).
// For really small sizes, alignment constraints force the
// overhead higher.

— mksizeclasses.go:19-26

注意 87.5% 的"max waste":class 1(8B)的 max waste 高达 87.5%,远超 12.5% 约束。这是因为 8B 对象必须满足 MinHeapAlign = 8 字节对齐(sizeclasses.go:85),跨度无法压到 12.5% 以内。对齐约束在极小尺寸上"凌驾"于浪费约束。

对齐(alignment)策略

对象对齐是生成器隐含的硬约束,最终汇总在表尾:

// alignment  bits  min obj size
//         8     3             8
//        16     4            32
//        32     5           256
//        64     6           512
//       128     7           768
//      4096    12         28672
//      8192    13         32768

— sizeclasses.go:75-82

读法:size class 的对象大小越高,要求的对齐越严格。例如:

  • 8B~16B:8 字节对齐(MinHeapAlign)
  • 达到 32B 起:至少 16 字节对齐(heap bitmap 假设 ≥32B 的分配都 16 字节对齐,mksizeclasses.go:79)
  • 达到 32KB(class 67):8192 字节对齐,即整页对齐

为什么对齐重要? ① 8 字节对齐保证指针字(uintptr)操作天然安全;② GC 的指针位图(heapBits)按 8 字节/位布局,只有对齐的地址才能直接映射到位图下标。对象错位会让位图索引计算变得极其昂贵。


3. 完整 size class 表解读

sizeclasses.go 是工具生成的文件(首行 Code generated by mksizeclasses.go; DO NOT EDIT.),顶部注释就是完整表的唯一权威来源。下表把 1~67 全部列出,按 bytes/span(页数)分组观察规律:

classbytes/objbytes/span页数objectstail wastemax wastemin align
18819211024087.50%8
21681921512043.75%16
32481921341829.24%8
43281921256021.88%32
548819211703231.52%16
66481921128023.44%64
780819211023219.07%16
89681921853215.95%32
911281921731613.56%16
101288192164011.72%128
11144819215612811.82%16
121608192151329.73%32
131768192146969.59%16
1419281921421289.25%64
152088192139808.12%16
1622481921361288.15%32
172408192134326.62%16
18256819213205.86%256
19288819212812812.16%32
20320819212519211.80%64
213528192123969.88%32
2238481921211289.51%128
23416819211928810.71%32
2444881921181288.37%64
254808192117326.82%32
26512819211606.05%512
27576819211412812.33%64
28640819211251215.48%128
29704819211144813.93%64
30768819211051213.94%256
3189681921912815.52%128
321024819218012.40%1024
33115281921712812.41%128
34128081921651215.55%256
3514081638421189614.00%128
36153681921551214.00%512
371792163842925615.57%256
382048819214012.45%2048
392304163842725612.46%256
40268881921312815.59%128
4130722457638012.47%1024
42320016384253846.22%128
43345624576373848.83%128
444096819212015.60%4096
454864245763525616.65%256
465376163842325610.92%256
4761442457634012.48%2048
48652832768451286.23%128
49678440960562564.36%128
50691249152677683.37%256
518192819211015.61%8192
529472573447651214.28%256
53972849152655123.64%512
5410240409605404.99%2048
551088032768431286.24%128
56122882457632011.45%4096
571356840960532569.99%256
5814336573447405.35%2048
59163841638421012.49%8192
60184327372894011.11%2048
611907257344731283.57%128
6220480409605206.87%4096
632176065536832566.25%256
64245762457631011.45%8192
65272648192010312810.00%128
6628672573447204.91%4096
67327683276841012.50%8192

— sizeclasses.go:6-73(注释表)

在这里插入图片描述

图 3-1:前 20 个 size class 示意图(条宽 ∝ 对象大小)

读表要点

  • bytes/span 列的变化:绝大多数 class 用一个 span(8192B = 1 页)。当 1 页装不下、且 2 页能让尾部浪费降到 12.5% 以内时,就换 2 页/3 页……最多 10 页(class 65,MaxSizeClassNPages = 10,sizeclasses.go:93)。页数绝不跳跃式增长到不需要的大小,这是切页浪费约束的直接体现。
  • objects 列:bytes/span ÷ bytes/obj 向下取整。class 1 一个 span 塞 1024 个对象(MaxObjsPerSpan = 1024,sizeclasses.go:92)——这也定义了 allocCache 是 64 位、需要按 64 个对象分块的原因(见 06 篇)。
  • tail waste 列:span 尾部无法再塞下一个完整对象的剩余字节。class 3(24B × 341 = 8184B,剩 8B)就是典型的"1 页切不干净"。
  • min align 列:即对象大小的最低 2 的幂因子。越大的对象对齐要求越高,呼应第 2 节的对齐策略。

NumSizeClasses = 68 而不是 67? 表里只有 67 个真实 class,但数组长度是 68——下标 0 留空不用(相当于一个"空位",mksizeclasses.go:69),让 SizeClassToSize[0] = 0。这样一来,任何合法的 size class 从 1 开始编号,如果代码因为 bug 拿到 0 号,查表得到的大小是 0,一眼就能发现问题;同时查表时不用再写 if (class == 0) 的边界判断。所以文档常说"67 个 size class",代码里数组却是 68 格——第 0 格是安全垫片。


4. 查表算法:SizeToSizeClass8 / SizeToSizeClass128

拿到一个 size,runtime 必须 O(1) 地找到对应 class。直接线性扫描 67 项显然太慢,于是生成了两张前缀表,用两次索引代替遍历:

var SizeToSizeClass8 = [SmallSizeMax/SmallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, ...}
var SizeToSizeClass128 = [(MaxSmallSize-SmallSizeMax)/LargeSizeDiv + 1]uint8{32, 33, 34, 35, 36, 37, 37, 38, 38, 39, 39, 40, ...}

— sizeclasses.go:101-102

为什么要两张表?

因为 size 跨度太大,单张表按 8 字节粒度建到 32KB 会需要 4096 项。Go 的分段策略是:

表覆盖范围粒度下标公式元素数
SizeToSizeClass80 ~ 1024B(SmallSizeMax)8B(SmallSizeDiv)size / 8129
SizeToSizeClass1281024 ~ 32768B(MaxSmallSize)128B(LargeSizeDiv)(size - 1024) / 128249

下标公式中的常量定义在 sizeclasses.go:86-89。

查表套路(runtime 侧)

分配时的查找分三步(见 malloc.go:1379-1383 的 mallocgcSmallNoscan,同款逻辑在 msize.go 的 roundupsize):

// 1. 先判区间
if size <= gc.SmallSizeMax-8 {
    // 2. 小尺寸:向上取整到 8 的倍数后除 8 得下标
    sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
    // 3. 大尺寸:偏移后按 128 除
    sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])

— malloc.go:1379-1384(简化)

妙处:divRoundUp 免费完成"向上取整"

divRoundUp(a, b) = (a + b - 1) / b。例如请求 100B:

  1. 100 ≤ 1024-8 走 8B 表
  2. divRoundUp(100, 8) = (100+7)/8 = 13 查 SizeToSizeClass8[13] = 9(看看表里第 13 项确实是 9)
  3. SizeClassToSize[9] = 112 返回 112B 槽位

为什么 SmallSizeMax-8 这个边界? 若请求恰好 1024B,divRoundUp(1024,8)=128,而 8B 表只有 129 项(下标 0~128),SizeToSizeClass8[128]=32,仍合法。用 -8 是为了把 1024 整 1 的请求安全推给 128B 表,避免下标越界的同时保持语义一致。

另一张索引表:SizeClassToSize

拿到 class 号后,用 SizeClassToSize[class] 直接读出对齐后的对象大小:

var SizeClassToSize = [NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, ...}

— sizeclasses.go:98

同理还有 SizeClassToNPages(每个 class 的页数)和 SizeClassToDivMagic(32 位魔法数除法,用于把"span 内偏移"换算成"对象下标",见 mksizeclasses.go:136-205 的 computeDivMagic):

var SizeClassToNPages = [NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}

— sizeclasses.go:99

读 SizeClassToNPages 能直观看到页数序列:前 34 个全 1,从 class 35(1408B)开始出现 2,1,2,1,2,1,3,...——正是第 3 节表格中"页数"列的压缩形式。


5. roundupsize():大小对齐的快速路径

roundupsize 把用户请求的 size 变成真正会分配的槽位大小,是 roundupsize 查表 对齐的快速路径:

// Returns size of the memory block that mallocgc will allocate if you ask for the size,
// minus any inline space for metadata.
func roundupsize(size uintptr, noscan bool) (reqSize uintptr) {
	reqSize = size
	if reqSize <= maxSmallSize-gc.MallocHeaderSize {
		// Small object.
		if !noscan && reqSize > gc.MinSizeForMallocHeader { // !noscan && !heapBitsInSpan(reqSize)
			reqSize += gc.MallocHeaderSize
		}
		// (reqSize - size) is either mallocHeaderSize or 0. We need to subtract mallocHeaderSize
		// from the result if we have one, since mallocgc will add it back in.
		if reqSize <= gc.SmallSizeMax-8 {
			return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass8[divRoundUp(reqSize, gc.SmallSizeDiv)]]) - (reqSize - size)
		}
		return uintptr(gc.SizeClassToSize[gc.SizeToSizeClass128[divRoundUp(reqSize-gc.SmallSizeMax, gc.LargeSizeDiv)]]) - (reqSize - size)
	}
	// Large object. Align reqSize up to the next page. Check for overflow.
	reqSize += pageSize - 1
	if reqSize < size {
		return size
	}
	return reqSize &^ (pageSize - 1)
}

— msize.go:14-36

三步解读

  1. scan 对象 > 512B 时额外加 8B:reqSize += gc.MallocHeaderSize。这类对象前面要放一个 MallocHeader(8 字节) 存类型指针(见 04 的 heapBitsInSpan 一节)。但返回值会再减掉这 8 字节,因为调用方(mallocgc)实际拿到的指针会指向 header 之后——大小算对,地址不用重复加。
  2. 小对象查表:SizeToSizeClass8 或 SizeToSizeClass128 二选一(见第 4 节),从 SizeClassToSize 读出对齐后大小。
  3. 大对象整页对齐:(size + pageSize - 1) &^ (pageSize - 1) 就是"向上取整到 8KB 倍数",溢出则回退原 size(msize.go:30-35)。

MallocHeaderSize = 8 定义在 internal/runtime/gc/malloc.go:17。取 8 是为了保证 32 位平台上也满足 8 字节对齐(8 字节原子操作需要)。MinSizeForMallocHeader = PtrSize * PtrBits(64 位下 = 512)定义在同文件 internal/runtime/gc/malloc.go:47——小于等于 512B 的 scan 对象,指针位图放在 span 尾部(heapBits),不需要 header。

为什么大对象直接整页对齐,而不是查表? 大对象(>32KB)通常一次分配就独占整页,没有"槽位复用"的概念,所以不需要 size class 表。整页对齐既能满足 mmap/页映射的硬件要求,也让页管理(pageAlloc)用最简单的方式记账。


6. spanClass:scan / noscan 双维度

光有 size class 还不够——同样 64 字节的对象,含指针(GC 要扫)和不含指针(GC 直接跳过)在 GC 开销上天差地别。于是 runtime 在 size class 之上叠了一个维度,得到 spanClass:

// A spanClass represents the size class and noscan-ness of a span.
//
// Each size class has a noscan spanClass and a scan spanClass. The
// noscan spanClass contains only noscan objects, which do not contain
// pointers and thus do not need to be scanned by the garbage
// collector.
type spanClass uint8

const (
	numSpanClasses = gc.NumSizeClasses << 1
	tinySpanClass  = spanClass(tinySizeClass<<1 | 1)
)

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

//go:nosplit
func (sc spanClass) sizeclass() int8 {
	return int8(sc >> 1)
}

//go:nosplit
func (sc spanClass) noscan() bool {
	return sc&1 != 0
}

— mheap.go:567-592

编码规则

  • 一个 uint8 存两个信息:高 7 位 = size class 号,最低位 = noscan 标志。
  • makeSpanClass(sizeclass, noscan) = sizeclass << 1 | noscan(mheap.go:580-582)。
  • 取出时 sizeclass() = sc >> 1、noscan() = sc & 1(mheap.go:585-592)。
  • 总数 numSpanClasses = 68 << 1 = 136(mheap.go:576)。mcache.alloc [numSpanClasses]*mspan 就是按这 136 个下标索引的(见 06)。
  • tinySpanClass = spanClass(2<<1 | 1) = 5:Tiny 分配器专用的 span,对应 size class 2(16B)+ noscan(mheap.go:577)。

为什么 span 要区分 scan/noscan? 一个 span 内的所有对象共享同一套元数据(指针位图/分配位图)。如果 scan 和 noscan 对象混在一个 span 里,GC 就必须按对象逐个判断,位图布局也无法统一。拆成两套 spanClass,GC 扫到 noscan span 直接跳过整个 span,这是分配侧最便宜的优化之一。

spanClass 值含义
0 ~ 133常规 spanClass:sizeclass << 1(scan)或 +1(noscan)
5tinySpanClass(SC2noscan),Tiny 分配器专用

spanClass 会随 size class 一起被 mcache 索引,所以 04 的分配路径里,拿到 class 号后第一件事就是 spc := makeSpanClass(sizeclass, true)(noscan 分配,见 malloc.go:1385)。


7. 为什么是 67 个 class?mksizeclasses.go 生成算法

sizeclasses.go 由 mksizeclasses.go 生成(//go:generate go -C ../../../runtime/_mkmalloc run mksizeclasses.go,见 sizeclasses.go:2)。makeClasses() 是核心(mksizeclasses.go:66-134),它做三件事:

第一步:遍历所有候选尺寸,选 class(mksizeclasses.go:71-105)

align := minHeapAlign
for size := align; size <= maxSmallSize; size += align {
	if powerOfTwo(size) { // bump alignment once in a while
		if size >= 2048 {
			align = 256
		} else if size >= 128 {
			align = size / 8
		} else if size >= 32 {
			align = 16 // heap bitmaps assume 16 byte alignment for allocations >= 32 bytes.
		}
	}
	// Make the allocnpages big enough that
	// the leftover is less than 1/8 of the total,
	// so wasted space is at most 12.5%.
	allocsize := pageSize
	for allocsize%size > allocsize/8 {
		allocsize += pageSize
	}
	npages := allocsize / pageSize
	...
}

— mksizeclasses.go:71-93

逻辑链:

  1. 从 align=8 开始,每 8 字节试一个候选尺寸。
  2. 碰到 2 的幂就提高对齐步长:>=2048 后步长升到 256;>=128 后升到 size/8;>=32 后升到 16(mksizeclasses.go:73-80)。这就是为什么 class 越小越密、越大越疏。
  3. 为候选尺寸选页数:从 1 页开始,只要 allocsize % size > allocsize/8(尾部浪费 > 12.5%),就加一页再试(mksizeclasses.go:89-92)。这是"切页浪费 ≤ 12.5%"约束的直接落地。
  4. 合并等价 class:如果新候选和上一个 class 页数相同、且每页装的整数对象数也相同,就把上一个 class 的尺寸改成更大的那个,去掉冗余(mksizeclasses.go:100-103)。

第二步:把能塞进同页数的对象"加大"(mksizeclasses.go:107-123)

// Increase object sizes if we can fit the same number of larger objects
// into the same number of pages. For example, we choose size 8448 above
// with 6 objects in 7 pages. But we can well use object size 9472,
// which is also 6 objects in 7 pages but +1024 bytes (+12.12%).
for i := range classes {
	...
	c := &classes[i]
	psize := c.npages * pageSize
	new_size := (psize / (psize / c.size)) &^ (largeSizeDiv - 1)
	if new_size > c.size {
		c.size = new_size
	}
}

— mksizeclasses.go:107-123

核心洞察:既然某些尺寸用 7 页只能装 6 个对象,那为什么不把对象做大一点、同样装 6 个,把尾部的页浪费变成"对象更大带来的取整收益"?示例注释里的 8448 9472 就是这个思路。这也是为什么 class 52 有 9472B 这种"奇怪"的尺寸(对应 SizeClassToSize[52])。

这步对 SizeToSizeClass128 尤其重要:largeSizeDiv = 128 保证所有 class 尺寸是 128 的倍数,查表时按 128 分段的 SizeToSizeClass128 才能精确命中。

第三步:校验 class 总数并计算魔法数(mksizeclasses.go:125-131)

if len(classes) != 68 {
	panic("number of size classes has changed")
}
for i := range classes {
	computeDivMagic(&classes[i])
}

— mksizeclasses.go:125-131

  • len(classes) != 68 直接 panic:67 个真实 class + 1 个 dummy 是写死的契约。sizeclasses.go:90 的 NumSizeClasses = 68 与 mheap.go:576 的 numSpanClasses = 68 << 1 都依赖它,改任何一个都会连锁破坏 mcache/位图布局。
  • computeDivMagic 为每个 class 算出一个 32 位魔法乘法倒数,把"span 内偏移 对象下标"的除法换成乘法+移位(mksizeclasses.go:136-205)。这是 06 篇 nextFreeIndex/位图下标计算性能的关键。

生成器常量与源码常量一一对应

printClasses(mksizeclasses.go:266-335)把生成器内部的 minHeapAlign/maxSmallSize/smallSizeDiv/... 原样写成 sizeclasses.go:84-96 的常量块。两边的值完全一致:

源码常量值含义
MinHeapAlign8最小对象对齐
MaxSmallSize32768小对象上限(> 此值走 Large 路径)
SmallSizeDiv8小尺寸查表粒度(0~1024B)
SmallSizeMax10248B 粒度查表的上界
LargeSizeDiv128大尺寸查表粒度(1024~32KB)
NumSizeClasses68class 总数(含 dummy 0)
PageShift13页大小 = 2^13 = 8192B
MaxObjsPerSpan1024单个 span 最多对象数(class 1)
MaxSizeClassNPages10单 span 最多页数(class 65)
TinySize16Tiny 分配器块大小
TinySizeClass2Tiny 块对应的 size class

— sizeclasses.go:84-96


8. 小结

  1. size class = 67 个标准规格 + 向上取整,用可预测的碎片换取 O(1) 分配,源自 TCMalloc 设计。
  2. 两条 12.5% 约束(取整浪费 + 切页浪费)决定 class 取值与 span 页数;对齐约束在极小尺寸上凌驾其上(class 1 达 87.5%)。
  3. 两张查表:SizeToSizeClass8(01024B,8B 粒度)和 SizeToSizeClass128(1024B32KB,128B 粒度),divRoundUp 让"向上取整 + 查表"一步完成。
  4. roundupsize 是小对象查表、大对象整页对齐的统一入口;scan 对象 >512B 时额外预算 8B 的 MallocHeader。
  5. spanClass = sizeclass << 1 | noscan 叠加 scan/noscan 维度,136 个 spanClass 驱动 mcache 索引与 GC 跳过。
  6. 67 = 生成器产物:遍历候选尺寸 + 页数最小化 + 等价合并 + 对象加大,最后由 len(classes) != 68 强约束。

下一步,从 mallocgc 入口看这些 class 如何被分派到 Tiny / Small / Large 三条路径:04. mallocgc:分配总入口。