第17章 内存管理
引言:第16章给出了 Go 内存管理的“全景图“。本章把镜头拉近到数据结构层面,逐字段拆解 mspan、mcache、mcentral、mheap、arena、treap,并解释对象从
mallocgc到 mmap 的完整路径。读完本章,你应该能解释“为什么 Go 分配 8 字节比 1KB 还快“、“为什么make([]byte, 1)不一定进堆“以及“arena、span、page 三层抽象的边界在哪里”。
Heap
1. 是什么
Go 的“堆“有两层含义:
- 逻辑堆:用户通过
new、make、取地址逃逸得到的对象,由 GC 管理。 - 物理堆:runtime 通过
mmap向 OS 申请的一大片连续虚拟地址空间,按 arena 切大块、按 page 切小块,由mheap全局唯一实例统筹分配。
物理堆是逻辑堆的“地基“。所有逃逸对象最终落在物理堆的某个 page 上,由 mspan 标记“这段 page 当前按 size class X 分配“。
2. 底层数据结构
64 位 Linux/amd64 下,堆地址空间按 arena(也叫 heap arena)划分,每块 64 MiB。heapArena 是每块 arena 的“元数据页“:
package runtime
const (
heapArenaBytes = 67108864 // 64 MiB
pageSize = 8192 // 8 KB
pagesPerArena = heapArenaBytes / pageSize // 8192
arenaL1Bits = 1 // 64 位用两级 radix,第一级很浅
arenaL2Bits = 22 // 第二级 4M 槽
)
// heapArena 是每块 64MB arena 的元数据
type heapArena struct {
// 该 arena 内每个 page (8KB) 指向哪个 mspan
spans [pagesPerArena]*mspan
// 每个 word (8B) 对应若干指针 bitmap bit
// 2 bit/指针:1=指针、1=标记
bitmap [heapArenaBytes / (8 * 8)]uint8 // 简化示意
// pageAlloc: 每 page 的分配状态、scavenge 状态等
// 实际由 mheap.pages 字段统一管理
}
mheap 是全局唯一实例(var mheap_ mheap),它持有所有 arena、所有 mcentral、所有空闲 page:
package runtime
import "unsafe"
type mheap struct {
lock mutex
// 全局 mcentral:每个 size class(含 noscan 变体)一个,加 cache line pad 防伪共享
central [numSpanClasses]struct {
mcentral mcentral
_ [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
// 空闲 page 的若干组织方式
free mTreap // 空闲、可分配的 span 集合(按地址 + 随机优先级)
busy mTreap // 已分配但可 scavenged 的大块
scavenge mTreap
// 按"最少页数"分桶的链表,O(1) 找够 N 页的 span
freepages mSpanList
// arena 元数据两级 radix
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// arena 增长相关
curArena struct {
base, end uintptr
}
// 全局统计与 page 状态
pages pagesAlloc // 每 page 的 allocBits
sweepgen uint32 // 当前 sweep 代号
sweepdone uint32
// release / scavenge
scavengeGoal uintptr
scav scavengerState
// 各类大对象统计
largealloc uint64
nlargealloc uint64
largefree uint64
nlargefree uint64
// ...
}
字段解释:
central[136]:每个 size class(67 个 × 2 scan/noscan)一个 mcentral,加 cache line pad 防伪共享。freetreap:空闲 span 集合,按地址排序便于合并相邻 span,按优先级近似平衡二叉树,期望 O(log n)。arenas:两级 radix 树索引所有 heapArena 元数据。给定任意堆指针p,能 O(1) 定位其所在 arena 与 page。curArena:当前正在用的 arena 边界,分配新 arena 时从这里“切“。sweepgen:每轮 sweep 加 1,mspan 用sweepgen比对判断“是否要回收“。
分配路径(伪代码):
package main
func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
var s *mspan
systemstack(func() {
lock(&h.lock)
s = h.allocSpan(npages, spanclass, needzero)
unlock(&h.lock)
})
return s
}
func (h *mheap) allocSpan(npages uintptr, spanclass spanClass, needzero bool) *mspan {
// 1) 优先 free 桶链表找
// 2) free treap 找,必要时切分
// 3) 都没有 → grow (mmap 新 arena)
// 4) 初始化 mspan:sweepgen、spanclass、allocBits
return nil // 简化
}
3. 工程实践与常见坑
- 大对象直接走 mheap:>32KB(67 个 size class 之外)的对象会绕开 mcache/mcentral,直接向 mheap 要
ceil(size/8KB)页。这意味着每次分配都拿全局mheap.lock——并发大对象分配是显著瓶颈。 arena不是mmap一次 64MB:mmap单位更大(heapArenaBytes量级),arena 元数据是按需分配的。/proc/<pid>/maps看到的大块匿名段即此。MADV_DONTNEED与MADV_FREE:runtime 在scavenge时把空闲页归还给 OS,Linux 默认用MADV_FREE(懒回收,再次访问不需缺页),达到scavengeGoal时才退化MADV_DONTNEED。GODEBUG=madvdontneed=1强制后者,便于 RSS 监控对齐。- 观察物理堆:
runtime.ReadMemStats的HeapSys(OS 给的总堆虚拟内存)、HeapInuse(已分配)、HeapIdle(空闲未还)、HeapReleased(已还给 OS)。HeapIdle - HeapReleased是 runtime 持有但未分配的“缓存“,可被复用但占 RSS。 - 容器 OOM 与
HeapReleased:HeapIdle高但HeapReleased低意味着 RSS 高但实际活对象少;调高GOMEMLIMIT触发更激进 scavenge 可缓解。 - 不要
runtime.MemProfileRate = 1:默认 512KB 采样,开 1 是全量记录,每 alloc 都进 map,分配本身被拖慢数倍。
Stack
1. 是什么
每个 goroutine 有独立栈。与 OS 线程固定栈不同,Go 栈起步 2KB(_StackMin),按需扩展,最多 1GB(_StackMax)。栈空间来自堆(准确地说是来自 mheap 切出的 mspan),但栈对象不参与 GC mark——扫描时只扫栈根,不扫栈内对象图。
逃逸分析决定对象在栈还是堆。栈分配几乎免费:移动 SP 指针即可,无 GC 压力,函数返回自动回收。
2. 底层数据结构
package runtime
type stack struct {
lo uintptr
hi uintptr
}
type g struct {
stack stack
stackguard0 uintptr // 序言比较 SP 与之;不足则 morestack
stackalloc uintptr // 栈所用 mspan 起始地址
// ...
}
栈扩容流程(runtime/stack.go):
- 函数序言:
cmp SP, g.stackguard0,若 SP < stackguard0 调runtime.morestack。 morestack→newstack:- 计算新大小(通常 2 倍,最多翻到
_StackMax); - 分配新栈(来自 mheap 的 stack pool 或直接 mallocgc);
copystack:把旧栈整段 memcpy 到新栈,并调整所有指向旧栈内部的指针——靠栈帧里的stackmap元数据知道哪些槽是指针;- 释放旧栈回 stack pool;
gogo切回用户代码,从函数序言重入。
- 计算新大小(通常 2 倍,最多翻到
栈缩容:GC 时检测 goroutine 实际使用 < 1/4,且未在系统调用中,会把它缩到 2 倍使用量。
栈分配的 mspan 走专门 size class(栈大小离散:2K、4K、8K、16K、32K…),有一个 stack pool 缓存:
package runtime
var stackpool [_NumStackOrders]struct {
item stackpoolItem
_ [cpu.CacheLinePadSize]byte
}
type stackpoolItem struct {
lock mutex
list mSpanList
}
关键细节:
copystack是 STW 的(针对单个 G 的 STW,但会 stop 该 M)。栈拷贝本身只发生一次(扩容后通常够用整个 goroutine 生命周期),所以总开销摊薄后很低。
3. 工程实践与常见坑
runtime.Stack与runtime.Callers:前者返回格式化字符串(含分配),后者直接拿 PC 数组,热点路径用后者。- 深递归 → 栈溢出 fatal:
runtime: goroutine stack exceeds 1000000000-byte limit直接 crash,无 recover。深递归算法在 Go 里不友好,应改迭代或换语言。 //go:nosplit函数:禁止扩容,函数序言跳过 SP 检查。用于 runtime 内部极小函数(如gcWriteBarrier),用户代码用错会栈溢出。//go:nosplit必须保证栈足够(≤_StackGuard)。- CGO 调用切到
g0栈:每次 CGO 调用都在 M 的g0栈上执行(更大、固定),切回 G 时恢复 G 栈。频繁 CGO 调用有上下文切换开销。 - 栈上 string 拼接:
s := "a" + "b"这种常量拼接编译期完成;运行期fmt.Sprintf大多逃逸。+链式拼接 N 个 string 是 O(N²)。 - 逃逸分析边界:
func f() *int { x := 1; return &x }会逃逸(返回局部指针),但func f() int { x := 1; return x }不会。go build -gcflags='-m -l'看分析结果(-l关内联避免干扰)。
Tiny Allocator
1. 是什么
mallocgc 对 无指针(noscan) 的微小对象(<16B)走 tiny allocator:把多个微对象合并放进一个 16B 的 tiny block 里,由 mcache 持有。这是 Go 1.2 引入的优化,针对“大量 []byte(1)、int32、bool 之类“的碎片化场景。
2. 底层数据结构
mcache 里维护一个 tiny slot:
package runtime
type mcache struct {
// 微分配器
tiny uintptr // 当前 tiny block 起始地址
tinyoffset uintptr // 已用偏移
tinyAllocs uintptr // 统计
// ...
alloc [numSpanClasses]*mspan // 每类 size 一个 mspan
}
分配逻辑(runtime/msize.go 与 malloc.go,简化):
package main
import "unsafe"
const maxTinySize = 16
func mallocgc(size uintptr, typ unsafe.Pointer, needzero bool) unsafe.Pointer {
if size <= maxSmallSize {
noscan := typ == nil /* || !typ.Pointers() */
if size <= maxTinySize && noscan {
c := getg().m.p.ptr().mcache
off := c.tinyoffset
// 对齐
if size&7 == 0 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
if off+size <= maxTinySize {
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
return x
}
// 当前 tiny block 装不下,分配新 16B(span class 1 的 noscan 变体)
// 旧 block 让 GC 自然回收(已分配的对象仍存活)
}
// ... 普通 size class 路径
}
// ... 大对象路径
return nil
}
要点:
- 只有 noscan 才能合并:含指针对象不能合并——合并后扫描时无法区分边界,可能扫到下一个对象的“假指针“。每个小对象自身就是有效指针图。
- 大小 ≤16B:上限与
maxTinySize对齐,过大会浪费。 - 对齐:tiny block 内 8B 对齐优先,4B/2B/1B 依次退让,保证字段访问对齐。
- 释放:单个小对象不能主动 free,等整个 16B block 所有小对象都不可达时由 GC 整体回收。
3. 工程实践与常见坑
make([]byte, 1)的真实代价:不逃逸时栈分配;逃逸时进 tiny allocator,与下一个<16Bbyte 数组挤同一个 block。所以一次性“临时 1B 缓冲“不会真触发 16B 分配。sync.Pool与 tiny allocator 协同:Pool 缓存的对象仍可能进 tiny allocator(如果它没指针且 ≤16B),但池的意义更多在于“避免重新初始化“。string(1byte)的逃逸:string是 16B 头(指针 + 长度),合并 tiny 没意义,但其 backing array 是单独的 1B/2B…/16B 分配,可走 tiny。- 不要靠 tiny allocator 做“零拷贝“:tiny block 是 mcache 私有,跨 goroutine 传递指针等于跨 P 传递——对象会因不可达被 GC,但内存复用是 mcache 级,不保证。
bool/int8字段逃逸时几乎免费:tiny allocator 让这类小对象分配成本极低,但对象计数仍计入 GC——海量小对象即便每次便宜,累计扫描仍是压力。
Span
1. 是什么
mspan 是 mheap 分配给 mcache/mcentral 的“基本单元“,由若干连续 8KB page 组成,绑定一个 size class。可以把它理解为“一摞定长对象槽“:分配时不切割 page,而是按 size class 把 page 内每个对象槽标记 已分配/空闲。
每个 mspan 要么属于一个 size class(“分配中的 span”),要么是“空闲 span“(按 page 给 mheap 管理)。
2. 底层数据结构
package runtime
import "unsafe"
const (
pageSize = 8192
numSpanClasses = _NumSizeClasses * 2 // 67*2 = 136
maxSmallSize = 32768 // 32KB
)
type mspan struct {
next *mspan // 链表
prev *mspan
startAddr uintptr // 起始堆地址
npages uintptr // page 数
nelems uintptr // 对象槽总数 = npages*pageSize/elemSize
limit uintptr // 末尾地址
// allocBits: 每个对象槽 1 bit,1=已分配
allocBits *gcBits
allocCount uint16 // 已分配槽数
// GC 双缓冲:mark 期写 gcmarkBits,sweep 期换为 allocBits
gcmarkBits *gcBits
sweepgen uint32 // 该 span 的"代",与 mheap.sweepgen 比较
// size class 信息(用于快速计算槽 index)
divShift uint8
divMul uint16
divShift2 uint8
baseMask uintptr
elemsize uintptr // = npages*pageSize / nelems,定长
// span class:低 7 位是 size class,最高位是 noscan
spanclass spanClass
state mSpanStateBox // _MSpanDead/InUse/Manual/Free
scavenged bool // 是否已 scavenge
needzero bool // 下次 alloc 是否要清零
// ...
}
type spanClass uint8
type gcBits struct {
_ unsafe.Pointer // 实际是位图数组
}
关键操作:
package main
// 在 span 内分配一个槽
func (s *mspan) nextFreeIndex() uintptr {
for i := s.freeindex; i < s.nelems; i++ {
if s.allocBits.bit(i) == 0 {
s.freeindex = i + 1
return i
}
}
return s.nelems
}
// mcache 用光时归还/换新
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
// 把用过的 span 交还 mcentral
if s.allocCount != s.nelems {
mcentral_.uncacheSpan(s)
}
// 拿一个 partial span
s2 := mcentral_.cacheSpan(spc)
c.alloc[spc] = s2
}
sizeclasses.go 里 67 个 class 的定义(前几个):
| class | elemsize | span 页数 | 一次 span 元素数 |
|---|---|---|---|
| 1 | 8 B | 1 | 8192 |
| 2 | 16 B | 1 | 4096 |
| 3 | 24 B | 1 | 2730 |
| 4 | 32 B | 1 | 2048 |
| 5 | 48 B | 1 | 1365 |
| 6 | 64 B | 1 | 1024 |
| 7 | 80 B | 1 | 819 |
| … | … | … | … |
| 67 | 32768 B | 8 | 8 |
class 之间留“间隔“是为了内存利用率:8/16/24/32 是 8 的倍数;48 跨 16 的倍数;后续按 ~1.15× 递增。整体在“碎片少“与“类数少“间权衡,67 类是经验最优。
3. 工程实践与常见坑
- 同一 size class 的对象不能跨 span 共享:mcache 持有的 span 是 P 私有,跨 P 传指针正常,但 mcache 统计是 per-P。
allocBits不是 mark bits:allocBits表示“是否已分配“,GC 时复制为gcmarkBits后清零,标记阶段往gcmarkBits写。Sweep 时allocBits = gcmarkBits,未标记的槽自然变成“空闲“。sweepgen让 sweep 大部分无锁:mcache refill 时用 CAS 把 span 的 sweepgen 与全局对齐,对齐后该 span 的 free 操作不需 mheap.lock。- 大对象不进 size class:>32KB 直接 mheap,分配出来是个
npages>4的 mspan 但spanclass=0(“大对象“标志),整块作为一个对象。GC 时直接判断可达性,无 allocBits 维护。 freeindex是优化:从上次分配位置开始扫描,避免每次从头扫 allocBits。若freeindex == nelems说明该 span 满,需 refill。
MCache
1. 是什么
mcache 是 每个 P 一个 的本地分配缓存,是无锁快路径。mallocgc 大多数命中这里,无需任何原子或锁操作。每个 mcache 持有 136 个 mspan(每个 size class + noscan 变体一个),加 tiny allocator 状态。
2. 底层数据结构
package runtime
type mcache struct {
// 微分配器
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// 每类一个 mspan,指向当前可分配的 span
alloc [numSpanClasses]*mspan // 136 个
// 栈分配用栈池(栈大小对应几个离散 size class)
stackcache [_NumStackOrders]stackfreelist
// GC 与扫描辅助
scanAlloc uintptr // 本 P 的扫描信用
flushGen uint32 // 上次 flush 的 sweepgen
// padding 防伪共享(编译期固定大小)
// ...
}
mcache 与 P 的关系:
package runtime
type p struct {
mcache *mcache // 仅在 _Prunning 状态非 nil
// ...
}
分配快路径(简化):
package main
import "unsafe"
func (c *mcache) malloc(spc spanClass, size uintptr) unsafe.Pointer {
s := c.alloc[spc]
if s == nil {
s = c.refill(spc)
}
// 找一个 free 槽
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
s = c.refill(spc)
freeIndex = s.nextFreeIndex()
}
x := unsafe.Pointer(s.startAddr + freeIndex*s.elemsize)
s.allocBits.setBit(freeIndex)
s.allocCount++
s.freeindex = freeIndex + 1
return x
}
refill 流程:把当前 span(用光或不再适合)归还 mcentral,从 mcentral 拿一个 partial span。如果 mcentral 也没有,mcentral 向 mheap 申请。
为什么无锁:每个 P 同一时刻只被一个 M 绑定执行,mcache 是 P 私有——并发只发生在 P 切换 M 时,runtime 保证切换时 mcache 状态一致(releasep/acquirep 会 flush)。
3. 工程实践与常见坑
GOMAXPROCS=1时只有 1 个 mcache:所有分配串行化同一缓存。无锁优化失效,但缓存命中率反而高。runtime.GOMAXPROCS减小会“丢弃“mcache:P 进入_Pdead时其 mcache 被释放,span 归还 mcentral。再次增大GOMAXPROCS会重新分配。频繁调整GOMAXPROCS有抖动。debug.SetGCPercent不影响 mcache:mcache 是分配路径,GC 是回收路径,二者解耦。runtime.NumCPUvsGOMAXPROCS:mcache 数量 =GOMAXPROCS,与 CPU 数无关。容器里设小GOMAXPROCS会减少 mcache 数,可能让“分配热点集中“反而变快——但通常会因为并发度降低而变慢。prepareForSyscall会 flush mcache:M 进入长时间 syscall 前 P 会被拿走,下次回到 P 时 mcache 已被其他 M 使用过——但这种切换由 runtime 保证 mcache 状态正确,用户无感知。
MCentral
1. 是什么
mcentral 是 每个 size class 一个 的中央缓存,所有 P 共享。它持有若干 mspan,分为 partial(有空闲槽)和 full(无空闲槽)两组,每组再分 swept(已扫)和 unswept(待扫)共 4 个集合。Mcache 在 miss 时向 mcentral 要 span,mcentral 是带锁的中路径。
2. 底层数据结构
package runtime
type mcentral struct {
spanclass spanClass
partial [2]spanSet // partial swept / partial unswept
full [2]spanSet // full swept / full unswept
// Go 1.21 改用 spanSet(lock-free 友好的环形结构),
// 早期版本是带 mutex 的 mSpanList。
}
type spanSet struct {
spine **mspan // 二级数组,可动态扩容
spineLen uint32 // 当前 spine 长度
spineCap uint32
head uint32 // 消费端索引
tail uint32 // 生产端索引
}
spanSet 的设计目标是 并发友好:生产(mcache 归还)和消费(mcache 申请)可以并发,仅 head/tail 越界时才需要扩容锁。它取代了 Go 1.20 之前的 mSpanList + mutex,在多 P 高并发分配时显著降低锁竞争。
核心操作:
package main
// mcache 申请一个有空闲槽的 span
func (c *mcentral) cacheSpan() *mspan {
// 1) partial[swept]:最理想
s := c.partial[0].pop()
if s != nil {
return s
}
// 2) partial[unswept]:尝试 sweep 后用
s = c.partial[1].pop()
if s != nil {
sweepOneSpan(s)
return s
}
// 3) full[swept]:满了,但 GC 后可能有空间——这里不取
// 4) 都没有 → 向 mheap grow
s = c.grow()
return s
}
// mcache 归还
func (c *mcentral) uncacheSpan(s *mspan) {
if s.allocCount == 0 {
// 完全空,直接归还 mheap
mheap_.freeSpan(s)
return
}
if s.sweepgen == mheap_.sweepgen {
c.partial[0].push(s) // swept
} else {
c.partial[1].push(s) // unswept
}
}
mcentral.grow() 路径:向 mheap 申请一个 npages 的 span,初始化为该 size class 的 mspan,加入 partial。
3. 工程实践与常见坑
- mcentral 锁是分配热点的“次瓶颈“:Go 1.21 spanSet 改造后竞争大幅下降,但极端场景(很多 P 频繁 miss mcache)仍可见。可通过减少分配、用
sync.Pool跨 goroutine 复用来规避。 partial/full×swept/unswept四象限的意义:保证 sweep 与分配并发——分配优先用已扫 span,未扫 span 留给后台 sweep worker 处理,避免分配路径触发同步 sweep。GOGC太小 → mcentral 频繁 grow/shrink:GC 太频繁让 span 一直在 mheap/mcentral/mcache 间搬运,CPU 烧在搬运上而非业务。gctrace=1看scvg行(scavenge)能观察。sync.Pool实际复用的是 mcache 路径:Pool 的Get/Put在 per-PpoolLocal内进行,底层仍是 mcache 分配——所以 Pool 减少 GC 而非减少 mallocgc 调用次数(虽然两者经常一起降)。- 观察 mcentral 命中率:
runtime/metrics没有直接暴露 mcentral 命中率,但gctrace=1的scvg行与runtime.ReadMemStats的BySize数组能间接反映各 size class 的活跃度。
MHeap
1. 是什么
mheap 是 全局唯一 的堆管理器,持有所有 arena、所有 mcentral、所有空闲 page。它向 OS 申请内存(mmap),按 page (8KB) 切分,按需 scavenge 还给 OS(madvise)。任何 mcentral miss 最终都会到 mheap;任何大对象(>32KB)直接走 mheap。mheap_ 是全局变量,访问需 mheap.lock(除只读元数据路径)。
2. 底层数据结构
package runtime
var mheap_ mheap
type mheap struct {
lock mutex
// 每类一个 mcentral(136 个,cache line padded)
central [numSpanClasses]struct {
mcentral mcentral
_ [cpu.CacheLinePadSize]byte
}
// 空闲 span 树
free mTreap // 空闲、可分配
busy mTreap // 大对象已分配、可 scavenge
scavenge mTreap
// 桶式快速查找:每个 page 数对应一个 mSpanList
// pagesInUse 统计
pagesInUse atomic.Uintptr
// arena 元数据两级 radix
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
curArena struct {
base, end uintptr
}
// 全局 sweep 状态
sweepgen uint32
sweepdone atomic.Bool
sweepers atomic.Uint32
// scavenge 状态
scavengeGoal atomic.Uintptr
scav scavengerState
// 大对象统计
largealloc atomic.Uint64
nlargealloc atomic.Uint64
largefree atomic.Uint64
nlargefree atomic.Uint64
// ...
}
treap(笛卡尔树):每个节点是一个空闲 mspan,键是 startAddr(满足 BST 性质便于找相邻 span 合并),优先级是随机数(满足堆性质近似平衡):
package runtime
type treapNode struct {
span *mspan
priority uint32 // 随机
left, right *treapNode
}
type mTreap struct {
treapRoot *treapNode
treapAlloc *treapNode // 节点池
}
allocSpan 主要路径(简化):
package main
func (h *mheap) allocSpan(npages uintptr) *mspan {
// 1) 查 free 桶(O(1) 找 size 匹配)
if s := h.freepages.first(npages); s != nil {
return h.carve(s, npages) // 可能切分
}
// 2) 查 free treap(找 >= npages 的最小连续段)
if s := h.free.find(npages); s != nil {
return h.carve(s, npages)
}
// 3) 都没有 → grow:mmap 新 arena
h.grow(npages)
return h.allocSpan(npages)
}
grow 路径(简化):
package main
func (h *mheap) grow(npages uintptr) {
// 1) 在 curArena 内切
// 2) 不够 → mmap 一大块(heapArena 量级)
v := sysReserve(nil, arenaSize)
// 初始化 heapArena 元数据
// 更新 arenas radix
// 把新 span 加入 free treap
}
scavenge 路径(简化):
package main
import "unsafe"
func (h *mheap) scavengeAll() uintptr {
var released uintptr
for {
s := h.scavenge.popMin() // 取最小且满足"空闲"的 span
if s == nil {
break
}
released += uintptr(s.npages) * pageSize
sysUnused(unsafe.Pointer(s.startAddr), uintptr(s.npages)*pageSize)
// sysUnused 内部:Linux 用 MADV_FREE 或 MADV_DONTNEED
}
return released
}
3. 工程实践与常见坑
mheap.lock是大对象分配的瓶颈:高并发大对象(如make([]byte, 64<<10))会让mheap.lock成为热点。优化方向:复用 buffer(sync.Pool/bytes.Buffer)、流式处理而非大 buffer。- scavenge 节奏与 RSS:
GOMEMLIMIT设置后会触发更频繁 scavenge,RSS 峰值降低但MADV_FREE的“软释放“特性意味着 RSS 不立即下降——只有内存压力来时 OS 才真正回收。 GODEBUG=madvdontneed=1:强制MADV_DONTNEED,RSS 立即下降,但再次访问触发缺页中断,性能略降。监控对齐 RSS 时常用。- cgroup 与
GOMEMLIMIT:Go 1.21 仍未自动读 cgroup 限制(automaxprocs解决 GOMAXPROCS 但不解决 GOMEMLIMIT),手动设GOMEMLIMIT= cgroup 内存上限的 ~90% 是稳妥做法。 - 大对象释放“懒惰“:
mheap.freeSpan把 span 放回 free treap,但不立即madvise——等 scavenge 触发。这意味着“释放大对象后 RSS 不掉“是正常现象。 - arena 元数据占用:每个 64MB arena 配一个
heapArena(约 8KB 的 spans 数组 + bitmap),1GB 堆约 16 个 arena,元数据 ~128KB——可忽略。但嵌入式小内存设备要注意。 - 观察 mheap 行为:
runtime.ReadMemStats的HeapSys/HeapInuse/HeapIdle/HeapReleased直接反映 mheap 视角;debug.GCStats的PauseTotalNs间接反映 mheap sweep 的代价。
本章小结
- Go 堆采用 arena → page → span 三层抽象:64MB arena 由 mmap 切出,8KB page 是最小管理单元,mspan 把连续 page 绑定到 size class 提供定长对象槽。
- mcache → mcentral → mheap 三级缓存:mcache per-P 无锁快路径,mcentral per-size-class 中路径(带锁但 Go 1.21 用 spanSet 降低竞争),mheap 全局唯一慢路径(含 treap 与 arena radix)。
- tiny allocator 把 ≤16B 无指针对象合并进 16B block,缓解碎片。
- 栈分配独立于堆,逃逸分析决定栈/堆,栈扩缩容靠
copystack(依赖 stackmap 调整指针)。 - GC 与分配解耦:
allocBits跟踪分配,gcmarkBits跟踪可达,sweepgen让 sweep 大部分无锁。 - 工程上:减少逃逸、复用 buffer、合理设
GOMEMLIMIT与GOGC、容器内调GOMAXPROCS、必要时madvdontneed=1对齐 RSS 监控。
掌握这些数据结构后,回到 第16章 Runtime 总览 的全景图就不再有“黑盒“;后续 第18章 GC 会基于 allocBits/gcmarkBits/sweepgen 展开标记-清扫细节,第10章 Goroutine 会基于 P/mcache 绑定关系解释抢占与 syscall 让出 P 的内存侧影响。