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导出给reflect和internal/runtime包使用(malloc.go:1066),所以reflect.unsafe_New和maps.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 = 32768,MallocHeaderSize = 8(internal/runtime/gc/malloc.go:17),所以小对象上限是 32760 字节。>32760 的分配走 Large 路径。
传统路径 vs sizeSpecializedMalloc 路径
Go 1.24+ 引入 sizeSpecializedMalloc 实验特性:把每个 size class 的分配路径编译成专用的函数 (如 mallocgcSmallNoscanSC1、mallocgcSmallNoscanSC2,见 malloc_generated.go),通过 mallocNoScanTable[size] 函数指针表直接跳转,省去 roundupsize 和查表开销。
传统路径的区别在于:
- Tiny 分配 只出现在传统路径中(
size < maxTinySize && gp.secret == 0,malloc.go:1146)。 - sizeSpecializedMalloc 路径 把 Tiny 路径替换为
mallocgcTinySC2(malloc.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-592 的 noscan() 方法)。这意味着:
spanClass的编码决定了 GC 能否跳过(参见 03. Size Class 第 6 节)。makeSpanClass(sizeclass, true)(noscan,malloc.go:1385)和makeSpanClass(sizeclass, false)(scan)的标记一次分配就永久确定,span 内的所有对象共享。
class 2 和 Tiny 的特殊关系
TinySizeClass = 2(sizeclasses.go:95),即 16B 的 size class。Tiny 分配器(mallocgcTiny)使用 tinySpanClass = spanClass(2<<1 | 1) = 5(mheap.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.PtrBits(internal/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 的对象走 mallocgcLarge(malloc.go:1689)。
大对象分配的特殊性
| 特征 | 小对象 | 大对象 |
|---|---|---|
| 必经 mcache | 是 | 否(跳过 mcache/mcentral) |
| 必经 mcentral | 是 | 否 |
| 必经 mheap | 可能(refill 时) | 是 (直接调用 c.allocLarge()) |
| span 类型 | 多对象共享 | nelems=1,独占 span |
| 页对齐 | 不要求(span 内偏移) | 整页对齐 |
mallocgcLarge 的核心流程:
roundupsize把 size 向上取整到 pageSize 的倍数(msize.go:30-35)。- 调用
c.allocLarge(size, noscan)(malloc.go:1705)mheap.alloc(npages, spanclass, needzero)。 - 返回的 span 设置
nelems = 1、elemsize = nbytes------整个 span 只装这一个对象。 - 如果对象含指针,调用
heapSetTypeLarge设置类型信息(malloc.go:1777-1780)。 - 发布内存屏障后返回(
malloc.go:1786的publicationBarrier())。
大对象的 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)才检查------因为refill时heapLive已经乐观更新(mcache.go:refill会调gcController.addLiveBytes),此时堆状态变化最大,最适合检查触发条件。nextFreeFast和nextFree中allocCache命中的情况没有触发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. 小结
mallocgc是分配的唯一入口 ,所有new/make/逃逸对象最终都经此函数。零大小分配走&zerobase捷径。- GC 标记期间,分配前先扣信用 (
deductAssistCredit),分配器不会快于 GC。 - 三条路径按大小分界:<16B(Tiny)、≤32KB(Small)、>32KB(Large),Tiny 和 Small 走 mcache 无锁路径,Large 直接 mheap。
- scan/noscan 双维度 :
typ.Pointers()决定对象是否含指针,影响 GC 扫描成本和 spanClass 编码。 - heapBitsInSpan vs MallocHeader:≤512B 对象的指针位图存在 span 尾部,>512B 则每个对象前放 8 字节 header。512B 是两种方案开销相等的自然分界点。
- 公共尾部统一处理 :
profilealloc做泊松内存采样(pprof heap数据源),gcTrigger检查是否需要启动 GC------仅在refill后检查,避免不必要的开销。
下一步,深入研究 Tiny 分配器的合并策略:05. Tiny 分配器:微对象的合并艺术。