10. Large 对象分配:直接路径

摘要 :本文深入剖析 Go 运行时大对象(>32KB)的分配路径。大对象分配跳过 mcache 与 mcentral 的缓存 ,直接从 mheap 按整页分配,span 采用"一个对象一个 span"的特殊结构(sizeclass=0nelems=1)。文章依次讲解 mallocgcLarge 入口、c.allocLarge 的四步关键操作、大对象 span 的特殊性、绕过缓存的三点意义、GC 扫描与黑色分配、largeType 类型信息的原子发布、延迟清零与可抢占设计,最后给出完整调用链与 MemStats 统计字段。核心结论:大对象走"直接路径"是"不值得缓存"的经典取舍,以分配速度换取缓存命中率与实现简洁性。

1. 大对象从哪里来?

回顾 04. mallocgc:分配总入口,mallocgc 的分派逻辑:

go 复制代码
// malloc.go:1123-1162(结构化简)
if size <= maxSmallSize - gc.MallocHeaderSize {
	// 小对象路径:Tiny / Small,经过 mcache
	...
} else {
	// size > 32760  Large 路径
	x, elemsize = mallocgcLarge(size, typ, needzero)
}

--- malloc.go:1137 / 1162

maxSmallSize = gc.MaxSmallSize = 32 << 10 = 32768(32KB),实际判据还要减去 MallocHeaderSize = 8,即 size > 32760 走 Large(32761~32768 的请求也会走大对象路径,见 04 篇)。所以:

什么算"大对象"? 请求大小超过 32760 字节maxSmallSize - MallocHeaderSize,约 32KB)的对象。典型场景:大数组、大 buffer、大文件读取的缓冲、大哈希表等。这些对象一次分配、整块使用,不适合用 mcache 的槽位式管理。

大对象的分配路径和前面几篇完全不同------它跳过 mcache 和 mcentral 的 span 缓存,直接从 mheap 分配一整块连续页。

图 10-1:Large 对象分配路径(左边是它跳过的小对象路径)


2. mallocgcLarge:入口

mallocgcLarge() 定义在 malloc.go:1689-1788,是大对象分配的主函数:

go 复制代码
// malloc.go:1689
func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
	// Set mp.mallocing to keep from being preempted by GC.
	mp := acquirem()
	if doubleCheckMalloc {
		if mp.mallocing != 0 {
			throw("malloc deadlock")
		}
	}
	mp.mallocing = 1

	c := getMCache(mp)
	// For large allocations, keep track of zeroed state so that
	// bulk zeroing can be happen later in a preemptible context.
	span := c.allocLarge(size, typ == nil || !typ.Pointers())
	span.freeindex = 1
	span.allocCount = 1
	span.largeType = nil // Tell the GC not to look at this yet.
	size = span.elemsize
	x := unsafe.Pointer(span.base())

	// Ensure that the store above that sets largeType to nil
	// happens before the caller can make x observable
	// to the garbage collector.
	publicationBarrier()

	if writeBarrier.enabled {
		// Allocate black during GC.
		// All slots hold nil so no scanning is needed.
		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)

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

	// 延迟清零(可抢占上下文)
	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
}

--- malloc.go:1689-1788(结构化简)

函数拆解

步骤 代码 作用 行号
1 acquirem(); mp.mallocing = 1 绑定 M、禁止被 GC 抢占 malloc.go:1691-1700
2 c.allocLarge(...) 核心:分配 span malloc.go:1705
3 span.freeindex = 1; allocCount = 1 标记第一个对象已被占用 malloc.go:1706-1707
4 span.largeType = nil 暂不发布类型信息 malloc.go:1708
5 publicationBarrier() 内存屏障,确保 largeType=nil 先可见 malloc.go:1719
6 gcmarknewobject(...) GC 期间分配黑色对象 malloc.go:1726
7 profilealloc(...) 内存采样(memProfRate) malloc.go:1748-1751
8 GC trigger 检查 触发 GC 则 gcStart malloc.go:1756-1758
9 memclrNoHeapPointersChunked 延迟清零(可抢占点) malloc.go:1765
10 heapSetTypeLarge(...) 写入类型信息 malloc.go:1779

span.freeindex = 1 的含义malloc.go:1706):大对象 span 只有一个对象,位于槽位 0。把 freeindex 置为 1 表示"0 号槽位已分配",后续 nextFreeIndex() 不会再返回 0。allocCount = 1 同步记录占用计数。


3. c.allocLarge:直接调 mheap

真正分配 span 的是 mcache.allocLarge()mcache.go:241-288)。注意它虽然挂在 mcache 的方法名下,但完全绕过了 mcache 的对象缓存

go 复制代码
// mcache.go:241
// allocLarge allocates a span for a large object.
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {
	if size+pageSize < size {
		throw("out of memory")
	}
	npages := size >> gc.PageShift
	if size&pageMask != 0 {
		npages++
	}

	// Deduct credit for this span allocation and sweep if
	// necessary. mHeap_Alloc will also sweep npages, so this only
	// pays the debt down to npage pages.
	deductSweepCredit(npages*pageSize, npages)

	spc := makeSpanClass(0, noscan)
	s := mheap_.alloc(npages, spc) // 直接调 mheap,绕过 mcentral
	if s == nil {
		throw("out of memory")
	}

	// 统计:大对象分配字节数 + 次数
	stats := memstats.heapStats.acquire()
	atomic.Xadd64(&stats.largeAlloc, int64(npages*pageSize))
	atomic.Xadd64(&stats.largeAllocCount, 1)
	memstats.heapStats.release()

	// 内部统计 + heapLive 更新
	gcController.totalAlloc.Add(int64(npages * pageSize))
	gcController.update(int64(s.npages*pageSize), 0)

	// Put the large span in the mcentral swept list so that it's
	// visible to the background sweeper.
	mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)

	// Adjust s.limit down to the object-containing part of the span.
	s.limit = s.base() + size
	s.initHeapBits()
	return s
}

--- mcache.go:241-288

四步关键操作

① 计算需要的页数mcache.go:246-249):

go 复制代码
npages := size >> gc.PageShift
if size&pageMask != 0 {
	npages++
}

大对象按页对齐:8KB 一页,向上取整。例如申请 40KB 5 页,申请 33KB 5 页。

② 比例清扫记账mcache.go:254):

go 复制代码
deductSweepCredit(npages*pageSize, npages)

分配大对象前先抵扣清扫信用------如果后台清扫还没完成,这里会同步清扫足够的页,防止堆无限膨胀。

③ 直接调 mheapmcache.go:256-257):

go 复制代码
spc := makeSpanClass(0, noscan)
s := mheap_.alloc(npages, spc)

这是整个大对象路径的关键:makeSpanClass(0, noscan) 的 sizeclass = 0 ,表示"没有固定 size class"。mheap_.alloc 会走上一章讲的 allocSpan 从页分配器拿整块连续页。

注意 :这里虽然把 span 也放进了 mcentral(下面第 ④ 步),但不是 为了复用------大对象 span 只有 1 个对象,用完即整体回收。放进 mcentral 的 fullSwept 列表只是为了让清扫器能发现并清扫它

④ 统计与发布mcache.go:263-286):

go 复制代码
// 大对象统计(MemStats.largeAlloc / largeAllocCount)
atomic.Xadd64(&stats.largeAlloc, int64(npages*pageSize))
atomic.Xadd64(&stats.largeAllocCount, 1)

// 更新 heapLive
gcController.update(int64(s.npages*pageSize), 0)

// 让清扫器可见
mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)

// 收紧 limit,只包含对象所在部分
s.limit = s.base() + size

fullSwept 是双缓冲中的哪个? 回顾 08. mcentral 的双缓冲设计:full[2] 分为 fullSwept(已清扫满 span)和 fullUnswept(未清扫满 span)。新分配的大对象 span 是"满"的(1 个对象已用),所以放入 fullSwept 列表。


4. 大对象 span 的特殊性

回顾 09. mheap 的 initSpan,size class 为 0 时的处理完全不同:

go 复制代码
// mheap.go:1452-1455
if sizeclass := spanclass.sizeclass(); sizeclass == 0 {
	// 大对象 span:整个 span 就是一个对象
	s.elemsize = nbytes
	s.nelems = 1
	s.divMul = 0
}

--- mheap.go:1452-1455

对比小对象 span:

属性 小对象 span 大对象 span
elemsize SizeClassToSize[sizeclass](固定大小) nbytes(整个 span 的大小)
nelems 多个对象(nbytes / elemsize 1(整个 span 一个对象)
divMul SizeClassToDivMagic[sizeclass] 0(不参与索引除法)
freeindex 0(逐步分配) 1(0 号已被占用)
allocCount 逐步增长 1(直接置 1)
allocBits 多槽位位图 只有 1 个有效位

一句话 :大对象 span 是"一个对象一个 span",nelems = 1elemsize = nbytes。整个 span 的内存全部给这一个对象使用,不存在碎片,也不存在槽位复用。

这带来一个直接后果:大对象分配的内存利用率是 100%(向上取整到页的浪费除外,最多浪费 8KB-1 字节)。而小对象会有 size class 取整的内部碎片。


5. 绕过缓存的意义

为什么大对象不经过 mcache/mcentral 的缓存?有三个原因:

① 缓存没有意义

mcache 的 alloc [numSpanClasses]*mspan 缓存的是已切分成槽位的 span 。大对象 span 只有 1 个对象,用完即整体归还,没有"部分空闲等待复用"的状态------所以缓存它没有任何收益。

② 避免污染缓存

大对象 span 数量少(一个对象就占几十 KB 甚至更大),如果放进 mcache,会挤掉大量小对象的缓存位置。让大对象直接走 mheap,可以保持 mcache 的命中率

③ 分配更直接

大对象需要的页数多(npages = size / 8KB),调用 mheap.alloc(npages) 直接从页分配器拿连续页最直接。多经过一层 mcentral 反而多一次锁和链表操作。

性能影响 :大对象分配需要持有 mheap.lock(在 allocSpan 中),且可能触发 grow()(向 OS 要内存)。所以大对象分配比小对象慢很多(微秒级 vs 纳秒级)。但大对象分配频率低,对整体性能影响可控。


6. 大对象的 GC 扫描

大对象对 GC 的影响值得单独讲。关键点:

分配黑色(GC 期间)

go 复制代码
// malloc.go:1721-1726
if writeBarrier.enabled {
	// Allocate black during GC.
	// All slots hold nil so no scanning is needed.
	gcmarknewobject(span, uintptr(x))
}

--- malloc.go:1721-1726

GC 标记期间新分配的大对象直接标记为黑色 (存活),避免它被误回收。注释还解释了为什么可以这样:大对象刚分配时槽位全为 nil,不需要扫描

扫描 vs 不扫描

大对象的 scan/noscan 决定权在调用方:

go 复制代码
// malloc.go:1705
span := c.allocLarge(size, typ == nil || !typ.Pointers())
  • noscantyp == nil!typ.Pointers()):对象内部不含指针,GC 扫描时可以完全跳过。
  • scan(含指针):GC 需要按类型位图扫描整个对象。
go 复制代码
// malloc.go:1777-1779
if typ != nil && typ.Pointers() {
	// Finish storing the type information, now that we're certain the memory is zeroed.
	getMCache(mp).scanAlloc += heapSetTypeLarge(uintptr(x), size, typ, span)
}

--- malloc.go:1777-1779

含指针的大对象(例如大 struct、大 slice of structs)扫描成本和对象大小成正比。这是大对象一个隐藏的 GC 成本------一个大数组可能让 GC 花不少时间扫描。

对 GC 的另一个影响:堆活量估算

go 复制代码
// mcache.go:272
gcController.update(int64(s.npages*pageSize), 0)

大对象分配直接增加 heapLive(堆活量),每次大对象分配都按整页记账 。如果程序频繁分配大对象,heapLive 会快速逼近 GC 触发阈值。

实践建议 :避免在热点循环里分配大对象。例如 buf := make([]byte, 1<<20) 放在循环内,会让 GC 频繁触发。应该复用缓冲区 (比如 sync.Pool)或使用对象池。


7. largeType:大对象的类型信息

mspan 结构里专门有一个字段存大对象的类型信息:

go 复制代码
// mheap.go:515
largeType *_type // malloc header for large objects.

--- mheap.go:515

为什么需要单独存?

小对象的类型信息编码在 GC 位图中(每对象一个指针位图,由 allocBits/heapBits 管理)。但大对象是整个 span 一个对象 ,它的指针位图信息量远超普通对象,单独存一个 _type 指针更合理。

写入流程

go 复制代码
// malloc.go:1708
span.largeType = nil // 先置空:GC 还不能看它

// malloc.go:1719
publicationBarrier() // 屏障:确保 largeType=nil 先可见

// ... 清零、初始化内存 ...

// malloc.go:1779
getMCache(mp).scanAlloc += heapSetTypeLarge(uintptr(x), size, typ, span)
// 内部最终调用:atomic.StorepNoWB(&span.largeType, gctyp)

--- malloc.go:1708-1779;mbitmap.go:797

为什么这么小心?

largeType 的写入时机和内存清零有严格的先后关系(mbitmap.go:753-797 有详细注释)。核心推理:

  1. 大对象分配后,内存可能是脏的(未清零)。
  2. GC 并发运行,随时可能通过 span.largeType 读取类型信息来扫描这个对象。
  3. 如果 GC 在内存清零之前 就读到类型信息并扫描,可能把垃圾数据误当指针,造成程序崩溃。

所以流程是:

ini 复制代码
largeType = nil ──publicationBarrier── 清零内存 ── heapSetTypeLarge(原子写类型)── GC 才能看到

largeType == nil 期间,GC 读到 nil 就知道"这个对象还不能扫描",直接跳过。

atomic.StorepNoWBmbitmap.go:797):无写屏障的原子指针存储。因为这里不需要触发写屏障(GC 已正确处理这种情况),只需要保证原子性和可见性


8. 延迟清零与可抢占

大对象清零是一个可以延后到可抢占上下文 的操作(malloc.go:1760-1766):

go 复制代码
// malloc.go:1760-1766
// Objects can be zeroed late in a context where preemption can occur.
//
// x will keep the memory alive.
if needzero && span.needzero != 0 {
	// N.B. size == fullSize always in this case.
	memclrNoHeapPointersChunked(size, x) // This is a possible preemption point: see #47302
}

--- malloc.go:1760-1766

两个关键设计:

① 为什么延迟?

memclrNoHeapPointersChunked 一次性清零可能很大(几十 MB)。如果放在分配路径里同步做,会长时间阻塞分配线程。延迟清零可以让分配先返回,清零以**分块(chunked)**方式在稍后执行,并且是可抢占点。

② 为什么要分块?

go 复制代码
memclrNoHeapPointersChunked(size, x) // This is a possible preemption point: see #47302

注释引用的 issue #47302 说明:一次性清零大内存时,若期间发生 GC,可能出现 GC 观察到"类型信息已设置但内存未清零"的竞态。分块清零 + 上文讲的 largeType 顺序,共同保证了安全。

span.needzero 什么情况下为 0? 回顾 09. mheap 的 initSpan:如果分配到的页是 scavenged(已归还 OS,内核保证重新使用时清零)或 arena 的 zeroedBase 标记已清零,needzero 为 0,可以跳过显式清零。


9. 完整调用链与统计

完整调用链

scss 复制代码
make([]byte, N) / 大 struct / 大 buffer
  └─ mallocgc(size, typ, needzero)                malloc.go:1067
       └─ size > 32760 (maxSmallSize-8)?
            └─ mallocgcLarge(size, typ, needzero)  malloc.go:1689
                 ├─ c.allocLarge(size, noscan)     mcache.go:241
                 │    ├─ deductSweepCredit(...)    比例清扫
                 │    ├─ spc = makeSpanClass(0, noscan)  sizeclass=0
                 │    └─ mheap_.alloc(npages, spc) mheap.go:997   直接调,绕过 mcentral
                 │         └─ allocSpan  initSpan mheap.go:1215/1430
                 ├─ publicationBarrier / gcmarknewobject
                 ├─ GC trigger 检查
                 ├─ memclrNoHeapPointersChunked(延迟清零)
                 └─ heapSetTypeLarge(设置类型信息)

对比:附录 B.1 分配调用链 中 Large 分支------mallocgcLarge c.allocLarge mheap.alloc (直接)

MemStats 统计字段

大对象有专门的统计(mstats.go:686-687):

字段 含义
MemStats.LargeAlloc 累计分配的大对象字节数
MemStats.LargeAllocCount 累计分配的大对象个数
MemStats.LargeFree 累计释放的大对象字节数
MemStats.LargeFreeCount 累计释放的大对象个数

写入位置就在 allocLargemcache.go:264-265)。另外 gcController.totalAllocheapLive 也在此更新。

runtime.MemStats 观察:HeapAlloc 减去各类 small 统计,剩下的就是大对象占用的内存。pprof 的 heap profile 也会记录大对象分配(profileallocmalloc.go:1750)。


10. 总结

核心要点

  1. 触发条件size > maxSmallSize - gc.MallocHeaderSize(32760)时走 Large 路径(malloc.go:1137)。
  2. 分配核心c.allocLarge() 绕过 mcache 对象缓存和 mcentral ,直接调 mheap_.alloc(npages, spc)mcache.go:257)。
  3. span 特殊性sizeclass=0 nelems=1elemsize=nbytes,一个对象独占整个 span。
  4. 类型信息 :大对象类型存在 span.largeType,通过 heapSetTypeLarge 原子发布,配合 publicationBarrier 保证 GC 安全。
  5. GC 影响 :GC 期间分配黑色;noscan 可跳过扫描;含指针的大对象扫描成本与大小成正比;分配即增加 heapLive
  6. 延迟清零memclrNoHeapPointersChunked 在可抢占上下文中分块执行。

为什么设计成"直接路径"?

对比项 小对象(经缓存) 大对象(直接)
经过 mcache 对象缓存
经过 mcentral span 缓存 (仅挂 fullSwept 供清扫)
需要 h.lock 大多数情况不需要 必须(allocSpan 全局锁路径)
分配粒度 槽位(8B~32KB) 整页(8KB 的倍数)
内部碎片 有(size class 取整) 仅有页级取整碎片(最多 8KB-1)
分配速度 ~10-100ns ~µs(可能触发 grow)
频率 极高

大对象走直接路径,本质是**"不值得缓存"**的判断:缓存带来的收益(避免锁)小于缓存管理的成本(挤占缓存、无复用价值)。这正是 TCMalloc 风格分配器的经典取舍。


相关推荐
yinchnag22 分钟前
CRTP:奇异递归模板模式在 Go 模块系统中的实现
设计模式·go
ylj_dev1 天前
从 0 构建 AI Workload Platform(七):可观测性、故障注入与性能验证
go·prometheus·可观测性·opentelemetry·故障注入
我的div丢了肿么办1 天前
go语言中基本数据类型的转换
后端·go
newerp2 天前
语法分析与 AST:Parser 与 go/ast
后端·程序员·go
newerp2 天前
Go 编译过程全景
后端·程序员·go
stark张宇3 天前
Go语言runtime全景图:从编译到GC,带你彻底吃透Go的底层血脉
后端·go
名字还没想好☜3 天前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go
运维开发笔记4 天前
5.2 Go 数组进阶学习笔记(多维、排序、搜索)
go
newerp4 天前
Redis 操作与缓存策略
后端·程序员·go