Go 内存管理(2):内存分配源码

17 阅读8分钟

内存分配源码 2:mallocgc 入口与分支

系列阅读:内存分配 -> 内存分配源码1 -> 内存分配源码2
术语口径:沿用前两篇;本篇额外:noscan / scantinymallocgcSmall*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 路径会用到;含指针的小对象强制清零)。

  1. 零长分配:size == 0 时不向堆申请真实块,返回全局 zerobase,所有零长对象可共享该地址。

  2. 小对象 / 大对象

    • 分界条件:size <= maxSmallSize - gc.MallocHeaderSize 即 size <= 32760 时走小对象路径(为可能存在的 header 预留 8 字节余量);size > 32760 走 mallocgcLarge。
    1. size < 16 → mallocgcTiny:在 per-P 的 tiny 块里把多个极小 noscan 对象拼在一起,减少碎片。
    2. 16 <= size <= 32760 → mallocgcSmallNoscan:普通小对象 noscan,按 size class 取槽。
    3. 含指针(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
}

要点说明:

  1. 为什么要 tiny:若每个小于 16 字节的对象都单独占一个 size class 槽,分配次数和碎片都多。拼在 16 字节里,多次分配常合并成一次向 span 要槽;典型受益是小字符串、逃逸的小标量(源码注释里 json 基准约少分配、缩堆)。

  2. 为何必须 noscan:多个子对象共享同一释放粒度,GC 不能单独回收其中一个,只能整块不可达再回收;若含指针,边界与标记会很复杂,故 tiny 只接不含指针的类型。

  3. 新槽与 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
}

要点说明:

  1. allocCache:mspan 里缓存的一小段 64 位空闲槽 hint,与 freeindex 配合;TrailingZeros64 找最低位的 1,即下一格相对当前块的偏移。allocCache >>= theBit+1 表示刚发出去的槽及之前的位清掉,后面仍在同一批 hint 里继续抠。

  2. theBit 为 64 时返回 0:allocCache 为 0 时尾部零个数为 64,本批 hint 没有可用位,快路径结束,由 nextFreeIndex 从堆位图重整 allocCache。

  3. freeidx%64==0 且 freeidx!=nelems 时返回 0:本组 64 位 hint 用尽但 span 未满,下一格要从下一段位图摘要装入 allocCache,快路径不代办,避免和真实空闲脱节。

  4. 成功时:槽下标 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
}

要点说明:

  1. c.alloc[spc]:每个 P 的 mcache 按 spanClass 绑着当前在用的一架 span。

  2. nextFreeIndex:算出下一个空闲槽下标(内部可能走 nextFreeFast 或扫堆位图并重刷 allocCache)。allocCount 与 nextFreeFast 里是否已自增,要以 mspan.nextFreeIndex 和 nextFree 末尾的 allocCount++ 对照源码为准。

  3. freeIndex==nelems:表示当前 span 已满;此时应满足 allocCount==nelems,否则 throw。接着 refill(spc) 向 mcentral 换新 span,checkGCTrigger=true,s=c.alloc[spc] 后再调一次 nextFreeIndex 从新架取槽。

  4. 地址与校验:用 freeIndex*elemsize+base() 得地址;allocCount++ 与 allocCount>nelems 的断言保证发出对象数不超过槽数。

  5. 不可抢占:须在 goroutine 稳定关联对应 P、mcache 的上下文调用(常见 acquirem),否则 c 的归属变了会写坏 per-P 状态。

  6. checkGCTrigger:refill 会加重堆占用,mallocgcSmall* 等据此决定是否 GC 协助或触发;未走 refill、只在 nextFreeIndex 里快路径成功的情形一般不立这个标志。