Go 底层原理丨内存模型 永久链接

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 中根据对象大小分为 tinysmalllarge 三种对象;
  • 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

  • 总共有 136mcentral 结构体
  • 其中 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 增加 2
  • partial[sweepgen/2%2] 是已清扫的 span
  • partial[1-sweepgen/2%2] 是未清扫的 span

这种设计使得 GC 和分配可以并发进行,无需等待所有 span 都清扫完毕。

5. 线程缓存 mcache

5.1 概述

mcentral 是一个中心索引,修改它需要使用互斥锁进行保护,锁竞争会造成性能问题。

Go 参考 GMP 模型,为每个 P(逻辑处理器)建立了线程本地缓存 mcache,极大缓解了并发锁争夺的性能消耗。

设计要点:

  • 每个 P 有一个 mcache
  • 对于每一种 size class,取一个 scan 和一个 noscan span
  • 一个 mcache 拥有 136mspan(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
Tiny0 ~ 16B(无指针)多个对象合并到 16Bclass 2
Tiny8B(单指针)64 位上使用 class 1class1
Small16B ~ 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 分配计数
  1. 尝试在当前 tiny block 中分配(根据对齐要求)
  2. 如果空间不足,从 class 2 (16B) 的 span 中获取新的 tiny block
  3. 多个 tiny 对象共享同一个 16B 块,减少内存浪费

Class 1 的特殊性:Class 1 (8B) 在实践中使用极少,仅在 64 位平台上分配恰好 8 字节且包含指针的对象时使用。典型例子如单个逃逸的指针变量。由于这种场景非常罕见,class 1 基本处于"保留但不常用"的状态。大多数 8 字节对象要么:

  • 无指针 → 走 tiny allocator(class 2)
  • 是结构体字段的一部分 → 随结构体一起分配
  • 是栈上变量 → 不进行堆分配

7.1.2 Small 对象分配

对于 16B ~ 32KB 的对象:

  1. 根据对象大小查表确定 size class
  2. 在 mcache 中寻找对应 class 的 span
  3. 从 span 的 allocBits 中找到空闲 slot
  4. 如果 mcache 中 span 已满,去 mcentral 交换
  5. 如果 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

特性FinalizerCleanup
对象复活不会
执行时机第一次变成不可达对象真正释放前
多个回调不支持支持
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,内存模型的主要演进方向:

  1. 更灵活的内存管理

    • User Arena:用户可控的批量分配/释放
    • 适应更多场景需求
  2. 更丰富的引用语义

    • Weak Pointer:支持弱引用
    • 打破循环引用,优化缓存
  3. 更安全的资源管理

    • Cleanup 机制:不会使对象复活
    • 减少 finalizer 带来的问题
  4. 更好的 CGO 支持

    • Pinner 机制:固定对象位置
    • 安全地与 C 代码交互
  5. 持续的 GC 优化

    • GreenTeaGC:实验性的低延迟 GC
    • Inline mark bits:减少内存开销

10.2 核心设计原则

Go 内存分配器的核心设计原则始终如一:

  1. 多层级缓存
    • 本地缓存:mcache(Per-P,无锁)
    • 中央索引:mcentral(按 size class,需要锁)
    • 全局堆:mheap(全局,需要全局锁)
    • 虚拟内存:heapArena(64MB 单元)
  2. 减少锁竞争:Per-P 缓存 + 细粒度锁
  3. 分级管理:68 个 size class 减少碎片
  4. 延迟归零:按需清零提高性能
  5. 与 GC 协作:双缓冲、sweepgen 等机制

10.3 最佳实践

  1. 理解内存分配路径:优先使用 mcache 的无锁快速路径
  2. 减少逃逸:让对象尽量在栈上分配
  3. 复用对象:使用 sync.Pool 减少分配
  4. 预分配容量:避免 slice/map 反复扩容
  5. 选择合适的特性:根据场景使用 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
下一篇Go 底层原理丨interface

来自开放网络的回应

引用、喜欢、转发和站外回复会通过 Webmention 回到这里。

接收与展示代码已经就绪,注册正式域名后即可开始收集回应。

评论

评论由 GitHub Discussions 保存和管理。