Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第17章 内存管理

引言:第16章给出了 Go 内存管理的“全景图“。本章把镜头拉近到数据结构层面,逐字段拆解 mspan、mcache、mcentral、mheap、arena、treap,并解释对象从 mallocgc 到 mmap 的完整路径。读完本章,你应该能解释“为什么 Go 分配 8 字节比 1KB 还快“、“为什么 make([]byte, 1) 不一定进堆“以及“arena、span、page 三层抽象的边界在哪里”。

Heap

1. 是什么

Go 的“堆“有两层含义:

  • 逻辑堆:用户通过 newmake、取地址逃逸得到的对象,由 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 防伪共享。
  • free treap:空闲 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 一次 64MBmmap 单位更大(heapArenaBytes 量级),arena 元数据是按需分配的。/proc/<pid>/maps 看到的大块匿名段即此。
  • MADV_DONTNEEDMADV_FREE:runtime 在 scavenge 时把空闲页归还给 OS,Linux 默认用 MADV_FREE(懒回收,再次访问不需缺页),达到 scavengeGoal 时才退化 MADV_DONTNEEDGODEBUG=madvdontneed=1 强制后者,便于 RSS 监控对齐。
  • 观察物理堆runtime.ReadMemStatsHeapSys(OS 给的总堆虚拟内存)、HeapInuse(已分配)、HeapIdle(空闲未还)、HeapReleased(已还给 OS)。HeapIdle - HeapReleased 是 runtime 持有但未分配的“缓存“,可被复用但占 RSS。
  • 容器 OOM 与 HeapReleasedHeapIdle 高但 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):

  1. 函数序言:cmp SP, g.stackguard0,若 SP < stackguard0 调 runtime.morestack
  2. morestacknewstack
    • 计算新大小(通常 2 倍,最多翻到 _StackMax);
    • 分配新栈(来自 mheap 的 stack pool 或直接 mallocgc);
    • copystack:把旧栈整段 memcpy 到新栈,并调整所有指向旧栈内部的指针——靠栈帧里的 stackmap 元数据知道哪些槽是指针;
    • 释放旧栈回 stack pool;
    • gogo 切回用户代码,从函数序言重入。

栈缩容: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.Stackruntime.Callers:前者返回格式化字符串(含分配),后者直接拿 PC 数组,热点路径用后者。
  • 深递归 → 栈溢出 fatalruntime: 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)int32bool 之类“的碎片化场景。

2. 底层数据结构

mcache 里维护一个 tiny slot:

package runtime

type mcache struct {
	// 微分配器
	tiny       uintptr  // 当前 tiny block 起始地址
	tinyoffset uintptr  // 已用偏移
	tinyAllocs uintptr  // 统计
	// ...
	alloc [numSpanClasses]*mspan // 每类 size 一个 mspan
}

分配逻辑(runtime/msize.gomalloc.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,与下一个 <16B byte 数组挤同一个 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 的定义(前几个):

classelemsizespan 页数一次 span 元素数
18 B18192
216 B14096
324 B12730
432 B12048
548 B11365
664 B11024
780 B1819
6732768 B88

class 之间留“间隔“是为了内存利用率:8/16/24/32 是 8 的倍数;48 跨 16 的倍数;后续按 ~1.15× 递增。整体在“碎片少“与“类数少“间权衡,67 类是经验最优。

3. 工程实践与常见坑

  • 同一 size class 的对象不能跨 span 共享:mcache 持有的 span 是 P 私有,跨 P 传指针正常,但 mcache 统计是 per-P。
  • allocBits 不是 mark bitsallocBits 表示“是否已分配“,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.NumCPU vs GOMAXPROCS: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=1scvg 行(scavenge)能观察。
  • sync.Pool 实际复用的是 mcache 路径:Pool 的 Get/Put 在 per-P poolLocal 内进行,底层仍是 mcache 分配——所以 Pool 减少 GC 而非减少 mallocgc 调用次数(虽然两者经常一起降)。
  • 观察 mcentral 命中率runtime/metrics 没有直接暴露 mcentral 命中率,但 gctrace=1scvg 行与 runtime.ReadMemStatsBySize 数组能间接反映各 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 节奏与 RSSGOMEMLIMIT 设置后会触发更频繁 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.ReadMemStatsHeapSys/HeapInuse/HeapIdle/HeapReleased 直接反映 mheap 视角;debug.GCStatsPauseTotalNs 间接反映 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、合理设 GOMEMLIMITGOGC、容器内调 GOMAXPROCS、必要时 madvdontneed=1 对齐 RSS 监控。

掌握这些数据结构后,回到 第16章 Runtime 总览 的全景图就不再有“黑盒“;后续 第18章 GC 会基于 allocBits/gcmarkBits/sweepgen 展开标记-清扫细节,第10章 Goroutine 会基于 P/mcache 绑定关系解释抢占与 syscall 让出 P 的内存侧影响。