Go 底层原理 · 第 6 篇
Go 底层原理丨内存模型
本文基于 Go 1.25.3 源码,从第一性原理出发,深入探讨 Go 的内存模型,包括内存分配机制、实现原理、使用场景以及最新特性。
[!NOTE]
💡 本文基于 Go 1.25.3 源码编写,相比 Go 1.16 版本,增加了 User Arena、Weak Pointer、Cleanup 机制等重要特性。后续版本可能会有变化。建议结合实际使用的 Go 版本阅读相关源码。
特此声明,本篇是笔者与 Google Gemini 3Pro 共创所作,非常庆幸在当今 AI 时代下获取知识已是如此便利,且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑,均由笔者把控和审阅,欢迎放心阅读。
结论先行
Go 的内存分配器设计源于 TCMalloc,采用了多层级缓存架构来减少锁竞争并提高性能。核心设计如下:
核心架构
- Go 将堆内存抽象为 mheap 结构体;
- Go 进程会从虚拟内存中申请 n 个 heapArena(64 位系统每个 64MB);
- 每个 heapArena 被按需划分成不同 class 的 mspan,共有 68 个 size class;
- 每个 mspan 由 n 个相同大小的 span 组成;
- 为了快速定位合适的 span,为 mheap 建立了 136 个中央索引 mcentral;
- 每个 mcentral 存储对应 class 的 mspan,每种 mspan 又划分为 gc scan 和 no scan 两种,故共有 68 × 2 = 136 个 mcentral;
- 为了解决中央索引的并发锁竞争问题,为每一个 P(线程)建立一个本地缓存 mcache;
- 每个 mcache 存储 136 个 span,分别是每种 class 的 mspan 的一个 scan 和 noscan 的 span。
内存分配策略
- Go 中根据对象大小分为 tiny、small 和 large 三种对象;
- tiny (0~16B 无指针) 对象主要分配到 class 2 的 span 中(通过 tiny allocator);
- small (16B~32KB) 对象会被分配到 class 2 ~ class 67 的 span 中;
- class 1 (8B) 仅用于 64 位平台上的单指针对象,使用极少;
- large (>32KB) 对象会量身定做分配到 class0 的 span 中,直接从 mheap 上申请;
- 为对象分配内存时,会先从 mcache 上找 span,找不到就去 mcentral 上交换,还找不到就去 mheap 上申请,最后找不到就 OOM。
1. 协程栈
1.1 作用
协程栈是 Go 协程执行的核心数据结构,主要用于:
- 记录执行路径:追踪函数调用链
- 存储局部变量:每个栈帧保存函数的局部变量
- 函数传参:通过栈传递函数参数
- 保存返回值:存储函数的返回值
1.2 位置
- Go 协程栈位于 Go 堆内存上(而非操作系统栈)
- Go 堆内存位于操作系统虚拟内存上
- 这种设计使得 Go 可以灵活管理协程栈的大小
1.3 图解
以下面的代码为例:
package main
func sum(a, b int) int {
sum := 0
sum = a + b
return sum
}
func main() {
a := 3
b := 5
print(sum(a, b))
}
栈帧结构如下:
1.4 参数传递
Go 采用值传递
- 传递结构体时:拷贝结构体中的全部内容
- 传递结构体指针时:拷贝结构体指针(8 字节)
1.5 栈大小
Go 1.25.3 中,协程栈的初始大小为 2KB,相比早期版本(如 Go 1.2 的 8KB)更加轻量。
1.6 逃逸分析
不是所有的变量都能放在协程栈上。以下三种情况会导致变量逃逸到堆上:
1. 指针逃逸
函数返回局部变量的指针:
func newInt() *int {
x := 42
return &x // x 逃逸到堆
}
2. 空接口逃逸
函数参数为 interface{},编译器无法确定具体类型:
func println(v interface{}) { // v 可能逃逸
// ...
}
3. 大变量逃逸
变量太大,栈帧放不下。在 64 位机器中,一般超过 64KB 的变量就会逃逸。
1.7 栈扩容
Go 栈的初始空间为 2KB。在函数调用前会执行 morestack 判断栈空间是否足够。
栈扩容策略演进
-
分段栈(Go 1.3 之前)
- 优点:没有空间浪费
- 缺点:栈帧在不连续的空间之间横跳,性能较差("热分裂"问题)
-
连续栈(Go 1.3 及之后)
- 优点:空间连续,性能更好
- 缺点:扩容时需要拷贝,开销较大
- 策略:小于 1KB 时翻倍,否则增长 25%
2. 虚拟内存单元 heapArena
2.1 概述
- 在物理内存为 64GB 的机器中,每个 Go 进程最多可被分配到 256TB 的虚拟内存
- Go 的虚拟内存单元为
heapArena,每次申请 64MB(64 位非 Windows 系统) - 最多可以申请 2²⁰ (约 100 万) 个 heapArena
- 所有的
heapArena组成了mheap(Go 堆内存)
💡 相关阅读:操作系统 - 虚拟内存
2.2 底层结构
heapArena 定义在 runtime/mheap.go#L266:
type heapArena struct {
_ sys.NotInHeap
// spans 将 arena 中的虚拟页 ID 映射到 mspan
spans [pagesPerArena]*mspan
// pageInUse 是位图,标记哪些页正在被使用
pageInUse [pagesPerArena / 8]uint8
// pageMarks 用于 GC,标记哪些 span 有被标记的对象
pageMarks [pagesPerArena / 8]uint8
// pageSpecials 标记哪些 span 有 special 记录(finalizer 等)
pageSpecials [pagesPerArena / 8]uint8
// pageUseSpanInlineMarkBits 标记使用内联 mark bits 的 span
pageUseSpanInlineMarkBits [pagesPerArena / 8]uint8
// checkmarks 用于 GC 调试
checkmarks *checkmarksMap
// zeroedBase 标记第一个未使用且已归零的页
zeroedBase uintptr
}
2.3 分配策略对比
| 线性分配 | 链表分配 | 分级分配 |
|---|---|---|
| 实现简单,但内存碎片较多 | 将空闲块连接起来,牺牲部分性能来缓解内存碎片 | 将内存按级别分成很多块,根据对象大小存放在能容纳它的最小块中 |
Go 采用分级分配策略,参考了 TCMalloc,将每一个级定义为
mspan。
3. 内存管理单元 mspan
3.1 概述
- Go 使用内存时的基本单位是
mspan - 每个
mspan由 N 个相同大小的span组成 - Go 1.25.3 中有 68 种 size class(class 0 ~ class 67)
Size Class 表(部分)
// runtime/sizeclasses.go
// class bytes/obj bytes/span objects tail waste max waste
// 0 0 0 0 0 0.00%
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 24 8192 341 8 29.24%
// 4 32 8192 256 0 11.72%
// ...
// 64 24576 24576 1 0 11.45%
// 65 27264 81920 3 128 10.00%
// 66 28672 57344 2 0 4.91%
// 67 32768 32768 1 0 12.50%
3.2 底层结构
mspan 定义在 runtime/mheap.go#L420:
type mspan struct {
_ sys.NotInHeap
// 双向链表
next *mspan
prev *mspan
list *mSpanList
// 内存地址和大小
startAddr uintptr // 起始地址
npages uintptr // 页数(每页 8KB)
// 分配信息
freeindex uint16 // 下一个空闲对象的索引
freeIndexForScan uint16 // GC 扫描器使用的索引(Go 1.19+)
nelems uint16 // 对象总数
allocCount uint16 // 已分配对象数
// 位图
allocBits *gcBits // 分配位图
gcmarkBits *gcBits // GC 标记位图
pinnerBits *gcBits // 固定对象位图(Go 1.21+)
// 元数据
spanclass spanClass // size class 和 noscan 标志
elemsize uintptr // 对象大小
state mSpanStateBox // mspan 状态
sweepgen uint32 // 清扫代数
// 新增字段(Go 1.20+)
isUserArenaChunk bool // 是否为 user arena chunk
userArenaChunkFree addrRange // user arena 管理
// GreenTeaGC 实验字段(Go 1.24+)
scanIdx uint16 // 扫描索引
// 大对象类型信息(Go 1.22+)
largeType *_type
// Special 记录
speciallock mutex
specials *special
}
3.3 关键字段说明
3.3.1 分配位图(allocBits)
使用位图标记对象是否已分配:
0表示空闲1表示已分配
3.3.2 双索引设计(Go 1.19+)
freeindex:分配器使用freeIndexForScan:GC 扫描器使用
这样设计避免了竞争条件,确保 GC 只在对象完全初始化后才能看到它。
3.3.2 状态机
const (
mSpanDead mSpanState = iota // 未使用
mSpanInUse // 正在使用
mSpanManual // 手动管理(如栈)
)
4. 中心索引 mcentral
4.1 概述
heapArena 中的 mspan 不是一开始就全部划分好的,而是按需划分。
由于每个 heapArena 中的 mspan 分布是动态的,为了给要分配空间的对象快速定位到合适的 mspan,Go 定义了中心索引 mcentral。
- 总共有 136 个
mcentral结构体 - 其中 68 个用于需要 GC 扫描的对象(scan)
- 另外 68 个用于无需 GC 扫描的对象(noscan)
4.2 底层结构
mcentral 定义在 runtime/mcentral.go#L22:
type mcentral struct {
_ sys.NotInHeap
spanclass spanClass // size class 级别
// 双缓冲设计:配合 GC 的 sweepgen
partial [2]spanSet // 有空闲对象的 span 列表
full [2]spanSet // 无空闲对象的 span 列表
}
4.3 双缓冲机制
mcentral 使用双缓冲配合 GC 的清扫机制:
sweepgen每次 GC 增加 2partial[sweepgen/2%2]是已清扫的 spanpartial[1-sweepgen/2%2]是未清扫的 span
这种设计使得 GC 和分配可以并发进行,无需等待所有 span 都清扫完毕。
5. 线程缓存 mcache
5.1 概述
mcentral 是一个中心索引,修改它需要使用互斥锁进行保护,锁竞争会造成性能问题。
Go 参考 GMP 模型,为每个 P(逻辑处理器)建立了线程本地缓存 mcache,极大缓解了并发锁争夺的性能消耗。
设计要点:
- 每个 P 有一个
mcache - 对于每一种 size class,取一个 scan 和一个 noscan span
- 一个
mcache拥有 136 个mspan(68 个 scan + 68 个 noscan) - 当本地缓存用完后,才需要上锁去 mcentral 交换
5.2 底层结构
mcache 定义在 runtime/mcache.go#L20:
type mcache struct {
_ sys.NotInHeap
// 内存分析相关
nextSample int64 // 触发堆采样的字节数
memProfRate int // 缓存的内存分析速率
scanAlloc uintptr // 可扫描对象的已分配字节数
// 微对象分配器(<16B 的对象)
tiny uintptr // 当前 tiny block 的起始地址
tinyoffset uintptr // tiny block 中的偏移
tinyAllocs uintptr // tiny 分配次数
// 核心:136 个 mspan
alloc [numSpanClasses]*mspan // numSpanClasses = 136
// 栈缓存
stackcache [_NumStackOrders]stackfreelist
// GC 相关
flushGen atomic.Uint32 // 上次 flush 时的 sweepgen
}
5.3 与 P 的关系
type p struct {
// ...
mcache *mcache
// ...
}
每个 P 持有一个 mcache 指针,实现无锁快速路径。
6. 堆 mheap
mheap 是 Go 堆内存的全局管理者,统筹所有内存分配。
6.1 底层结构
mheap 定义在 runtime/mheap.go#L64:
type mheap struct {
_ sys.NotInHeap
// 全局锁(必须在系统栈上获取)
lock mutex
// 页分配器
pages pageAlloc
// GC 相关
sweepgen uint32 // 清扫代数
pagesInUse atomic.Uintptr // 使用中的页数
pagesSwept atomic.Uint64 // 已清扫的页数
sweepPagesPerByte float64 // 比例清扫速率
// Arena 管理(二级映射)
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
heapArenas []arenaIdx // 所有已分配的 arena
curArena struct { // 当前正在增长的 arena
base, end uintptr
}
// 中心索引(136 个)
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
// mspan 是按需分级的,这里保存所有已划分的 mspan
allspans []*mspan
// 各种 fixalloc 分配器
spanalloc fixalloc // 分配 mspan
cachealloc fixalloc // 分配 mcache
specialfinalizeralloc fixalloc // 分配 finalizer
specialWeakHandleAlloc fixalloc // 分配弱指针(Go 1.23+)
specialCleanupAlloc fixalloc // 分配 cleanup(Go 1.24+)
specialPinCounterAlloc fixalloc // 分配 pin counter(Go 1.21+)
// ... 更多 special 分配器
speciallock mutex // 保护 special 分配器
arenaHintAlloc fixalloc
// 【新特性】User Arena 状态(Go 1.20+)
userArena struct {
arenaHints *arenaHint
quarantineList mSpanList // 等待释放的 span
readyList mSpanList // 可复用的 span
}
userArenaArenas []arenaIdx
// 【新特性】Cleanup ID 计数器(Go 1.24+)
cleanupID uint64
// 【新特性】弱指针映射(Go 1.23+)
immortalWeakHandles immortalWeakHandleMap
}
6.2 关键设计
1. 二级映射(arenas)
为了支持稀疏的虚拟地址空间,使用二级数组:
- L1 map:索引 arena 组
- L2 map:索引具体的 heapArena
在大多数 64 位平台上,arenaL1Bits = 0,退化为单级映射。
2. Cache Line 对齐
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
填充字节避免伪共享(false sharing),提升多核性能。
3. 页回收器
reclaimIndex atomic.Uint64 // 下一个要回收的页索引
reclaimCredit atomic.Uintptr // 额外回收的页的信用
后台异步回收未使用的页,减少内存占用。
7. 内存分配
7.1 对象分级
Go 根据对象大小将分配分为三类:
| 类型 | 大小范围 | 分配方式 | Size Class |
|---|---|---|---|
| Tiny | 0 ~ 16B(无指针) | 多个对象合并到 16B | class 2 |
| Tiny | 8B(单指针) | 64 位上使用 class 1 | class1 |
| Small | 16B ~ 32KB | 从 mcache 分配 | class 2 ~ 67 |
| Large | > 32KB | 直接从 mheap 分配 | class 0 |
注意:Class 1 (8B) 在实践中使用极少,仅在 64 位平台上分配恰好 8 字节且包含指针的对象时使用。绝大多数 8 字节对象要么无指针(走 tiny allocator),要么是结构体的一部分。
7.1.1 Tiny 对象分配
对于 < 16B 且无指针的对象,Go 使用特殊的 tiny allocator:
// mcache 中的 tiny allocator
tiny uintptr // 当前 tiny block 起始地址
tinyoffset uintptr // 已使用的偏移量
tinyAllocs uintptr // tiny 分配计数
- 尝试在当前 tiny block 中分配(根据对齐要求)
- 如果空间不足,从 class 2 (16B) 的 span 中获取新的 tiny block
- 多个 tiny 对象共享同一个 16B 块,减少内存浪费
Class 1 的特殊性:Class 1 (8B) 在实践中使用极少,仅在 64 位平台上分配恰好 8 字节且包含指针的对象时使用。典型例子如单个逃逸的指针变量。由于这种场景非常罕见,class 1 基本处于"保留但不常用"的状态。大多数 8 字节对象要么:
- 无指针 → 走 tiny allocator(class 2)
- 是结构体字段的一部分 → 随结构体一起分配
- 是栈上变量 → 不进行堆分配
7.1.2 Small 对象分配
对于 16B ~ 32KB 的对象:
- 根据对象大小查表确定 size class
- 在 mcache 中寻找对应 class 的 span
- 从 span 的 allocBits 中找到空闲 slot
- 如果 mcache 中 span 已满,去 mcentral 交换
- 如果 mcentral 也没有,去 mheap 申请
7.1.3 Large 对象分配
对于 > 32KB 的大对象:量身定做 class0,直接从 mheap 上申请内存。
7.2 mcache 替换
在 mcache 中,每个 class 的 mspan 只有一个,当 mspan 满了之后,会从 mcentral 中兑换一个新的。
func (c *mcache) refill(spc spanClass) {
// 1. 释放当前 span 回 mcentral
s := c.alloc[spc]
if s != &emptymspan {
if s.sweepgen != mheap_.sweepgen+3 {
throw("bad sweepgen in refill")
}
mheap_.central[spc].mcentral.uncacheSpan(s)
}
// 2. 从 mcentral 获取新的 span
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}
// 3. 更新 mcache
c.alloc[spc] = s
}
7.3 mcentral 扩容
mcentral 中,只有有限数量的 mspan,当 mspan 缺少时,会像 mheap 中开辟新的 heapArena,并申请对应 class 的 span。
func (c *mcentral) grow() *mspan {
// 1. 计算需要的页数
npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
size := uintptr(class_to_size[c.spanclass.sizeclass()])
// 2. 从 mheap 分配新的 span
s := mheap_.alloc(npages, c.spanclass)
if s == nil {
return nil
}
// 3. 初始化 span
n := (npages << pageShift) / size
s.limit = s.base() + size*n
return s
}
7.4 mallocgc 源码分析
mallocgc 是 Go 内存分配的核心函数,核心结构如下:
mallocgc
├── mallocgcTiny // Tiny 对象 (0~16B, 无指针)
├── mallocgcSmallNoscan // Small 对象 (16B~32KB, 无指针)
├── mallocgcSmallScanNoHeader // Small 对象 (带指针, 无 header)
├── mallocgcSmallScanHeader // Small 对象 (带指针, 有 header)
└── mallocgcLarge // Large 对象 (>32KB)
源码注释如下(省略了与内存分配无关的次要代码):
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
// 零大小分配的快速路径
if size == 0 {
return unsafe.Pointer(&zerobase)
}
// ========== 核心分配逻辑 ==========
var x unsafe.Pointer
var elemsize uintptr
if size <= maxSmallSize-gc.MallocHeaderSize {
// 小对象分配
if typ == nil || !typ.Pointers() {
// 无指针对象
if size < maxTinySize {
x, elemsize = mallocgcTiny(size, typ) // Tiny 路径
} else {
x, elemsize = mallocgcSmallNoscan(size, typ, needzero) // Small Noscan
}
} else {
// 有指针对象(必须归零)
if !needzero {
throw("objects with pointers must be zeroed")
}
if heapBitsInSpan(size) {
x, elemsize = mallocgcSmallScanNoHeader(size, typ) // 位图在 span 中
} else {
x, elemsize = mallocgcSmallScanHeader(size, typ) // 需要 malloc header
}
}
} else {
// 大对象分配
x, elemsize = mallocgcLarge(size, typ, needzero)
}
// ...
return x
}
7.4.1 Tiny 对象分配:mallocgcTiny
用于 < 16B 且无指针的对象,多个对象合并到 16B 块中。
mallocgcTiny进行了以下优化:
-
对齐优化:根据大小选择合适的对齐
-
空间复用:多个对象共享 16B 块
-
无锁快速路径:nextFreeFast 尝试无锁获取
-
平均浪费率:约 12.5%(远低于独立分配的 87.5%)
func mallocgcTiny(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
// ========== 1. 获取 M 和 mcache ==========
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
off := c.tinyoffset
// ========== 2. 对齐计算 ==========
if size&7 == 0 {
off = alignUp(off, 8) // 8 字节对齐
} else if goarch.PtrSize == 4 && size == 12 {
off = alignUp(off, 8) // 32 位特殊情况
} else if size&3 == 0 {
off = alignUp(off, 4) // 4 字节对齐
} else if size&1 == 0 {
off = alignUp(off, 2) // 2 字节对齐
}
// ========== 3. 尝试在现有 tiny block 中分配 ==========
if off+size <= maxTinySize && c.tiny != 0 {
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
mp.mallocing = 0
releasem(mp)
return x, maxTinySize
}
// ========== 4. 需要新的 tiny block ==========
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span, _ = c.nextFree(tinySpanClass)
}
x := unsafe.Pointer(v)
(*[2]uint64)(x)[0] = 0 // 清零前 16 字节
(*[2]uint64)(x)[1] = 0
// ========== 5. 更新 tiny allocator 状态 ==========
if !raceenabled && (size < c.tinyoffset || c.tiny == 0) {
c.tiny = uintptr(x)
c.tinyoffset = size
}
size = maxTinySize
// ... 发布屏障和返回
publicationBarrier()
return x, size
}
7.4.2 小对象无扫描分配:mallocgcSmallNoscan
用于 16B~32KB 且无指针的对象。
关键点:
-
查表优化:两个查找表覆盖不同大小范围
-
延迟归零:只在需要时归零
-
GC 协作:黑色分配或设置 freeIndexForScan
-
发布屏障:确保内存可见性
func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
// ========== 1. 确定 size class ==========
var sizeclass uint8
if size <= gc.SmallSizeMax-8 {
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])
// ========== 2. 从 mcache 分配 ==========
spc := makeSpanClass(sizeclass, true) // true = noscan
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc)
}
// ========== 3. 按需归零 ==========
x := unsafe.Pointer(v)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
// ========== 4. 发布屏障 ==========
publicationBarrier()
// ========== 5. GC 期间分配黑色 ==========
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
// ... 返回
return x, size
}
7.4.3 小对象有扫描分配(无 Header):mallocgcSmallScanNoHeader
用于带指针的小对象,且堆位图在 span 中。
关键点:
-
堆位图设置:heapSetTypeNoHeader 根据类型设置指针位图
-
扫描统计:累加 scanAlloc 用于 GC 调度
-
8 字节优化:64 位平台的特殊处理
func mallocgcSmallScanNoHeader(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
// ========== 1. 确定 size class ==========
sizeclass := gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
spc := makeSpanClass(sizeclass, false) // false = scan
// ========== 2. 分配和归零 ==========
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc)
}
x := unsafe.Pointer(v)
if span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
// ========== 3. 设置堆位图(类型信息)==========
if goarch.PtrSize == 8 && sizeclass == 1 {
// 8 字节 class 在 64 位平台已预设指针位
c.scanAlloc += 8
} else {
c.scanAlloc += heapSetTypeNoHeader(uintptr(x), size, typ, span)
}
size = uintptr(gc.SizeClassToSize[sizeclass])
// ========== 4. 发布屏障 + GC 协作 ==========
publicationBarrier()
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
// ... 返回
return x, size
}
7.4.4 小对象有扫描分配(有 Header):mallocgcSmallScanHeader
用于带指针的小对象,需要 malloc header 存储类型信息。
Malloc Header 设计:
+------------------+
| *_type (header) 丨 <- 指向类型元数据的指针
+------------------+
| 实际对象数据 | <- x 指向这里
+------------------+
为什么需要 Header:
-
当对象较大且堆位图不在 span 中时,需要额外存储类型信息
-
Header 存储类型指针,GC 可以快速找到对象的类型信息
-
权衡:增加少量空间换取更快的 GC 扫描
func mallocgcSmallScanHeader(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
// ========== 1. 增加 header 空间 ==========
size += gc.MallocHeaderSize
// ========== 2. 确定 size class ==========
var sizeclass uint8
if size <= gc.SmallSizeMax-8 {
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])
// ========== 3. 分配和归零 ==========
spc := makeSpanClass(sizeclass, false)
span := c.alloc[spc]
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc)
}
x := unsafe.Pointer(v)
if span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
// ========== 4. 设置 malloc header ==========
header := (**_type)(x)
x = add(x, gc.MallocHeaderSize) // 跳过 header
c.scanAlloc += heapSetTypeSmallHeader(uintptr(x), size-gc.MallocHeaderSize, typ, header, span)
// ========== 5. 发布屏障 + GC 协作 ==========
publicationBarrier()
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x)-gc.MallocHeaderSize)
} else {
span.freeIndexForScan = span.freeindex
}
// ... 返回
return x, size
}
7.4.5 大对象分配:mallocgcLarge
用于 > 32KB 的对象。
大对象优化:
-
直接分配:绕过 mcache/mcentral,直接从 mheap
-
largeType 字段:避免为大对象创建复杂的位图
-
分块归零:允许抢占,减少延迟
-
量身定做:每个大对象有专属的 span
func mallocgcLarge(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
// ========== 1. 直接从 mheap 分配 ==========
span := c.allocLarge(size, typ == nil || !typ.Pointers())
span.freeindex = 1
span.allocCount = 1
span.largeType = nil // 暂时设为 nil,防止 GC 过早扫描
size = span.elemsize
x := unsafe.Pointer(span.base())
// ========== 2. 发布屏障 ==========
publicationBarrier()
// ========== 3. GC 协作 ==========
if writeBarrier.enabled {
gcmarknewobject(span, uintptr(x))
} else {
span.freeIndexForScan = span.freeindex
}
// ========== 4. 设置类型信息 ==========
if typ != nil && typ.Pointers() {
if !heapBitsInSpan(span.elemsize) {
// 大对象使用 largeType 字段
span.largeType = typ
// 发布屏障确保 largeType 可见
publicationBarrier()
} else {
c.scanAlloc += heapSetTypeLarge(uintptr(x), span.elemsize, typ, span)
}
}
// ========== 5. 按需归零(可抢占) ==========
if needzero && span.needzero != 0 {
if goexperiment.AllocHeaders {
memclrNoHeapPointersChunked(size, x) // 分块归零
} else {
memclrNoHeapPointers(x, size)
}
}
// ... 返回
return x, size
}
8. Go 1.20+ 新增特性
Go 在 1.16 之后的版本中引入了多项重要的内存管理特性,极大地增强了 Go 的能力和灵活性。
8.1 User Arena(Go 1.20+)
概述
User Arena 允许应用程序手动管理一组对象的生命周期,所有对象在同一个 arena 中分配,可以一次性释放整个 arena。
使用场景
- 临时数据处理:请求处理完后批量释放
- 请求级别内存池:每个请求一个 arena
- 减少 GC 压力:大量临时对象不进入 GC 扫描
数据结构
type mheap struct {
// mheap 中的 user arena 状态
userArena struct {
arenaHints *arenaHint // 分配提示
quarantineList mSpanList // 隔离列表(等待无指针引用)
readyList mSpanList // 就绪列表(可复用)
}
}
type mspan struct {
// mspan 中的标记
isUserArenaChunk bool // 是否为 user arena chunk
userArenaChunkFree addrRange // chunk 分配管理
}
使用示例
import "arena"
func processRequest(data []byte) {
// 创建 arena
a := arena.NewArena()
defer a.Free() // 批量释放
// 在 arena 中分配对象
obj := arena.New[MyStruct](a)
// 使用对象...
}
8.2 Weak Pointer(Go 1.23+)
概述
弱引用机制允许持有对象的引用,但不阻止 GC 回收该对象。
使用场景
- 缓存:缓存条目可以被 GC 回收
- Observer 模式:观察者不阻止被观察对象回收
- 循环引用打破:避免内存泄漏
数据结构
// mheap 中的弱指针映射
immortalWeakHandles immortalWeakHandleMap // 不朽对象的弱指针
// Special 记录
specialWeakHandle struct {
special special
handle *atomic.Uintptr // 弱指针句柄
}
使用示例
import "weak"
type Cache struct {
items map[string]weak.Pointer[*Item]
}
func (c *Cache) Get(key string) *Item {
wp := c.items[key]
return wp.Value() // 可能返回 nil(已被 GC)
}
func (c *Cache) Set(key string, item *Item) {
c.items[key] = weak.Make(item)
}
实现细节
- 弱指针本身不占用 GC 扫描时间
- 对象被回收后,弱指针自动变为 nil
- 弱指针转强指针需要确保 span 已清扫
8.3 Cleanup 机制(Go 1.24+)
概述
类似 finalizer 但更安全的资源清理机制,不会使对象复活。
Cleanup vs Finalizer
| 特性 | Finalizer | Cleanup |
|---|---|---|
| 对象复活 | 会 | 不会 |
| 执行时机 | 第一次变成不可达 | 对象真正释放前 |
| 多个回调 | 不支持 | 支持 |
| GC 延迟 | 较大 | 较小 |
数据结构
// mheap 中的 cleanup ID
cleanupID uint64 // 全局唯一 ID 计数器
// Special 记录
specialCleanup struct {
special special
fn *funcval // 清理函数
id uint64 // 全局唯一 ID
}
使用示例
import "runtime"
type Resource struct {
handle uintptr
}
func NewResource() *Resource {
r := &Resource{handle: openResource()}
// 注册 cleanup(不会使 r 复活)
runtime.AddCleanup(r, func() {
closeResource(r.handle)
})
return r
}
8.4 Pinner 机制(Go 1.21+)
概述
固定对象在内存中的位置,防止 GC 移动(为未来的移动式 GC 做准备)。
使用场景
- CGO 交互:C 代码持有 Go 对象指针
- DMA 操作:硬件直接访问内存
- 性能优化:避免某些热点对象移动
数据结构
// mspan 中的 pin 位图
pinnerBits *gcBits // 标记哪些对象被固定
// Special 记录(支持多次 pin)
specialPinCounter struct {
special special
counter uintptr // pin 计数
}
使用示例
import "runtime"
func passToC(data []byte) {
var pinner runtime.Pinner
pinner.Pin(&data[0]) // 固定切片底层数组
// 调用 C 函数
C.processData(unsafe.Pointer(&data[0]), C.int(len(data)))
pinner.Unpin() // 解除固定
}
8.5 GreenTeaGC(实验性,Go 1.24+)
概述
实验性的新 GC 算法,旨在进一步降低延迟。
关键改进
- Span Inline Mark Bits:将 mark bits 内联到 span 中
- 增量标记:更细粒度的标记控制
- 减少停顿:优化 STW 阶段
数据结构
// mspan 中的 GreenTeaGC 字段
scanIdx uint16 // 扫描索引
// heapArena 中的位图
pageUseSpanInlineMarkBits [pagesPerArena / 8]uint8
启用方式
GOEXPERIMENT=greentea go build myapp.go
9. 性能优化技巧
9.1 减少内存分配
1. 复用对象(sync.Pool)
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process() {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
// 使用 buf...
}
2. 预分配切片
// 不好
var items []Item
for i := 0; i < 1000; i++ {
items = append(items, Item{})
}
// 好
items := make([]Item, 0, 1000)
for i := 0; i < 1000; i++ {
items = append(items, Item{})
}
3. 字符串拼接优化
// 不好
s := ""
for i := 0; i < 100; i++ {
s += "a"
}
// 好
var b strings.Builder
b.Grow(100)
for i := 0; i < 100; i++ {
b.WriteString("a")
}
s := b.String()
9.2 避免逃逸
1. 返回值而非指针
// 逃逸
func newPoint() *Point {
p := Point{x: 1, y: 2}
return &p // 逃逸
}
// 不逃逸
func newPoint() Point {
return Point{x: 1, y: 2}
}
2. 使用确定大小的数组
// 逃逸
func process() {
data := make([]byte, n) // 如果 n 不是常量,可能逃逸
}
// 不逃逸
func process() {
var data [1024]byte // 数组在栈上
}
9.3 检测工具
逃逸分析
go build -gcflags="-m" main.go
内存分析
import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// 访问 http://localhost:6060/debug/pprof/heap
}
Trace 分析
go test -trace=trace.out
go tool trace trace.out
10. 总结
10.1 内存模型演进
从 Go 1.16 到 Go 1.25.3,内存模型的主要演进方向:
-
更灵活的内存管理
- User Arena:用户可控的批量分配/释放
- 适应更多场景需求
-
更丰富的引用语义
- Weak Pointer:支持弱引用
- 打破循环引用,优化缓存
-
更安全的资源管理
- Cleanup 机制:不会使对象复活
- 减少 finalizer 带来的问题
-
更好的 CGO 支持
- Pinner 机制:固定对象位置
- 安全地与 C 代码交互
-
持续的 GC 优化
- GreenTeaGC:实验性的低延迟 GC
- Inline mark bits:减少内存开销
10.2 核心设计原则
Go 内存分配器的核心设计原则始终如一:
- 多层级缓存:
- 本地缓存:mcache(Per-P,无锁)
- 中央索引:mcentral(按 size class,需要锁)
- 全局堆:mheap(全局,需要全局锁)
- 虚拟内存:heapArena(64MB 单元)
- 减少锁竞争:Per-P 缓存 + 细粒度锁
- 分级管理:68 个 size class 减少碎片
- 延迟归零:按需清零提高性能
- 与 GC 协作:双缓冲、sweepgen 等机制
10.3 最佳实践
- 理解内存分配路径:优先使用 mcache 的无锁快速路径
- 减少逃逸:让对象尽量在栈上分配
- 复用对象:使用 sync.Pool 减少分配
- 预分配容量:避免 slice/map 反复扩容
- 选择合适的特性:根据场景使用 User Arena、Weak Pointer 等
10.4 参考资料
附录:常用命令
内存相关环境变量
GOGC=100 # GC 触发时的堆增长百分比
GOMEMLIMIT=4GiB # 内存限制(Go 1.19+)
GODEBUG=gctrace=1 # 打印 GC 跟踪信息
性能分析
# CPU profile
go test -cpuprofile=cpu.prof
go tool pprof cpu.prof
# Memory profile
go test -memprofile=mem.prof
go tool pprof mem.prof
# Trace
go test -trace=trace.out
go tool trace trace.out
逃逸分析
# 编译时查看逃逸分析
go build -gcflags="-m -m" main.go
# 查看汇编代码
go tool compile -S main.go
来自开放网络的回应
引用、喜欢、转发和站外回复会通过 Webmention 回到这里。
接收与展示代码已经就绪,注册正式域名后即可开始收集回应。
评论
评论由 GitHub Discussions 保存和管理。