摘要 :本文深入剖析 Go 运行时大对象(>32KB)的分配路径。大对象分配跳过 mcache 与 mcentral 的缓存 ,直接从 mheap 按整页分配,span 采用"一个对象一个 span"的特殊结构(
sizeclass=0、nelems=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)
分配大对象前先抵扣清扫信用------如果后台清扫还没完成,这里会同步清扫足够的页,防止堆无限膨胀。
③ 直接调 mheap (mcache.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 = 1,elemsize = 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())
- noscan (
typ == 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 有详细注释)。核心推理:
- 大对象分配后,内存可能是脏的(未清零)。
- GC 并发运行,随时可能通过
span.largeType读取类型信息来扫描这个对象。 - 如果 GC 在内存清零之前 就读到类型信息并扫描,可能把垃圾数据误当指针,造成程序崩溃。
所以流程是:
ini
largeType = nil ──publicationBarrier── 清零内存 ── heapSetTypeLarge(原子写类型)── GC 才能看到
在 largeType == nil 期间,GC 读到 nil 就知道"这个对象还不能扫描",直接跳过。
atomic.StorepNoWB(mbitmap.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 |
累计释放的大对象个数 |
写入位置就在 allocLarge(mcache.go:264-265)。另外 gcController.totalAlloc 和 heapLive 也在此更新。
用
runtime.MemStats观察:HeapAlloc减去各类 small 统计,剩下的就是大对象占用的内存。pprof 的 heap profile 也会记录大对象分配(profilealloc,malloc.go:1750)。
10. 总结
核心要点
- 触发条件 :
size > maxSmallSize - gc.MallocHeaderSize(32760)时走 Large 路径(malloc.go:1137)。 - 分配核心 :
c.allocLarge()绕过 mcache 对象缓存和 mcentral ,直接调mheap_.alloc(npages, spc)(mcache.go:257)。 - span 特殊性 :
sizeclass=0nelems=1、elemsize=nbytes,一个对象独占整个 span。 - 类型信息 :大对象类型存在
span.largeType,通过heapSetTypeLarge原子发布,配合publicationBarrier保证 GC 安全。 - GC 影响 :GC 期间分配黑色;noscan 可跳过扫描;含指针的大对象扫描成本与大小成正比;分配即增加
heapLive。 - 延迟清零 :
memclrNoHeapPointersChunked在可抢占上下文中分块执行。
为什么设计成"直接路径"?
| 对比项 | 小对象(经缓存) | 大对象(直接) |
|---|---|---|
| 经过 mcache 对象缓存 | ||
| 经过 mcentral span 缓存 | (仅挂 fullSwept 供清扫) | |
需要 h.lock |
大多数情况不需要 | 必须(allocSpan 全局锁路径) |
| 分配粒度 | 槽位(8B~32KB) | 整页(8KB 的倍数) |
| 内部碎片 | 有(size class 取整) | 仅有页级取整碎片(最多 8KB-1) |
| 分配速度 | ~10-100ns | ~µs(可能触发 grow) |
| 频率 | 极高 | 低 |
大对象走直接路径,本质是**"不值得缓存"**的判断:缓存带来的收益(避免锁)小于缓存管理的成本(挤占缓存、无复用价值)。这正是 TCMalloc 风格分配器的经典取舍。