mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3593 字
10 分钟
Go 内存管理深度解析
2022-07-02

一、引言:为什么 Go 需要自己的内存分配器?#

在理解 Go 的内存管理之前,需要先回答一个根本问题:为什么不直接使用操作系统的内存分配接口(如 malloc/free)?

答案在于并发性能。传统的 malloc 实现在多线程环境下面临严峻挑战:

  1. 锁竞争:全局堆需要锁保护,高并发下成为瓶颈
  2. 缓存失效:不同线程分配的内存可能分散在不同 CPU 缓存行
  3. 碎片问题:频繁的分配释放导致内存碎片化

Go 的解决方案借鉴了 Google 的 TCMalloc(Thread-Caching Malloc),采用多级缓存架构,将内存分配上下文与处理器(P)绑定,实现了近乎无锁的并发分配。

本文将从 TCMalloc 的核心思想出发,逐步深入 Go 内存分配器的各个组件,并探讨逃逸分析如何影响分配决策。

二、TCMalloc 算法原理#

2.1 核心思想:多级缓存#

TCMalloc 的设计哲学可以概括为:「让小对象的分配尽可能本地化,减少全局竞争」。

其架构分为三个层级:

┌─────────────────────────────────────────────────────────────────┐
│ Thread Cache (线程缓存) │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ Thread 1 │ │ Thread 2 │ │ Thread N │ │
│ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │
│ │ │ 8B列表 │ │ │ │ 8B列表 │ │ │ │ 8B列表 │ │ │
│ │ │ 16B列表 │ │ │ │ 16B列表 │ │ │ │ 16B列表 │ │ │
│ │ │ ... │ │ │ │ ... │ │ │ │ ... │ │ │
│ │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │ │
│ │ 小对象分配 │ │ 小对象分配 │ │ 小对象分配 │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │ │ │ │
│ │ 缓存未命中 │ │ │
│ ▼ ▼ ▼ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Central Cache (中央缓存) │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ 8B Central │ │ 16B Central │ │ 32KB Central │ │
│ │ Freelist │ │ Freelist │ │ Freelist │ │
│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ │ 全局锁 │
└────────────────────────────┼────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Page Heap (页堆) │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Span 1 (1 page) │ Span 2 (2 pages) │ Span N (N pages) ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 空闲页链表:1页 → 2页 → 3页 → ... → 128页 → 大页区 ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

关键优化点

  • 小对象(<= 32KB):从 Thread Cache 分配,完全无锁
  • 中对象:从 Central Cache 分配,需要获取全局锁
  • 大对象(> 32KB):直接从 Page Heap 分配

2.2 Span:内存管理的基本单元#

TCMalloc 引入了 Span 的概念,它是连续页的集合:

Span (管理连续内存页):
┌─────────────────────────────────────────────────────┐
│ Span Header │
│ ┌────────────────────────────────────────────────┐ │
│ │ start_addr | npages | size_class | freelist │ │
│ └────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Memory Pages │
│ ┌──────────┬──────────┬──────────┬──────────┐ │
│ │ Page 0 │ Page 1 │ Page 2 │ Page 3 │ │
│ │ (8KB) │ (8KB) │ (8KB) │ (8KB) │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
│ │
│ 如果 size_class = 32B: │
│ ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐ │
│ │obj0│obj1│obj2│obj3│... │obj511 (32KB/32B)│ │
│ └────┴────┴────┴────┴────┴────┴────┴────┴────┘ │
└─────────────────────────────────────────────────────┘

一个 Span 可以被切分成多个相同大小的对象(size class),也可以作为一个整体用于大对象分配。

三、Go 内存分配器架构#

Go 的内存分配器直接继承了 TCMalloc 的设计,但针对 Go 的运行时特性做了适配。其核心组件包括:

  • mspan:对应 TCMalloc 的 Span
  • mcache:对应 Thread Cache,绑定到 P
  • mcentral:对应 Central Cache
  • mheap:对应 Page Heap

3.1 架构总览#

Go 内存分配器架构
┌─────────────────────────────────────────────────────────────────────┐
│ mheap (全局) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ arenas [][]*heapArena // 稀疏地址空间映射 │ │
│ │ spans []mspan // span 映射 │ │
│ │ bitmap []uint8 // GC 标记位图 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ central [numSpanClasses]mcentral // 136 个 mcentral │ │
│ │ [0]: 8B noscan [1]: 8B scan [2]: 16B noscan ... │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ 缓存未命中时从 mcentral 获取
┌─────────────────────────────────────────────────────────────────────┐
│ mcache (每个 P 一个) │
│ │
│ P0 ──▶ mcache │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ alloc [136]*mspan │ │
│ │ [0]: 8B noscan span [1]: 8B scan span │ │
│ │ [2]: 16B noscan span [3]: 16B scan span │ │
│ │ ... │ │
│ │ │ │
│ │ tiny uintptr // 微小对象分配起始地址 │ │
│ │ tinyoffset uintptr // 微小对象已用偏移 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ P1 ──▶ mcache P2 ──▶ mcache ... PN ──▶ mcache │
└─────────────────────────────────────────────────────────────────────┘

3.2 mspan:Go 的内存管理单元#

mspan 是 Go 内存管理的基本单元,定义在 mheap.go

type mspan struct {
next *mspan // 链表后向指针
prev *mspan // 链表前向指针
list *mSpanList // 所属链表(debug用)
startAddr uintptr // 起始地址
npages uintptr // 页数(每页 8KB)
nelems uintptr // 可分配对象总数
allocCount uint16 // 已分配对象数
spanclass spanClass // 大小级别(含 scan/noscan 标志)
allocBits *gcBits // 分配位图
gcmarkBits *gcBits // GC 标记位图
// ...
}

关键概念:spanClass

Go 将对象大小划分为 67 个级别(8B 到 32KB),每个级别又有「含指针」和「不含指针」两类,共 134 种 spanClass(加上 tiny 共 136 个槽位):

type spanClass uint8
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

noscan 标志对 GC 非常重要:不含指针的对象在 GC 时不需要扫描,节省大量 CPU 时间。

3.3 mcache:P 本地缓存#

mcache 是每个 P 独有的内存缓存,定义在 mcache.go

type mcache struct {
// 微小对象分配(< 16B 且不含指针)
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// 各 spanClass 的 mspan 缓存
alloc [numSpanClasses]*mspan // numSpanClasses = 136
// stack 预分配缓存
stackcache [_NumStackOrders]stackfreelist
// ...
}

微小对象分配(Tiny Allocation)

对于小于 16B 且不含指针的对象(如小字符串、小整数),Go 会将多个对象合并到一个 16B 的内存块中:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
// 微小对象分配路径
if size <= maxTinySize && noscan {
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 && c.tiny != 0 {
// 在已有的 tiny 块中分配
c.tinyoffset = off + size
return unsafe.Pointer(c.tiny + off)
}
// 分配新的 tiny 块
span := c.alloc[tinySpanClass]
v := nextFreeFast(span)
if v == 0 {
v, span = c.nextFree(tinySpanClass)
}
c.tiny = v
c.tinyoffset = size
return unsafe.Pointer(v)
}
// ...
}

这种设计极大地减少了小对象的内存碎片和分配开销。

3.4 mcentral:中央缓存#

mcentral 管理特定 spanClass 的 mspan 集合,定义在 mcentral.go

type mcentral struct {
spanclass spanClass
// 部分使用的 span(有空闲空间)
partial [2]spanSet // [0]: 不需要清扫, [1]: 需要清扫
// 完全使用的 span(无空闲空间)
full [2]spanSet
}

当 mcache 的 mspan 用尽时,会从 mcentral 获取新的 mspan:

func (c *mcache) refill(spc spanClass) *mspan {
s := c.alloc[spc]
if s != &emptymspan {
// 将当前 span 归还给 mcentral
if s.allocCount != 0 {
mheap_.central[spc].mcentral.uncacheSpan(s)
}
}
// 从 mcentral 获取新 span
s = mheap_.central[spc].mcentral.cacheSpan()
c.alloc[spc] = s
return s
}

3.5 mheap:全局页堆#

mheap 是 Go 运行时的全局内存分配器,管理所有的堆内存:

type mheap struct {
lock mutex
// 稀疏地址空间映射
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
// span 映射(地址 -> span)
spans []mspan
// GC 位图
bitmap []uint8
// 各 spanClass 的 mcentral
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
// 空闲 span 链表
free mTreap // 空闲 span 的树形结构
// ...
}

heapArena:稀疏地址空间

Go 1.11 引入了稀疏内存布局,允许 Go 程序使用更大的虚拟地址空间:

虚拟地址空间布局(64位 Linux):
arenaBaseOffset = 0x00c0 << 32
arenaSize = 1 << 32 = 4GB 每个 arena
┌────────────────────────────────────────────────────────────────┐
│ arena[0][0] │ arena[0][1] │ arena[0][2] │ ... │ arena[0][N] │
│ 4GB │ 4GB │ 4GB │ │ 4GB │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ heapArena 结构 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ spans [pagesPerArena]*mspan // 每页对应的 span │ │
│ │ pageInUse [pagesPerArena/8]uint8 // 页使用位图 │ │
│ │ pageMarks [pagesPerArena/8]uint8 // 页标记位图 │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘

Go 1.18 引入了**稀疏堆(sparse heap)**机制,用 heapArena 二级数组替代了原来的连续 spans 数组,彻底移除了 512GB 的堆内存上限。在 64 位系统上,Go 现在可以管理远超 512GB 的堆内存(理论上受虚拟地址空间大小限制)。

flowchart LR subgraph "Go 1.17 及之前(密集布局)" A1["spans []mspan<br/>连续数组<br/>上限 512GB"] end subgraph "Go 1.18+(稀疏堆)" A2["arenas [L1][L2]*heapArena<br/>二级稀疏数组<br/>无硬性上限"] end A1 -->|"Go 1.18"| A2 style A2 fill:#4CAF50,color:#fff

四、堆内存分配流程#

4.1 分配路径概览#

flowchart TD A[mallocgc] --> B{对象大小} B -->|Tiny < 16B, 无指针| C[mcache.tiny 微小对象分配] B -->|Small <= 32KB| D[mcache.alloc 分配] B -->|Large > 32KB| E[直接从 mheap 分配] C --> F{tiny 块有空间?} F -->|是| G[在 tiny 块中分配] F -->|否| H[从 mcache 获取新 tiny span] D --> I{当前 span 有空间?} I -->|是| J[nextFreeFast 快速分配] I -->|否| K[refill 从 mcentral 获取新 span] K --> L{mcentral 有空闲 span?} L -->|是| M[cacheSpan 获取] L -->|否| N[grow 从 mheap 分配新 span] N --> O[mheap.allocSpan] O --> P{mheap 有足够页?} P -->|是| Q[从空闲链表获取] P -->|否| R[从操作系统申请内存] E --> O

4.2 源码级分配流程#

入口函数 mallocgc 位于 malloc.go

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mp := getg().m
pp := mp.p.ptr()
c := pp.mcache
// 1. 检查是否需要协助 GC
if gcphase == _GCmark && gcBlackenEnabled != 0 {
gcAssistAlloc(gp)
}
// 2. 根据大小选择分配路径
if size <= maxSmallSize {
// 小对象分配
if noscan && size < maxTinySize {
// 微小对象分配
return mallocTiny(c, size)
}
// 标准小对象分配
return mallocSmall(c, size, typ)
}
// 大对象分配
return mallocLarge(c, size, typ)
}

小对象分配核心逻辑

func mallocSmall(c *mcache, size uintptr, typ *_type) unsafe.Pointer {
// 计算大小级别
spc := makeSpanClass(size_to_class[size], typ.ptrdata == 0)
// 从 mcache 获取 span
s := c.alloc[spc]
// 快速路径:span 有空闲空间
if v := nextFreeFast(s); v != 0 {
return unsafe.Pointer(v)
}
// 慢速路径:需要 refill
s = c.refill(spc)
return unsafe.Pointer(s.alloc())
}
func nextFreeFast(s *mspan) uintptr {
theBit := sys.Ctz64(s.allocCache) // 找到第一个空闲位
if theBit < 64 {
result := s.freeindex + theBit
if result < s.nelems {
s.freeindex = result + 1
s.allocCache >>= theBit + 1
s.allocCount++
return s.startAddr + result * s.elemsize
}
}
return 0
}

大对象分配

func mallocLarge(c *mcache, size uintptr, typ *_type) unsafe.Pointer {
// 计算需要的页数
npages := size >> pageShift
if size&(_PageSize-1) != 0 {
npages++
}
// 直接从 mheap 分配
s := mheap_.alloc(npages, makeSpanClass(0, typ.ptrdata == 0))
// 设置 GC 位图
s.limit = s.startAddr + size
return unsafe.Pointer(s.startAddr)
}

五、栈内存管理#

5.1 栈的动态增长#

Go 的 goroutine 栈不是固定大小的,而是可以动态增长。初始栈大小为 2KB,最大可达 1GB(64位系统)。

Goroutine 栈增长过程:
初始状态(2KB):
┌─────────────────────────────────────┐
│ Stack [lo, hi) = 2KB │
│ ┌─────────────────────────────────┐ │
│ │ 函数 A 的栈帧 │ │
│ │ - 局部变量 │ │
│ │ - 返回地址 │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ 函数 B 的栈帧 │ │
│ │ ... │ │
│ └─────────────────────────────────┘ │
│ │
│ guard = lo - StackGuard (保护页) │
└─────────────────────────────────────┘
触发增长(栈指针接近 guard):
┌─────────────────────────────────────┐
│ 检测到栈溢出风险 │
│ morestack() 被调用 │
└─────────────────────────────────────┘
增长后(4KB):
┌─────────────────────────────────────┐
│ 新 Stack [new_lo, new_hi) = 4KB │
│ ┌─────────────────────────────────┐ │
│ │ 复制旧栈内容 │ │
│ │ ... │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ 新的可用空间 │ │
│ │ ... │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘

5.2 栈增长实现#

栈溢出检测和增长逻辑位于 stack.go

// 栈溢出检测(编译器在每个函数开头插入)
TEXT main·foo(SB), $size-0
MOVQ (TLS), CX // 获取当前 g
CMPQ SP, -8(CX) // 比较 SP 与 stackguard0
JLS morestack // 如果 SP <= stackguard0,调用 morestack
// ... 函数体 ...
morestack:
CALL runtime·morestack(SB)
JMP 0(PC) // 重新执行函数

栈增长函数:

func morestack() {
gp := getg()
// 计算新栈大小(翻倍)
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
// 不能超过最大限制
if newsize > maxStackSize {
throw("stack overflow")
}
// 分配新栈
newstack(gp, newsize)
}
func newstack(gp *g, newsize uintptr) {
// 分配新栈空间
new := stackalloc(uint32(newsize))
// 复制旧栈内容到新栈
memmove(unsafe.Pointer(new.hi-copelemsize),
unsafe.Pointer(gp.stack.hi-copelemsize),
copelemsize)
// 调整栈中的指针(非常关键!)
adjustsudogs(gp)
adjustctxt(gp)
// 切换到新栈
old := gp.stack
gp.stack = new
gp.stackguard0 = new.lo + stackGuard
// 释放旧栈
stackfree(old)
}

5.3 栈缓存#

为了提高栈分配效率,Go 在 mcache 中维护了栈缓存:

type mcache struct {
// 栈缓存,按大小分级
stackcache [_NumStackOrders]stackfreelist
}
type stackfreelist struct {
list gclinkptr // 空闲栈链表
size uintptr // 累计大小
}

栈分配时优先从缓存获取,缓存不足时才从 mheap 分配:

func stackalloc(n uint32) stack {
// 计算大小级别
order := uint8(0)
for n > _FixedStack<<order {
order++
}
// 尝试从 mcache 的栈缓存获取
c := getg().m.p.ptr().mcache
if x := c.stackcache[order].list.ptr(); x != nil {
c.stackcache[order].list = x.next
c.stackcache[order].size -= uintptr(n)
return stack{lo: uintptr(unsafe.Pointer(x)), hi: uintptr(unsafe.Pointer(x)) + uintptr(n)}
}
// 从全局栈池或 mheap 分配
return stackallocFromHeap(n)
}

六、逃逸分析#

6.1 什么是逃逸分析?#

逃逸分析是编译器决定变量分配位置的关键技术:

  • 不逃逸:变量分配在栈上,函数返回后自动回收
  • 逃逸:变量分配在堆上,由 GC 管理

逃逸分析的目标是尽可能将对象分配在栈上,减少 GC 压力。

6.2 逃逸场景#

场景一:返回局部变量指针

// 逃逸!返回了局部变量的指针
func newInt() *int {
x := 42
return &x // x 逃逸到堆上
}
// 不逃逸,变量在调用者的栈上
func newIntNoEscape() int {
return 42
}

场景二:闭包捕获

// 逃逸!x 被闭包捕获
func counter() func() int {
x := 0
return func() int {
x++
return x // x 逃逸到堆上
}
}

场景三:接口转换

// 逃逸!转换为接口类型
func printAny(v any) {
fmt.Println(v)
}
func main() {
x := 42
printAny(x) // x 逃逸(装箱为 interface)
}

场景四:slice/map 存储

// 逃逸!存入全局 map
var globalMap = make(map[string]*int)
func store() {
x := 42
globalMap["key"] = &x // x 逃逸
}

6.3 查看逃逸分析结果#

使用 -gcflags='-m' 查看逃逸分析决策:

$ go build -gcflags='-m -m' escape.go
# 输出示例:
./escape.go:4:2: x escapes to heap:
./escape.go:4:2: flow: ~r0 = &x:
./escape.go:4:2: from &x (address-of) at ./escape.go:5:9
./escape.go:4:2: from return &x (return) at ./escape.go:5:2

6.4 逃逸分析优化案例#

案例一:减少指针返回

// 原版本:每次调用都堆分配
func parseHeader(header string) *Header {
h := &Header{}
// 解析逻辑...
return h
}
// 优化版本:接收者传递,避免逃逸
func parseHeader(header string, h *Header) {
// 解析逻辑...
}

案例二:预分配 slice

// 原版本:可能导致多次堆分配
func processItems(items []string) []Result {
var results []Result
for _, item := range items {
results = append(results, Result{...})
}
return results
}
// 优化版本:预分配避免多次增长
func processItems(items []string) []Result {
results := make([]Result, 0, len(items))
for _, item := range items {
results = append(results, Result{...})
}
return results
}

案例三:避免接口装箱

// 原版本:int 装箱为 interface 导致逃逸
func addOne(n any) any {
return n.(int) + 1
}
// 优化版本:使用泛型避免装箱
func addOne[T int | float64](n T) T {
return n + 1
}

七、内存分配性能优化建议#

7.1 减少堆分配#

  1. 优先使用值类型:小结构体用值而非指针
  2. 预分配容器:使用 make(T, 0, capacity) 预分配
  3. 使用 sync.Pool:复用临时对象
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
// 使用 buf...
}

7.2 降低指针密度#

// 高指针密度:GC 扫描成本高
type Bad struct {
data []*Item // 每个元素都是指针
}
// 低指针密度:GC 扫描成本低
type Good struct {
data []Item // 元素是值类型
}

7.3 对象大小优化#

了解 size class 可以帮助优化内存使用:

// size class 表(部分)
// 8B, 16B, 24B, 32B, 48B, 64B, 80B, 96B, 112B, 128B, ...
// 如果对象大小刚好超过某个 size class 边界,会有内部碎片
type Example struct {
a int64 // 8B
b int64 // 8B
c int64 // 8B
d int64 // 8B
e int64 // 8B // 40B -> 分配 48B 的 span,浪费 8B
}
// 如果可能,调整字段顺序或大小
type Optimized struct {
a int64 // 8B
b int64 // 8B
c int64 // 8B
d int64 // 8B
// 移除 e 或使用其他策略
}

八、内存分配代码路径总结#

mallocgc(size, typ, needzero)
├── GC Assist 检查
├── 大小分类
│ │
│ ├── Tiny (< 16B, 无指针)
│ │ └── mcache.tiny 合并分配
│ │
│ ├── Small (<= 32KB)
│ │ │
│ │ ├── nextFreeFast (mcache 有空间)
│ │ │
│ │ └── refill (mcache 空间不足)
│ │ │
│ │ └── mcentral.cacheSpan
│ │ │
│ │ ├── 从 partial 获取
│ │ │
│ │ └── mheap.allocSpan (mcentral 无空闲)
│ │
│ └── Large (> 32KB)
│ └── mheap.allocSpan
│ │
│ ├── 从 free 链表获取
│ │
│ └── sysAlloc (向 OS 申请)
└── 返回指针

九、Go 与操作系统的内存交互#

前面几节聚焦于 Go 运行时内部的分配器架构,但 Go 最终必须通过操作系统接口来获取和归还内存。这一节深入 Go 与 OS 的内存交互层:如何向 OS 申请内存、如何归还空闲内存、以及如何利用 Linux 的 Transparent Huge Pages(THP)优化性能。

9.1 mmap/sysAlloc:Go 如何向 OS 申请内存?#

当 mheap 的空闲页不足以满足分配请求时,Go 需要向操作系统申请更多内存。这个底层操作由 sysAlloc 完成。

9.1.1 Linux:mmap 匿名映射#

在 Linux 上,sysAllocOS 通过 mmap 系统调用申请内存:

mem_linux.go
func sysAllocOS(n uintptr, v unsafe.Pointer) unsafe.Pointer {
p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
return nil
}
return p
}

关键参数:

  • _MAP_ANON:匿名映射,不依赖文件,页内容初始化为零
  • _MAP_PRIVATE:写时复制(copy-on-write),不影响其他进程
  • _PROT_READ|_PROT_WRITE:可读写权限
  • fd = -1:便携式匿名映射(某些系统需要 /dev/zero,但现代 Linux 直接用 -1

申请到的内存是虚拟地址空间,内核在首次访问时才分配物理页(demand paging)。

源码:mem_linux.go

9.1.2 Windows:VirtualAlloc#

在 Windows 上,Go 使用 VirtualAlloc 实现相同功能:

mem_windows.go
func sysAllocOS(n uintptr) unsafe.Pointer {
return VirtualAlloc(nil, n, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE)
}

两者语义一致:申请一段可读写的私有匿名内存。

9.1.3 sysReserve 与 sysMap:两阶段地址空间管理#

Go 的堆内存管理采用两阶段策略:

  1. sysReserve:预留虚拟地址空间(mmap(PROT_NONE)),不消耗物理内存。Go 在启动时预留大量虚拟地址空间用于 arena 布局。
  2. sysMap:在需要时将预留的地址空间映射为可用内存(mmap(MAP_FIXED|PROT_READ|PROT_WRITE)),此时才消耗物理页。
flowchart LR A["sysReserve\nmmap(PROT_NONE)\n预留虚拟地址"] --> B["虚拟地址空间已预留\n但不占物理内存"] B --> C["sysMap\nmmap(MAP_FIXED, PROT_RW)\n映射为可用内存"] C --> D["物理页分配\n(demand paging)"]

这种设计让 Go 可以在稀疏堆(sparse heap)架构下管理远超实际使用的虚拟地址空间,而不消耗物理资源。

源码:

9.2 Scavenger:如何将空闲内存归还给 OS?#

GC 的 sweep 阶段将对象标记为空闲,但对应的物理内存页并未归还给操作系统。Scavenger 负责将空闲 span 的物理内存归还给 OS,降低进程的 RSS。

9.2.1 归还机制:madvise#

在 Linux 上,Go 通过 madvise 系统调用归还物理页:

// mem_linux.go — sysUnusedOS
func sysUnusedOS(v unsafe.Pointer, n uintptr) {
// 优先尝试 MADV_FREE(Linux 4.5+)
if madvise(v, n, _MADV_FREE) == 0 {
return
}
// 回退到 MADV_DONTNEED
madvise(v, n, _MADV_DONTNEED)
}

两种策略的区别:

策略行为page fault适用场景
MADV_FREE惰性释放:内核在内存压力时才回收,之前进程仍可访问内核回收后触发Linux 4.5+,优先使用
MADV_DONTNEED立即释放:物理页马上归还,访问时触发 page fault立即生效兼容旧内核

Go 优先尝试 MADV_FREE,失败则回退到 MADV_DONTNEED,如果 madvise 完全不支持则回退到 mmap(MAP_FIXED) 重新映射。

源码:mem_linux.go

9.2.2 后台 Scavenger 的工作方式#

Scavenger 以独立 goroutine(bgscavenge)运行,以不超过 mutator CPU 时间的 1% 为限,持续将空闲页归还给 OS。其 RSS 目标根据是否设置 GOMEMLIMIT 有两种计算方式:

  • 无 GOMEMLIMITgoal = (1 + 10%) × (heapGoal / lastHeapGoal) × lastHeapInUse,RSS 跟随 heap goal 按比例缩放。
  • 有 GOMEMLIMITgoal = 95% × memoryLimit,更激进地归还内存以维持限制。

Scavenger 遵循密度启发式:只归还至少经历了一个完整 GC cycle 仍未被密集分配的 chunk,避免破坏 THP 和频繁 page fault。

源码:mgcscavenge.go

9.3 Transparent Huge Pages(THP):Go 与 Linux 大页的交互#

Linux 的 Transparent Huge Pages(THP) 是一种自动将连续的 4KB 常规页合并为 2MB 大页的机制,可以减少 TLB miss、提升大块内存访问的性能。Go 运行时对 THP 有专门的优化。

9.3.1 为什么 Go 关心 THP?#

Go 的堆通常很大(数百 MB 到数 GB),频繁的内存分配和 scavenging 会导致:

  • THP 拆分madvise(MADV_FREE/DONTNEED) 会将 2MB 大页拆分为 4KB 常规页
  • THP 重建困难:一旦拆分,内核的 khugepaged 守护进程需要重新扫描并合并,延迟不可控
  • 性能退化:拆分后的 4KB 页导致 TLB 压力增大,影响分配和访问性能

因此 Go 采取了主动管理 THP 的策略。

9.3.2 sysHugePage:主动建议使用大页#

当 Go 识别出一个 chunk 被密集分配(高密度 chunk)时,会调用 sysHugePageOS 主动建议内核使用大页:

mem_linux.go
func sysHugePageOS(v unsafe.Pointer, n uintptr) {
if physHugePageSize != 0 {
beg := alignUp(uintptr(v), physHugePageSize)
end := alignDown(uintptr(v)+n, physHugePageSize)
if beg < end {
madvise(unsafe.Pointer(beg), end-beg, _MADV_HUGEPAGE)
}
}
}

MADV_HUGEPAGE 告诉内核:“这块内存我正在密集使用,请尽量用大页映射”。内核的 khugepaged 会优先处理标记了 MADV_HUGEPAGE 的区域。

源码:mem_linux.go

9.3.3 sysNoHugePage:阻止大页合并#

对于不希望被合并为大页的区域(如 GC 元数据、零散的小 span),Go 调用 sysNoHugePageOS

mem_linux.go
func sysNoHugePageOS(v unsafe.Pointer, n uintptr) {
madvise(v, n, _MADV_NOHUGEPAGE)
}

这可以防止 khugepaged 将不相关的内存页错误地合并成大页。

源码:mem_linux.go

9.3.4 MADV_COLLAPSE:强制合并大页(Linux 6.1+)#

Go 还支持 MADV_COLLAPSE(Linux 6.1+),这是一种更强力的大页合并方式:

mem_linux.go
func sysHugePageCollapseOS(v unsafe.Pointer, n uintptr) {
madvise(v, n, _MADV_COLLAPSE)
}

MADV_HUGEPAGE(仅建议内核合并)不同,MADV_COLLAPSE同步尝试将区域折叠为大页。但这个调用是 best-effort 的,可能因各种原因失败(如物理内存不连续),Go 不检查返回值。

源码:mem_linux.go

9.3.5 Scavenger 与 THP 的协作#

Scavenger 在选择要归还的内存区域时,会避免拆分大页

  • findScavengeCandidate 在搜索空闲页时,如果候选区域跨越大页边界,会扩展区域以包含整个大页,避免”切半”导致大页失效。
  • 只对”稀疏”chunk(至少一个 GC cycle 未被密集分配)做 scavenging,“密集”chunk 优先走 sysHugePage 路径。
flowchart TD A["GC sweep 完成\n空闲页可用"] --> B{"chunk 密度?"} B -- "高密度\n(≥96.875% 已分配)" --> C["sysHugePage\nMADV_HUGEPAGE\n建议内核用大页"] B -- "低密度\n(经历 ≥1 GC cycle)" --> D["scavenger\nMADV_FREE/DONTNEED\n归还物理页"] D --> E["THP 被拆分\n2MB → 4KB"] C --> F["THP 保持完整\nTLB 压力低"] G["debug.FreeOSMemory()\n或接近 GOMEMLIMIT"] --> H["强制 scavenge\n忽略密度启发式\n可能拆分 THP"]

9.3.6 GODEBUG 控制#

Go 提供了 GODEBUG 环境变量来控制 THP 行为:

  • GODEBUG=disablethp=1:禁用堆内存的 THP,在 sysMapOS 中自动调用 sysNoHugePageOS。适用于 THP 导致延迟尖刺的场景。

源码:mem_linux.go

总结#

Go 的内存管理是一个精妙的系统工程:

  1. 多级缓存架构:借鉴 TCMalloc,将 mcache 绑定到 P,实现近乎无锁的小对象分配

  2. 精细的大小分类:67 个 size class,区分 scan/noscan,优化内存利用和 GC 效率

  3. 动态栈增长:goroutine 栈从 2KB 起步,按需增长,平衡内存效率和灵活性

  4. 逃逸分析:编译期决策变量分配位置,尽可能使用栈内存

  5. 与 GC 协作:内存分配器与 GC 紧密配合,通过 GC Assist 机制控制内存增长

  6. OS 内存交互:通过 mmap/VirtualAlloc 向 OS 申请内存,通过 madvise 归还空闲页,主动管理 THP 优化 TLB 性能

十、常见问题#

Q1:为什么 goroutine 初始栈只有 2KB?#

2KB 是经验值,平衡了内存效率和功能需求。大多数 goroutine 不需要更多栈空间,2KB 允许创建数十万个 goroutine。栈溢出时通过 morestack 自动增长。

Q2:mcache 为什么绑定到 P 而不是 M?#

因为 M 可能阻塞(系统调用),此时 P 会 handoff 给其他 M。如果 mcache 绑定到 M,阻塞的 M 持有的缓存就浪费了。绑定到 P 确保缓存始终可用。

Q3:tiny 分配为什么限制 16B 且无指针?#

16B 是一个缓存行友好的大小,无指针保证 GC 不需要扫描。多个小对象合并到一个 16B 块中,减少分配次数和内存碎片。

Q4:Go 1.18 的稀疏堆解决了什么问题?#

旧实现用连续数组映射 span,限制了堆大小上限为 512GB。稀疏堆用二级数组(arenas[L1][L2])替代,移除了硬性上限,同时支持不连续的堆地址空间。

小结#

  • Go 内存分配器基于 TCMalloc 多级缓存:mcache(P 本地)→ mcentral(全局)→ mheap(页堆)
  • 67 个 size class + noscan 标志优化内存利用和 GC 效率
  • 微小对象(<16B 无指针)合并分配,减少碎片和分配开销
  • goroutine 栈从 2KB 起步动态增长,栈缓存提高分配效率
  • 逃逸分析尽可能将对象分配在栈上,减少 GC 压力

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 内存管理深度解析
https://blog.souloss.com/posts/golang/go-memory/
作者
Souloss
发布于
2022-07-02
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时