09. mheap:全局堆管理器
1. mheap 是什么?
前几篇 Wiki 我们层层向上:mcache 是 Per-P 的无锁缓存,mcentral 是每种 size class 的中心缓存。但它们都有枯竭的时候------当 mcentral 手里也没有空闲 span 时,就得找mheap 要。
mheap 是全局唯一的堆管理器,管理整个 Go 堆的所有内存。它像一个"大管家",掌握着:
- 页分配器 (
pages pageAlloc)------从哪块地址空间分配连续页 - Arena 映射 (
arenas)------地址 元数据的映射表 - 中心缓存数组 (
central [136])------所有 size class 的 mcentral 都在这里 - fixalloc 池------分配 mspan、mcache 等分配器自身元数据的专用分配器
- 清扫状态 ------
sweepgen世代号、比例清扫参数、页回收状态
一句话理解:mheap 是 Go 内存管理的"顶层总管"。前面讲的所有缓存(mcache / mcentral)最终都只是 mheap 的"代理",真正持有和分配内存的是 mheap。

图 9-1:mheap 整体结构(mheap.go:64-262)
2. mheap 结构全解
mheap 结构体定义在 mheap.go:64-262,字段非常多。我们先看几个最核心的:
go
// mheap.go:64
type mheap struct {
_ sys.NotInHeap
// lock 只能在系统栈上获取,否则持有锁时栈增长会导致自我死锁
lock mutex
pages pageAlloc // page allocation data structure
sweepgen uint32 // 清扫世代号,见 mspan 中的注释;STW 期间写入
allspans []*mspan // 所有 mspan,每个恰好出现一次
// 比例清扫参数
pagesInUse atomic.Uintptr
pagesSwept atomic.Uint64
sweepPagesPerByte float64 // 比例清扫斜率
// 页回收状态
reclaimIndex atomic.Uint64
reclaimCredit atomic.Uintptr
// Arena 地址空间映射(两级稀疏数组)
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// 中心缓存数组(136 个,带 cache line padding)
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
// fixalloc 池
spanalloc fixalloc // 分配 mspan
cachealloc fixalloc // 分配 mcache
specialfinalizeralloc fixalloc // 分配 specialfinalizer
arenaHintAlloc fixalloc // 分配 arenaHint
// ...(还有更多 special 相关的 fixalloc)
}
--- mheap.go:64-262(节选)
各字段职责
| 字段 | 类型 | 职责 | 行号 |
|---|---|---|---|
lock |
mutex |
全局堆锁,保护 mheap 内部数据结构 | mheap.go:69 |
pages |
pageAlloc |
页分配器,管理连续页的分配/释放 | mheap.go:71 |
sweepgen |
uint32 |
清扫世代号,每次 GC 后 +2 | mheap.go:73 |
allspans |
[]*mspan |
记录所有 mspan,用于遍历 | mheap.go:86 |
pagesInUse |
atomic.Uintptr |
在用的页数(span) | mheap.go:106 |
sweepPagesPerByte |
float64 |
比例清扫的斜率 | mheap.go:110 |
reclaimIndex |
atomic.Uint64 |
页回收的进度游标 | mheap.go:120 |
reclaimCredit |
atomic.Uintptr |
回收信用,多余回收页的信用池 | mheap.go:126 |
arenas |
两级数组 | 地址空间 heapArena 映射 | mheap.go:150 |
central |
数组 | 136 个 mcentral,按 spanClass 索引 | mheap.go:211 |
curArena |
struct | 当前正在增长的 arena 范围 | mheap.go:202 |
spanalloc 等 |
fixalloc |
元数据分配池 | mheap.go:216+ |
_ sys.NotInHeap:告诉编译器这个类型永远不会 出现在堆上(它必须是静态分配的全局变量mheap_)。这样编译器可以禁止对它取地址赋值到堆,并防止被 GC 跟踪。见mheap.go:65。
3. central 数组与 cache line padding
central 是 mheap 里最重要的字段之一,它把所有 size class 的 mcentral 集中管理:
go
// mheap.go:206-214
// central free lists for small size classes.
// the padding makes sure that the mcentrals are
// spaced CacheLinePadSize bytes apart, so that each mcentral.lock
// gets its own cache line.
// central is indexed by spanClass.
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
numSpanClasses 定义在 mheap.go:576:
go
// mheap.go:573-581
type spanClass uint8
const numSpanClasses = gc.NumSizeClasses << 1 // 136
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
gc.NumSizeClasses 是 68(size class 表的长度,含下标 0 的空位,真实 class 67 个,见 03 篇),<< 1 后得到 136------因为每个 size class 还有 scan / noscan 两个维度。所以 central[136] 覆盖了所有 spanClass。
为什么
central不是[136]mcentral而是[136]struct{mcentral; pad}? 这就是cache line padding(缓存行补齐) ,目的是防止伪共享(false sharing)。每个 mcentral 有自己的
lock。如果两个 mcentral 紧挨着落在同一条 CPU 缓存行(cache line,通常 64 字节)上,那么当不同 P 分别加锁修改这两个 mcentral 时,就会反复使对方的缓存行失效,造成严重的缓存抖动。用pad把每个 mcentral 撑到恰好跨一整条缓存行,就能让每个mcentral.lock独占一条缓存行。
136 个锁:理论上每个 spanClass 一个 mcentral 锁。所以并发时最多有 136 个 mcentral 锁可以被独立竞争,这比 mheap 一个全局锁要宽松得多。这就是多级缓存的精髓------把竞争分散。
4. alloc:分配入口
mheap 最核心的对外接口是 alloc():
go
// mheap.go:997
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
// Don't do any operations that lock the heap on the G stack.
// It might trigger stack growth, and the stack growth code needs
// to be able to allocate heap.
var s *mspan
systemstack(func() {
// To prevent excessive heap growth, before allocating n pages
// we need to sweep and reclaim at least n pages.
if !isSweepDone() {
h.reclaim(npages)
}
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
return s
}
--- mheap.go:997-1011
两个关键点:
-
先 reclaim 再分配 (
mheap.go:1006):如果清扫(sweep)还没完成,先调用h.reclaim(npages)按比例回收至少npages页。这样保证:分配 n 页之前,至少已经清扫并回收了 n 页,防止堆无限增长。这是**比例清扫(proportional sweep)**的一部分。 -
必须在系统栈执行 (
systemstack(func(){...}),mheap.go:1002):因为allocSpan会获取堆锁,而持锁时栈不能增长(否则可能死锁,详见第 11 节)。
h.reclaim(npages)正是前面reclaimIndex/reclaimCredit字段的使用者。它扫描 arena 的pageInUse位图,找到清扫后空出的整块页进行回收。
5. allocSpan:核心分配流程
真正的分配逻辑在 allocSpan() 里(mheap.go:1215-1426)。它是整个 Go 分配器最核心、最复杂的函数之一。整体流程:

图 9-2:allocSpan 完整流程(mheap.go:1215-1426)
go
// mheap.go:1215
func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) {
// 函数级状态
gp := getg()
base, scav := uintptr(0), uintptr(0)
growth := uintptr(0)
needPhysPageAlign := physPageAlignedStacks && typ == spanAllocStack && pageSize < physPageSize
// 1. P-local 页缓存(无锁快速路径)
pp := gp.m.p.ptr()
if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
c := &pp.pcache
if c.empty() {
lock(&h.lock)
*c = h.pages.allocToCache()
unlock(&h.lock)
}
base, scav = c.alloc(npages)
if base != 0 {
s = h.tryAllocMSpan()
if s != nil {
goto HaveSpan
}
}
}
// 2. 全局锁路径
lock(&h.lock)
if base == 0 {
base, scav = h.pages.alloc(npages)
if base == 0 {
growth, ok = h.grow(npages)
base, scav = h.pages.alloc(npages)
}
}
if s == nil {
s = h.allocMSpanLocked()
}
unlock(&h.lock)
HaveSpan:
// 3. 初始化 span
h.initSpan(s, typ, spanclass, base, npages, scav)
// 4. sysUsed 提交内存(若含 scavenged 页)
sysUsed(unsafe.Pointer(base), nbytes, scav)
return s
}
--- mheap.go:1215-1426(结构化简)
两条路径的分界线
allocSpan 遵循一个核心原则:能无锁就不加锁,加锁也要尽量少加。
- 快速路径(第 1 步) :如果分配量小(
npages < pageCachePages/4,即少于 16 页)、不需要物理页对齐、且当前有 P,就从 P-local 页缓存 (pp.pcache)分配。这个缓存是每 P 独有的,完全无锁。 - 慢速路径(第 2 步) :否则加全局锁
h.lock,从页分配器h.pages.alloc()分配;若不够就h.grow()增长堆后再试。
注意:这里的"页缓存"(pageCache)和前面讲过的 mcache 是两回事 。mcache 缓存的是对象级 的 mspan;而这里的 pageCache 缓存的是页级的空闲页(一次缓存 64 页)。当 mcache 需要一个新的 mspan 时,最终会落到 allocSpan,从这里拿到页。
6. P-local 页缓存:无锁快速路径
先看快速路径的细节(mheap.go:1226-1250):
go
// mheap.go:1226-1250
// If the allocation is small enough, try the page cache!
pp := gp.m.p.ptr()
if !needPhysPageAlign && pp != nil && npages < pageCachePages/4 {
c := &pp.pcache
// 缓存为空则加锁补充
if c.empty() {
lock(&h.lock)
*c = h.pages.allocToCache() // 从页分配器一次性拿 64 页
unlock(&h.lock)
}
// 尝试从缓存分配
base, scav = c.alloc(npages)
if base != 0 {
s = h.tryAllocMSpan()
if s != nil {
goto HaveSpan // 完美:既拿到页又拿到 mspan,全程无锁
}
// 有 base 但没拿到 mspan,需要加锁
}
}
--- mheap.go:1226-1250
关键点:
pageCache是p(P 结构体)的字段pp.pcache,每 P 独有,所以c.alloc(npages)无锁。- 缓存为空时,
h.pages.allocToCache()一次性从页分配器拿 64 页 (pageCachePages)填充本地缓存。这一步需要加h.lock。 - 分配成功后还要
h.tryAllocMSpan()(mheap.go:1128)拿一个 mspan 结构体。mspan 结构体本身也有 P-local 缓存 (pp.mspancache)。两者都成功才能走HaveSpan。
为什么限制
npages < pageCachePages/4(< 16 页)? 大分配如果也走 pageCache,会让缓存很快被掏空、频繁触发补充,失去缓存意义。小分配走缓存命中率高、收益大。这是"缓存只服务高频小请求"的经典权衡。
7. 全局锁路径与堆增长
当快速路径不可用(分配太大、需要对齐、或没有 P)时,进入全局锁路径(mheap.go:1252-1306):
go
// mheap.go:1252-1306
lock(&h.lock)
if needPhysPageAlign {
// 物理页对齐分配:over-allocate 再对齐(用于栈分配)
extraPages := physPageSize / pageSize
base, _ = h.pages.find(npages + extraPages)
if base == 0 {
growth, ok = h.grow(npages + extraPages)
...
}
base = alignUp(base, physPageSize)
scav = h.pages.allocRange(base, npages)
}
if base == 0 {
// 常规分配
base, scav = h.pages.alloc(npages)
if base == 0 {
growth, ok = h.grow(npages) // 页分配器也没货 堆增长
if !ok {
unlock(&h.lock)
return nil
}
base, scav = h.pages.alloc(npages) // 增长后重试
if base == 0 {
throw("grew heap, but no adequate free space found")
}
}
}
if s == nil {
s = h.allocMSpanLocked() // 拿 mspan 结构体
}
unlock(&h.lock)
--- mheap.go:1252-1306(结构化简)
这条路径的三级递进是:
h.pages.alloc(npages)------ 页分配器手里有货,直接分配(最常发生)- 没货
h.grow(npages)------ 向 OS 要更多内存,扩展堆 grow后h.pages.alloc()重试 ------ 增长后必然有货;若仍没有则throw(不可达)
物理页对齐路径(
needPhysPageAlign)只用于栈分配 (typ == spanAllocStack)。它会多分配一个物理页来保证对齐,用h.pages.find()找足够大的区域,再alignUp对齐后allocRange精确切出需要的部分。
8. initSpan:span 初始化
拿到 base(起始地址)和 s(mspan 结构体)后,调用 initSpan() 把 span 填充完整(mheap.go:1430-1538):
go
// mheap.go:1430
func (h *mheap) initSpan(s *mspan, typ spanAllocType, spanclass spanClass, base, npages, scav uintptr) {
// 设置 span 属性
s.init(base, npages)
needZero := h.allocNeedsZero(base, npages)
...
if sizeclass := spanclass.sizeclass(); sizeclass == 0 {
// sizeclass == 0 大对象 span
s.elemsize = nbytes
s.nelems = 1
s.divMul = 0
} else {
// 小对象 span:按 size class 计算
s.elemsize = uintptr(gc.SizeClassToSize[sizeclass])
s.nelems = uint16(nbytes / s.elemsize)
s.divMul = gc.SizeClassToDivMagic[sizeclass]
}
s.freeindex = 0
s.allocCache = ^uint64(0) // 全 1 = 全部空闲
s.gcmarkBits = newMarkBits(uintptr(s.nelems))
s.allocBits = newAllocBits(uintptr(s.nelems))
s.limit = s.base() + s.elemsize*uintptr(s.nelems)
s.state.set(mSpanInUse)
// 发布到 arena 映射(页 span)
h.setSpans(s.base(), npages, s)
atomic.Or8(&arena.pageInUse[pageIdx], pageMask)
}
--- mheap.go:1430-1538(结构化简)
初始化做了几件关键的事:
- 设置 span 属性 (
mheap.go:1452-1477):根据spanclass的 size class 计算elemsize(对象大小)和nelems(对象个数)。sizeclass == 0表示大对象 span :elemsize = nbytes(整个 span 只有一个对象),nelems = 1。- 否则按 size class 表切分:
elemsize = SizeClassToSize[sizeclass],nelems = nbytes / elemsize。 - 注意 :真实的 scan span 分支(
mheap.go:1458-1475)在heapBitsInSpan(elemsize)为真时,会先在 span 尾部预留指针位图的空间 再算nelems(可用槽位变少);GreenTeaGC 开启时还会额外预留 inline mark bits(mheap.go:1458-1468)。上文简化代码略去了这两个预留分支,按"整页都可用于对象"展示主干逻辑。
- 初始化位图 (
mheap.go:1484-1485):allocBits(分配位图,全 0 = 全空闲)和gcmarkBits(GC 标记位图)。 allocCache = ^uint64(0)(mheap.go:1483):64 位补码缓存全 1,表示前 64 个槽位全部空闲,方便用 CTZ 指令快速找空闲位。- 发布到 arena 映射 (
mheap.go:1515):h.setSpans()建立页 span 的映射,供spanOf()反查。 - 标记
pageInUse(mheap.go:1524):在 arena 的pageInUse位图置位,让清扫器/回收器能看到这个 span。
state.set(mSpanInUse)是发布屏障 (mheap.go:1505):只有 span 被完全初始化后,才把状态置为mSpanInUse。这样 GC 即使并发看到一个可疑指针指向这个 span,也能通过原子的状态检查避免读到半初始化数据。
9. grow:堆增长
当页分配器也没有空闲页时,allocSpan 调用 grow() 向 OS 要内存(mheap.go:1544-1654):
go
// mheap.go:1544
func (h *mheap) grow(npage uintptr) (uintptr, bool) {
assertLockHeld(&h.lock)
firstGrow := h.curArena.base == 0
// 必须在整个 palloc chunk 上增长(至少 4MB)
ask := alignUp(npage, pallocChunkPages) * pageSize
end := h.curArena.base + ask
nBase := alignUp(end, physPageSize)
if nBase > h.curArena.end || end < h.curArena.base {
// 当前 arena 空间不足 向 OS 申请新 arena
av, asize := h.sysAlloc(ask, &h.arenaHints, &h.heapArenas)
if av == nil {
print("runtime: out of memory: cannot allocate ", ask, "-byte block\n")
return 0, false
}
// ... 处理新旧 arena 的切换、pages.grow 等
}
// 在当前 arena 内增长
v := h.curArena.base
h.curArena.base = nBase
// Reserved Prepared
sysMap(unsafe.Pointer(v), nBase-v, &gcController.heapReleased, "heap")
// 更新页分配器元数据
h.pages.grow(v, nBase-v)
totalGrowth += nBase - v
...
return totalGrowth, true
}
--- mheap.go:1544-1654(结构化简)
grow 的关键点:
ask := alignUp(npage, pallocChunkPages) * pageSize(mheap.go:1554):增长量向上取整到 palloc chunk (512 页 = 4MB)的整数倍。因为页分配器以 chunk 为单位管理元数据,这样减少sysMap调用次数(注释mheap.go:1550明确说不会调用太多次)。- 优先在当前 arena 内增长 (
mheap.go:1616-1636):如果当前curArena空间够,直接往前推进curArena.base。 - arena 不够则
sysAlloc新 arena (mheap.go:1565):向 OS 申请新的地址空间区域,可能和旧的连续或不连续。 sysMap把 Reserved Prepared (mheap.go:1625):真正把虚拟地址映射为可用的内存(对应 OS 三状态模型中的 Reserved Prepared)。h.pages.grow()扩展页分配器元数据 (mheap.go:1635):让新区域可以被页分配器管理。
物理内存的提交(Prepared Ready)是惰性的 :
grow只做了sysMap(映射虚拟地址),真正把物理页提交出去(sysUsed)发生在allocSpan尾部,只在 span 实际被使用时才发生。
10. freeSpanLocked:span 释放
有分配就有释放。freeSpanLocked 是把一个 span 归还给页分配器的函数(mheap.go:1721-1777):
go
// mheap.go:1721
func (h *mheap) freeSpanLocked(s *mspan, typ spanAllocType) {
assertLockHeld(&h.lock)
// 校验状态
switch s.state.get() {
case mSpanManual:
if s.allocCount != 0 {
throw("mheap.freeSpanLocked - invalid stack free")
}
case mSpanInUse:
...
if s.allocCount != 0 || s.sweepgen != h.sweepgen {
throw("mheap.freeSpanLocked - invalid free")
}
h.pagesInUse.Add(-s.npages)
// 清除 arena 的 in-use 位
atomic.And8(&arena.pageInUse[pageIdx], ^pageMask)
default:
throw("mheap.freeSpanLocked - invalid span state")
}
// 更新统计(镜像 allocSpan 的记账)
...
// 把页归还给页分配器
h.pages.free(s.base(), s.npages)
// 标记 span 死亡并释放结构体
s.state.set(mSpanDead)
h.freeMSpanLocked(s)
}
--- mheap.go:1721-1777(结构化简)
关键点:
- 校验 (
mheap.go:1724-1749):span 必须满足allocCount == 0(没有分配出去的对象)且sweepgen == h.sweepgen(已清扫过)才能释放,否则报错。 h.pages.free()(mheap.go:1772):把 span 占用的连续页归还给页分配器,这些页以后可以被重新分配。h.pagesInUse.Add(-s.npages)(mheap.go:1737):更新在用页统计。- 清除
pageInUse位 (mheap.go:1741):让页回收器不再把它当作在用内存。 s.state.set(mSpanDead)+h.freeMSpanLocked(s)(mheap.go:1775-1776):span 状态变为死亡,mspan 结构体归还到 P-local 缓存或 fixalloc 池。
谁来调用
freeSpanLocked? 它由清扫器(sweeper)在mspan.sweep()中调用:当一个 span 的所有对象都变成了垃圾(allocCount == 0),整个 span 就被整体归还给 mheap。小对象通常留在 mcentral 复用,只有整块空出的 span 才回到 mheap。
11. 为什么必须 systemstack?
从第 4 节开始我们反复提到"必须在系统栈执行"。这是 Go 内存分配器里一个极其重要的细节。
问题的根源是栈增长:
- 普通 goroutine 的栈是动态增长 的。当栈不够用时,runtime 会调用
morestack栈增长代码。 - 栈增长本身需要分配内存(新的更大的栈段)。
- 如果此时已经持有
h.lock,栈增长又要去分配内存、再尝试获取h.lock------自己锁自己,造成死锁!
源码注释说得很清楚(mheap.go:67-68):
go
// lock must only be acquired on the system stack, otherwise a g
// could self-deadlock if its stack grows with the lock held.
g0 栈 vs g 栈 :每个 M 有一个 g0 栈(系统栈),它是固定大小的,不会增长。把持锁操作放到 g0 栈上执行,就彻底避免了"持锁期间栈增长 再分配 再拿同一把锁"的死锁循环。
所以:
alloc()用systemstack(func(){...})包裹(mheap.go:1002)。allocSpan声明//go:systemstack(mheap.go:1214)。freeManual/freeSpanLocked同样要求系统栈(mheap.go:1700)。
//go:systemstack是什么? 这是一个编译器指令,强制函数在 g0(系统)栈上运行。它和systemstack(func(){...})的运行时切换等价,但由编译器在调用点直接处理,更高效。
12. 总结与一览表
核心要点
- mheap 是全局唯一的堆管理器,持有页分配器、Arena 映射、136 个 mcentral、fixalloc 池和清扫状态。
alloc(npages, spanclass)是入口:先reclaim再allocSpan(mheap.go:997)。allocSpan有两条路径 :P-local 页缓存(无锁,< 16 页)和全局锁路径(pages.alloc grow 重试)。initSpan负责把 span 填完整:elemsize/nelems/ 位图 / 发布到 arena。grow在页分配器没货时向 OS 要内存,按 4MB chunk 增长。freeSpanLocked把整块空出的 span 归还给页分配器。- 必须 systemstack 以防止持锁期间栈增长导致的死锁。
mheap 关键函数索引
| 函数 | 行号 | 作用 |
|---|---|---|
mheap 结构体 |
mheap.go:64 |
全局堆管理器定义 |
spanClass / numSpanClasses |
mheap.go:573-581 |
136 个 spanClass 的编码 |
alloc() |
mheap.go:997 |
分配入口,reclaim + allocSpan |
setSpans() |
mheap.go:1039 |
建立页 span 映射 |
allocNeedsZero() |
mheap.go:1063 |
判断内存是否需要清零 |
tryAllocMSpan() |
mheap.go:1128 |
P-local mspan 缓存(无锁) |
allocMSpanLocked() |
mheap.go:1151 |
持锁分配 mspan 结构体 |
allocSpan() |
mheap.go:1215 |
核心分配流程 |
initSpan() |
mheap.go:1430 |
span 初始化 |
grow() |
mheap.go:1544 |
堆增长 |
freeManual() |
mheap.go:1701 |
释放手动管理的 span |
freeSpanLocked() |
mheap.go:1721 |
span 释放,归还页 |
与前后文的联系
- 上游 :08. mcentral 的
mcentral.grow()最终调用mheap.alloc()。 - 下游 :下一章 10. Large 对象分配 会看到,大对象直接 调用
mheap.alloc(),绕过了 mcache/mcentral。 - 内部结构 :
pages(12. pageAlloc)、arenas(14. Arena)、fixalloc(16. fixalloc)在后续章节会逐一深入。