内存分配源码 2:mallocgc 入口与分支
系列阅读:
内存分配->内存分配源码1->内存分配源码2
术语口径:沿用前两篇;本篇额外:noscan/scan、tiny、mallocgcSmall*、mallocgcLarge等见下节「前置」。
这篇写给谁
- 已经跟完头注释与数据结构,想进函数看分配怎么走。
- 想从
mallocgc理清:按尺寸、类型、needzero如何分流,以及 tiny、scan header、大对象各自落在哪条实现上。
一句话总览
mallocgc 是堆分配的公共入口:零长返回 zerobase;再按是否 tiny、是否含指针、是否超过小对象上限,分别进入 mallocgcTiny、各 mallocgcSmall*、mallocgcLarge;读源码时顺着 if 拆下去即可。
前置:结构与术语
P(processor)
Go 调度里的逻辑处理器:每个 P 绑定本地运行队列、mcache 等。代码里说的 per-P、每个 P 都是「这份数据每个 P 各有一份」,目的是常见分配路径无锁或少锁,不用和别的线程抢中心结构。
noscan / scan(是否让 GC 扫对象内部)
- noscan:类型不含堆指针(typ == nil 或 !typ.Pointers()),GC 不需要遍历对象里的字段去找子指针,分配侧可走更简单的路径(如 mallocgcSmallNoscan、tiny)。
- scan:类型含指针,GC 必须扫描对象;分配时要配合堆位图 / 类型头(ScanNoHeader / ScanHeader),且小对象通常强制清零,避免未初始化内存被当成野指针。
tiny
小于 16 字节、且走 noscan 的分配,会进 tiny allocator:在 per-P 的一块 16 字节「微块」里拼多个极小对象,提高利用率。变量名里的 Tiny、注释里的 per-P tiny 都指这个。
zerobase
零长请求的占位地址。所有 0 字节分配都复用同一个指针。语义上:[]T{}、make([]T, 0)、空 struct{} 等落到堆上的「零长」仍要一个合法指针满足 API,但共享地址即可;不要假设它与别的对象相邻或独占一页。若后续代码把该指针当「有多元素」去读写,属于逻辑 bug,与 zerobase 本身无关。
const(
maxSmallSize = gc.MaxSmallSize //32 KiB
)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
// Short-circuit zero-sized allocation requests.
if size == 0 {
return unsafe.Pointer(&zerobase)
}
...
// Actually do the allocation.
var x unsafe.Pointer
var elemsize uintptr
// gc.MallocHeaderSize = 8字节
// size <= 32760
if size <= maxSmallSize-gc.MallocHeaderSize {
if typ == nil || !typ.Pointers() {
// maxTinySize = 16
if size < maxTinySize {
x, elemsize = mallocgcTiny(size, typ)
} else {
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)
}
...
}
讲解
mallocgc 是堆分配的公共入口:size 为字节数,typ 描述类型(是否含指针、是否走 scan),needzero 表示是否必须把新内存清零(noscan 路径会用到;含指针的小对象强制清零)。
-
零长分配:size == 0 时不向堆申请真实块,返回全局 zerobase,所有零长对象可共享该地址。
-
小对象 / 大对象
- 分界条件:size <= maxSmallSize - gc.MallocHeaderSize 即 size <= 32760 时走小对象路径(为可能存在的 header 预留 8 字节余量);size > 32760 走 mallocgcLarge。
- size < 16 → mallocgcTiny:在 per-P 的 tiny 块里把多个极小 noscan 对象拼在一起,减少碎片。
- 16 <= size <= 32760 → mallocgcSmallNoscan:普通小对象 noscan,按 size class 取槽。
- 含指针(typ.Pointers()):必须 needzero,再走 mallocgcSmallScanNoHeader 或 mallocgcSmallScanHeader;由 heapBitsInSpan(size) 判断该尺寸的指针/堆位图能否跟对象一起塞进 span(能则往往无单独 header,否则对象前多 8 字节类型头)。
mallocgcTiny
只处理小于 maxTinySize(16 字节)且 noscan 的请求。 状态在 mcache 里:tiny 是当前「微块」起始地址,tinyoffset 是已用到哪里,tinyAllocs 统计次数。 多个分配拼在同一段 16 字节里;放不下再向 mcache 要 tinySpanClass 的槽(走 nextFreeFast / nextFree,和普通小对象一样只是 class 固定)。
func mallocgcTiny(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
...
mp := acquirem()
...
mp.mallocing = 1
c := getMCache(mp)
off := c.tinyoffset
// 按 size 对齐(8/4/2;32 位上 12 字节特殊对齐到 8,避免首字段 64 位原子访问 fault)
if size&7 == 0 {
off = alignUp(off, 8)
} else if goarch.PtrSize == 4 && size == 12 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
// 快路径:还能塞进当前 16 字节微块
if off+size <= maxTinySize && c.tiny != 0 {
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x, 0 // mallocgc 里对 elemsize==0 有单独分支(如 memprofile)
}
// 慢路径:从 tiny 对应 span 拿一整槽,清零;再决定是否把这块登记为新的「当前微块」
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(tinySpanClass)
}
x := unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0
(*[2]uint64)(x)[1] = 0
if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
c.tiny = uintptr(x)
c.tinyoffset = size
}
publicationBarrier()
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
...
mp.mallocing = 0
releasem(mp)
...
if raceenabled {
x = add(x, span.elemsize-size)
}
return x, span.elemsize
}
要点说明:
-
为什么要 tiny:若每个小于 16 字节的对象都单独占一个 size class 槽,分配次数和碎片都多。拼在 16 字节里,多次分配常合并成一次向 span 要槽;典型受益是小字符串、逃逸的小标量(源码注释里 json 基准约少分配、缩堆)。
-
为何必须 noscan:多个子对象共享同一释放粒度,GC 不能单独回收其中一个,只能整块不可达再回收;若含指针,边界与标记会很复杂,故 tiny 只接不含指针的类型。
-
新槽与 c.tiny:从 span 拿到的是一整槽(常为 16 字节),先整槽写 0。是否把该块登记为新的「当前微块」用剩余空间继续拼,受 race 与 tinyoffset 比较影响(race 开启时不在此更新 tiny,末尾另有把 x 指到槽尾的对齐,便于 checkptr 发现越界)。
mallocgcSmallNoscan、mallocgcSmallScanNoHeader、mallocgcSmallScanHeader
三条都是:按 size class → mcache.alloc → nextFreeFast / nextFree 取槽,再按需清零、写堆位图、publicationBarrier、写屏障与 GC 标记、memprofile、可能 gcStart。
差别:是否 noscan;含指针时 heapBitsInSpan(size) 为真走 mallocgcSmallScanNoHeader(位图跟对象挤在 span 里),为假走 mallocgcSmallScanHeader(槽前单独 8 字节类型指针)。
mallocgcSmallScanHeader
mallocgc 在 typ 含指针、且 heapBitsInSpan(size) 为假时调用。含义是:这一尺寸的 scan 对象无法在 span 内用「内联 heap bits」描述,于是在每个对象前加固定 MallocHeaderSize(8 字节),存 *_type,GC 靠类型元数据扫描;分配器先把「对象尺寸 + 8」算进 size class,再取槽。
func mallocgcSmallScanHeader(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
...
mp := acquirem()
...
mp.mallocing = 1
checkGCTrigger := false
c := getMCache(mp)
size += gc.MallocHeaderSize
// 按「对象 + 8 字节头」总尺寸查 class,再对齐到该档槽尺寸
var sizeclass uint8
if size <= gc.SmallSizeMax-8 {
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])
spc := makeSpanClass(sizeclass, false) // false:带指针的 scan span,不是 noscan
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc)
}
x := unsafe.Pointer(v)
if span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
header := (**_type)(x)
x = add(x, gc.MallocHeaderSize)
c.scanAlloc += heapSetTypeSmallHeader(uintptr(x), size-gc.MallocHeaderSize, typ, header, span)
publicationBarrier()
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
...
mp.mallocing = 0
releasem(mp)
...
return x, size
}
mallocgcLarge
mallocgc 在 size > maxSmallSize - MallocHeaderSize(即大于小对象上限)时调用。不经 mcache 按档切槽,而是 mcache.allocLarge → mheap 按页要连续内存,整段 span 只装这一大对象(独占该 span)。
func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
...
mp := acquirem()
...
mp.mallocing = 1
c := getMCache(mp)
// allocLarge:向 mheap 要页,建 span;第二参数表示是否 noscan 大对象
span := c.allocLarge(size, typ == nil || !typ.Pointers())
span.freeindex = 1
span.allocCount = 1
span.largeType = nil // 先不让 GC 按类型扫,等后面清零与 heapSetTypeLarge 就绪
size = span.elemsize
x := unsafe.Pointer(span.base())
publicationBarrier()
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
...
c.nextSample -= int64(size)
if c.nextSample < 0 || MemProfileRate != c.memProfRate {
profilealloc(mp, x, size)
}
mp.mallocing = 0
releasem(mp)
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
gcStart(t)
}
// 大块清零可在已 releasem 之后做,允许被抢占;x 已使 span 存活
if needzero && span.needzero != 0 {
memclrNoHeapPointersChunked(size, x)
}
mp = acquirem()
if typ != nil && typ.Pointers() {
getMCache(mp).scanAlloc += heapSetTypeLarge(uintptr(x), size, typ, span)
}
publicationBarrier()
releasem(mp)
return x, size
}
nextFreeFast / nextFree(小对象取槽)
常见调用链:先试 nextFreeFast(只动 allocCache);返回 0 再进入 (c *mcache).nextFree(可走 nextFreeIndex 慢路径,span 满则 refill)。下面分两节写源码与要点。
nextFreeFast
在当前 mspan 上,若 allocCache 还能直接抠出一格,就更新 freeindex、allocCache、allocCount 并返回对象地址;否则返回 0,交给 nextFreeIndex。
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:mspan 里缓存的一小段 64 位空闲槽 hint,与 freeindex 配合;TrailingZeros64 找最低位的 1,即下一格相对当前块的偏移。allocCache >>= theBit+1 表示刚发出去的槽及之前的位清掉,后面仍在同一批 hint 里继续抠。
-
theBit 为 64 时返回 0:allocCache 为 0 时尾部零个数为 64,本批 hint 没有可用位,快路径结束,由 nextFreeIndex 从堆位图重整 allocCache。
-
freeidx%64==0 且 freeidx!=nelems 时返回 0:本组 64 位 hint 用尽但 span 未满,下一格要从下一段位图摘要装入 allocCache,快路径不代办,避免和真实空闲脱节。
-
成功时:槽下标 result 对应 result*elemsize+base(),推进 freeindex 与 allocCount,表示本格已从该 span 划出。
(c *mcache).nextFree
对给定 spanClass,从当前 P 的 mcache 取出绑定的 mspan,得到下一个空闲槽;当前 span 已满则 refill 换新架,并设置 checkGCTrigger 供上游决定是否碰 GC。返回新槽的 gclinkptr、所用 mspan,以及是否要检查 GC。
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger bool) {
s = c.alloc[spc]
checkGCTrigger = false
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
// The span is full.
if s.allocCount != s.nelems {
println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount != s.nelems && freeIndex == s.nelems")
}
c.refill(spc)
checkGCTrigger = true
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
if freeIndex >= s.nelems {
throw("freeIndex is not valid")
}
v = gclinkptr(uintptr(freeIndex)*s.elemsize + s.base())
s.allocCount++
if s.allocCount > s.nelems {
println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
throw("s.allocCount > s.nelems")
}
return
}
要点说明:
-
c.alloc[spc]:每个 P 的 mcache 按 spanClass 绑着当前在用的一架 span。
-
nextFreeIndex:算出下一个空闲槽下标(内部可能走 nextFreeFast 或扫堆位图并重刷 allocCache)。allocCount 与 nextFreeFast 里是否已自增,要以 mspan.nextFreeIndex 和 nextFree 末尾的 allocCount++ 对照源码为准。
-
freeIndex==nelems:表示当前 span 已满;此时应满足 allocCount==nelems,否则 throw。接着 refill(spc) 向 mcentral 换新 span,checkGCTrigger=true,s=c.alloc[spc] 后再调一次 nextFreeIndex 从新架取槽。
-
地址与校验:用 freeIndex*elemsize+base() 得地址;allocCount++ 与 allocCount>nelems 的断言保证发出对象数不超过槽数。
-
不可抢占:须在 goroutine 稳定关联对应 P、mcache 的上下文调用(常见 acquirem),否则 c 的归属变了会写坏 per-P 状态。
-
checkGCTrigger:refill 会加重堆占用,mallocgcSmall* 等据此决定是否 GC 协助或触发;未走 refill、只在 nextFreeIndex 里快路径成功的情形一般不立这个标志。