04. mallocgc:分配总入口

1. mallocgc 函数签名与职责

mallocgc 是 Go 运行时内存分配的唯一入口。所有用户态分配------new(T)make([]T, n)、逃逸到堆的局部变量------最终都调到这里。

go 复制代码
//go:linkname mallocgc
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	if doubleCheckMalloc {
		if gcphase == _GCmarktermination {
			throw("mallocgc called with gcphase == _GCmarktermination")
		}
	}

--- malloc.go:1067-1072

参数解读:

参数 含义 例子
size 请求的字节数(用户看到的) new(int) size=8
typ 类型指针(*_type),用于 scan 判定和 GC 扫描 nil 表示无类型信息
needzero 是否需要清零内存 大部分含指针的对象需要 true

go:linkname :此函数通过 //go:linkname 导出给 reflectinternal/runtime 包使用(malloc.go:1066),所以 reflect.unsafe_Newmaps.newarray 等底层 API 也是走这条路。


2. 零大小分配:&zerobase 技巧

go 复制代码
// Short-circuit zero-sized allocation requests.
if size == 0 {
	return unsafe.Pointer(&zerobase)
}

--- malloc.go:1074-1077

零长度分配(如 struct{}[0]int{} 逃逸到堆)不走正常分配路径,而是直接返回一个全局唯一的 "zerobase" 地址

go 复制代码
var zerobase uintptr

--- malloc.go:965

所有零大小分配共享这 8 字节,不占用任何堆空间。GC 也能正确识别------因为 zerobase 不在堆内,GC 不会扫描它。

为什么零大小分配也要有地址? Go 的指针语义要求即使指向空结构体,指针也必须合法(可以比较、可以解引用)。如果没有统一的 zerobase,每个零大小对象都要分配独立内存,会带来大量浪费和 GC 压力。


3. GC Assist:分配前的信用检查

GC 标记期间(gcBlackenEnabled != 0),每次分配都要先检查"信用额度":

go 复制代码
if gcBlackenEnabled != 0 {
	deductAssistCredit(size)
}

--- malloc.go:1115-1117

deductAssistCredit 的逻辑(见后续 24. Mutator Assist 篇):

  • 每个 goroutine 的 gcAssistBytes 记录了"已经标记了多少字节的信用"。
  • 每次分配,按 assistWorkPerByte 换算成需要扫描的工作量,从信用中扣除。
  • 信用不够 阻塞当前 goroutine,帮 GC 扫一些对象再回来分配。

这是 Go 内存分配器最重要的背压机制:分配器不会无限快于 GC。GC 跟不上时,分配会变慢,自动调节到 GC 能跟上的节奏。没有这个机制,GC 标记期间堆会爆炸式增长,触发更多 GC,形成恶性循环。


4. 三种分配路径的分派

mallocgc 的核心就是按大小分派 到三条路径,同时考虑 sizeSpecializedMallocEnabled 实验特性的双实现:

go 复制代码
if sizeSpecializedMallocEnabled {
	if size <= maxSmallSize-gc.MallocHeaderSize {
		if typ == nil || !typ.Pointers() {
			x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
		} else {
			if !needzero {
				throw("objects with pointers must be zeroed")
			}
			if heapBitsInSpan(size) {
				x, elemsize = mallocgcSmallScanNoHeader(size, typ)
			} else {
				x, elemsize = mallocgcSmallScanHeader(size, typ)
			}
		}
	} else {
		x, elemsize = mallocgcLarge(size, typ, needzero)
	}
} else {
	if size <= maxSmallSize-gc.MallocHeaderSize {
		if typ == nil || !typ.Pointers() {
			gp := getg()
			if size < maxTinySize && gp.secret == 0 {
				x, elemsize = mallocgcTiny(size, typ)
			} else {
				x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
			}
		} else {
			...
		}
	} else {
		x, elemsize = mallocgcLarge(size, typ, needzero)
	}
}

--- malloc.go:1122-1164(简化,传统路径分支)

图 4-1:mallocgc 分派逻辑

两条路径的分界

大小条件 分派函数 缓存层级
size == 0 返回 &zerobase
size <= maxSmallSize - 8(≤ 32760) Tiny / Small 路径 mcache mcentral mheap
size > maxSmallSize - 8(≥ 32769) Large 路径 直接 mheap,跳过缓存

注意 maxSmallSize = 32768MallocHeaderSize = 8internal/runtime/gc/malloc.go:17),所以小对象上限是 32760 字节。>32760 的分配走 Large 路径。

传统路径 vs sizeSpecializedMalloc 路径

Go 1.24+ 引入 sizeSpecializedMalloc 实验特性:把每个 size class 的分配路径编译成专用的函数 (如 mallocgcSmallNoscanSC1mallocgcSmallNoscanSC2,见 malloc_generated.go),通过 mallocNoScanTable[size] 函数指针表直接跳转,省去 roundupsize 和查表开销。

传统路径的区别在于:

  • Tiny 分配 只出现在传统路径中(size < maxTinySize && gp.secret == 0malloc.go:1146)。
  • sizeSpecializedMalloc 路径 把 Tiny 路径替换为 mallocgcTinySC2malloc.go:1084),直接走 size class 2 的分配,不做合并。
  • 传统路径的 size < maxTinySize 判断在 sizeSpecializedMalloc 路径中通过 mallocNoScanTable 间接实现(malloc.go:1079-1084)。

本文主要分析传统路径的完整分派,因为它是无实验特性时的默认行为,且与历史文档一致。sizeSpecializedMalloc 路径本质上是传统路径的内联/特化版本。


5. scan vs noscan 的分支决策

分派到 Tiny/Small 路径后,第二个关键决策是对象是否含指针

go 复制代码
if typ == nil || !typ.Pointers() {
	// noscan: 不含指针,GC 不需要扫描
	...
} else {
	// scan: 含指针,GC 需要扫描
	...
}

--- malloc.go:1141-1160(简化)

为什么需要这个分支?

类型 GC 扫描成本 标志位 分配路径
noscan 整个 span 跳过扫描 spanClass 最低位 = 1 mallocgcSmallNoscan / mallocgcTiny
scan 遍历每个对象的指针位图 spanClass 最低位 = 0 mallocgcSmallScanNoHeader / mallocgcSmallScanHeader

GC 扫描时,spanClass.noscan() == true 的 span 直接跳过整个 span ,不检查其中任何一个对象(见 mheap.go:590-592noscan() 方法)。这意味着:

  • spanClass 的编码决定了 GC 能否跳过(参见 03. Size Class 第 6 节)。
  • makeSpanClass(sizeclass, true)(noscan,malloc.go:1385)和 makeSpanClass(sizeclass, false)(scan)的标记一次分配就永久确定,span 内的所有对象共享。

class 2 和 Tiny 的特殊关系

TinySizeClass = 2sizeclasses.go:95),即 16B 的 size class。Tiny 分配器(mallocgcTiny)使用 tinySpanClass = spanClass(2<<1 | 1) = 5mheap.go:577)------即 SC2 的 noscan 版本。这意味着:

  • Tiny 对象只能是无指针的(字符、小数字、bool、小结构体字段)。
  • 如果有指针的对象小于 16B,它不会进入 Tiny 分配器 ,而是走 mallocgcSmallNoscan(传统路径)或 mallocgcSmallScanHeader(sizeSpecialized 路径)。

为什么 Tiny 必须是 noscan? 多个微对象共享同一个 16B 物理块,但每个对象有各自的起始地址和类型。如果其中混入含指针的对象,GC 无法精确知道该 16B 块里哪些字节是指针。Tiny 分配器用 "noscan only" 的约束绕过了这个难题(见 05. Tiny 分配器)。


6. heapBitsInSpan:span 内位图与 MallocHeader 机制

scan 对象的分支进一步拆成两条路,由 heapBitsInSpan 决定:

go 复制代码
if heapBitsInSpan(size) {
	x, elemsize = mallocgcSmallScanNoHeader(size, typ)
} else {
	x, elemsize = mallocgcSmallScanHeader(size, typ)
}

--- malloc.go:1155-1159

heapBitsInSpan 是什么?

go 复制代码
// heapBitsInSpan returns true if the size of an object implies its ptr/scalar
// data is stored at the end of the span, and is accessible via span.heapBits.
//
// Note: this works for both rounded-up sizes (span.elemsize) and unrounded
// type sizes because gc.MinSizeForMallocHeader is guaranteed to be at a size
// class boundary.
func heapBitsInSpan(userSize uintptr) bool {
	return userSize <= gc.MinSizeForMallocHeader
}

--- mbitmap.go:68-80

MinSizeForMallocHeader = goarch.PtrSize * goarch.PtrBitsinternal/runtime/gc/malloc.go:47)。64 位下 = 8 × 64 = 512等号是"exclusive minimum" ------<= 512 返回 true(用 heapBits),> 512 返回 false(用 MallocHeader)。

两种布局选择

对象大小 元数据位置 路径 额外开销
≤ 512B span 尾部集中存储(heapBits) mallocgcSmallScanNoHeader 无 per-object 开销
> 512B 每个对象前 8 字节(MallocHeader) mallocgcSmallScanHeader 每个对象 +8B

heapBits 方案 (≤512B):所有对象的指针位图(typePointers)连续存储在 span 尾部的 heapBits 区域。每个对象不需要额外元数据,GC 通过 span.heapBits 定位位图。

MallocHeader 方案 (>512B):每个对象前面 8 字节放一个类型指针 header。GC 扫描时直接读 header 即可知道对象类型和指针布局。这 8 字节由 roundupsize 在分配时额外预留(msize.go:20-21)。

go 复制代码
// roundupsize 中的 header 逻辑
if !noscan && reqSize > gc.MinSizeForMallocHeader {
	reqSize += gc.MallocHeaderSize
}

--- msize.go:20-21
为什么 512B 是分界点? 64 位下,一个 8192B 的 span 如果全部放 ≤512B 的对象,每个对象用 heapBits 需要 8 字节位图(64 位指针 ÷ 8 位/字节)。span 尾部的 heapBits 区域共 128 字节(8192/512 × 8 = 128)。如果换成 MallocHeader,每个对象 +8B,8192B span 放 16 个 512B 对象 16 × 8 = 128B------开销恰好相等MinSizeForMallocHeader 选在这一点,让两种方案的开销自然衔接(internal/runtime/gc/malloc.go:32-36)。


7. Large 路径:越过缓存,直通 mheap

go 复制代码
x, elemsize = mallocgcLarge(size, typ, needzero)

--- malloc.go:1137, 1162

大于 maxSmallSize - MallocHeaderSize = 32760 的对象走 mallocgcLargemalloc.go:1689)。

大对象分配的特殊性

特征 小对象 大对象
必经 mcache (跳过 mcache/mcentral)
必经 mcentral
必经 mheap 可能(refill 时) (直接调用 c.allocLarge()
span 类型 多对象共享 nelems=1,独占 span
页对齐 不要求(span 内偏移) 整页对齐

mallocgcLarge 的核心流程:

  1. roundupsize 把 size 向上取整到 pageSize 的倍数(msize.go:30-35)。
  2. 调用 c.allocLarge(size, noscan)malloc.go:1705mheap.alloc(npages, spanclass, needzero)
  3. 返回的 span 设置 nelems = 1elemsize = nbytes------整个 span 只装这一个对象。
  4. 如果对象含指针,调用 heapSetTypeLarge 设置类型信息(malloc.go:1777-1780)。
  5. 发布内存屏障后返回(malloc.go:1786publicationBarrier())。

大对象的 GC 影响 :因为 nelems=1,GC 扫描时虽然要处理这个对象,但 span 的 markBits 只有 1 位,位图管理非常轻量。但大对象本身如果有大量指针,scanobject 的扫描成本仍然存在。大对象更关键的影响是不能在 mcache/mcentral 中缓存------每次分配都是 mheap 全局操作,代价较高。


8. 公共尾部:profilealloc 采样与 gcTrigger 检查

所有分配路径(Tiny/Small/Large)最终都会执行一段公共尾部逻辑。以 mallocgcSmallNoscan 为例(malloc.go:1441-1453,同款逻辑出现在其他路径中):

go 复制代码
// 内存采样(Poisson 过程)
c.nextSample -= int64(size)
if c.nextSample < 0 || MemProfileRate != c.memProfRate {
	profilealloc(mp, x, size)
}
mp.mallocing = 0
releasem(mp)

// GC 触发检查
if checkGCTrigger {
	if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
		gcStart(t)
	}
}

--- malloc.go:1441-1452

内存采样:profilealloc

go 复制代码
func profilealloc(mp *m, x unsafe.Pointer, size uintptr) {
	c := getMCache(mp)
	if c == nil {
		throw("profilealloc called without a P or outside bootstrapping")
	}
	c.memProfRate = MemProfileRate
	c.nextSample = nextSample()
	mProf_Malloc(mp, x, size)
}

--- malloc.go:2214-2226

nextSample() 返回一个服从指数分布 的随机值(malloc.go:2235-2245),平均采样间隔 = MemProfileRate 字节(默认 512KB)。profilealloc 重置计数器并调用 mProf_Malloc 记录采样对象。这是 pprof heap 的数据来源。

为什么用指数分布? 泊松采样的优势在于:不会在固定间隔采样(避免与程序周期共振),且统计上无偏------可以通过 采样的总大小 / 采样率 估算真实分配量。

GC 触发检查:gcTrigger

checkGCTrigger == true(仅在 nextFree 触发了 refill 后被设置,malloc.go:1006-1007),检查堆是否达到触发条件:

go 复制代码
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
	gcStart(t)
}

gcTriggerHeap 检查 heapLive >= trigger。如果条件满足,当前 goroutine 会直接进入 gcStart() 参与 GC 启动(见 19. gcStart 篇)。

为什么不每次都检查? 只有 refill 后(即 mcache 从 mcentral 取了新 span)才检查------因为 refillheapLive 已经乐观更新(mcache.go:refill 会调 gcController.addLiveBytes),此时堆状态变化最大,最适合检查触发条件。nextFreeFastnextFreeallocCache 命中的情况没有触发 refill,所以 checkGCTrigger 保持 false,跳过检查。


9. 完整调用链

mallocgc 入口到最终分配,完整的调用链如下(箭头表示"调用关系"):

erlang 复制代码
new(T) / make([]T, n) / 逃逸对象
  └─ mallocgc(size, typ, needzero)                            malloc.go:1067
       ├─ size == 0  &zerobase                               malloc.go:1074
       ├─ deductAssistCredit(size)  [if GC marking]           malloc.go:1115
       │
       ├─ size ≤ 32760 (small) ──────────────────────────────┐
       │    ├─ noscan:                                       │
       │    │    ├─ size < 16B  mallocgcTiny()              │   malloc.go:1204
       │    │    │    └─ nextFreeFast()                       │   malloc.go:969
       │    │    │         └─ nextFree()  refill()           │   malloc.go:996
       │    │    │              └─ mcentral.cacheSpan()       │
       │    │    │                   └─ mcentral.grow()       │
       │    │    │                        └─ mheap.alloc()   │
       │    │    └─ ≥ 16B  mallocgcSmallNoscan()            │   malloc.go:1360
       │    │         └─ (同上 nextFree* 链)                  │
       │    └─ scan:                                         │
       │         ├─ heapBitsInSpan  mallocgcSmallScanNoHeader│  malloc.go:1505
       │         └─ !heapBitsInSpan  mallocgcSmallScanHeader │  malloc.go:1596
       │              └─ (同上 nextFree* 链)                  │
       │                                                      │
       └─ size > 32760 (large)                                │
            └─ mallocgcLarge()                                │   malloc.go:1689
                 └─ c.allocLarge()  mheap.alloc()            │
                      └─ mheap.allocSpan()                    │   mheap.go:1215
                           ├─ P-local pageCache               │
                           ├─ h.pages.alloc()                 │
                           └─ h.grow()  sysAlloc()           │
                                                                
       └─ 公共尾部 (所有路径)                                    │
            ├─ c.nextSample  profilealloc() (采样)            │   malloc.go:2214
            └─ gcTrigger  gcStart() (GC 触发)                 │   mgc.go:733

--- 详见附录 B.1 分配调用链(go-memory-wiki-outline.md:617-633)


10. 小结

  1. mallocgc 是分配的唯一入口 ,所有 new/make/逃逸对象最终都经此函数。零大小分配走 &zerobase 捷径。
  2. GC 标记期间,分配前先扣信用deductAssistCredit),分配器不会快于 GC。
  3. 三条路径按大小分界:<16B(Tiny)、≤32KB(Small)、>32KB(Large),Tiny 和 Small 走 mcache 无锁路径,Large 直接 mheap。
  4. scan/noscan 双维度typ.Pointers() 决定对象是否含指针,影响 GC 扫描成本和 spanClass 编码。
  5. heapBitsInSpan vs MallocHeader:≤512B 对象的指针位图存在 span 尾部,>512B 则每个对象前放 8 字节 header。512B 是两种方案开销相等的自然分界点。
  6. 公共尾部统一处理profilealloc 做泊松内存采样(pprof heap 数据源),gcTrigger 检查是否需要启动 GC------仅在 refill 后检查,避免不必要的开销。

下一步,深入研究 Tiny 分配器的合并策略:05. Tiny 分配器:微对象的合并艺术。


相关推荐
程序员爱钓鱼5 小时前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
名字还没想好☜1 天前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器
赫媒派1 天前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发
小满zs1 天前
Go语言第九章(错误处理)
后端·go
程序员爱钓鱼1 天前
Go 编程实战:指针 Pointer——理解地址、取址与解引用
后端·面试·go
Flynt2 天前
Go 1.27 升了一波,泛型方法和 JSON v2 真香但有个坑
后端·go
用户125758524362 天前
对象存储 URL 为什么别到处拼:后台附件预览要验这一层
后端·go·ai编程
名字还没想好☜2 天前
Go 的 database/sql 连接池实战:SetMaxOpenConns 怎么配、连接泄漏怎么查
数据库·sql·golang·go·数据库连接池
王中阳Go2 天前
杰富瑞实测 8 款 AI Agent,国产千问 95 分登顶:我连夜把项目的 OpenAI 硬编码全拆了
人工智能·go