This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/内存分配与逃逸分析.md
T

9.7 KiB
Raw Blame History

tags, create time
tags create time
go
golang
runtime
内存分配
逃逸分析
虚拟内存
2026-04-24 10:00

内存分配与逃逸分析

概述

Go 的内存管理分为两个层面:运行时的内存分配(由 Malloc 实现)和编译期的逃逸分析(决定变量分配在栈还是堆)。理解这两个机制对编写高性能 Go 代码至关重要。

思考:为什么大多数语言把局部变量分配在栈上,而把 new/malloc 出来的分配在堆上?Go 的逃逸分析打破了这个规则——它如何让编译器决定分配位置?

详见 → DEV/GO/内存分配与逃逸分析/为什么Go打破栈堆分工规则.md

多级存储模型

计算机的存储层次决定了数据的访问速度,距离 CPU 越近越快:

graph LR
    subgraph Fast["速度快 / 容量小 / 昂贵"]
        REG[寄存器]
        L1["L1 缓存 (~30ns)"]
        L2["L2 缓存 (~100ns)"]
        L3["L3 缓存 (~300ns)"]
    end

    subgraph Slow["速度慢 / 容量大 / 便宜"]
        MEM["物理内存 (~100ns)"]
        DISK["磁盘 / SSD"]
        SWAP["Swap 区域"]
    end

    REG --> L1 --> L2 --> L3 --> MEM --> SWAP --> DISK

    classDef fast fill:#4caf50,color:#fff
    classDef slow fill:#ff9800,color:#fff
    class REG,SWAP fast
    class L1,L2,L3,MEM slow
    class DISK slow

核心认知:CPU 寄存器的访问速度是纳秒级,而内存是百纳秒级,磁盘是毫秒级。三者相差百万倍。

虚拟内存的好处

Go 程序运行在虚拟内存之上,每个进程拥有独立的虚拟地址空间(64 位系统为 128TB)。

拓展:Go 的虚拟内存使用方式和其他语言有什么不同?详见 → DEV/GO/内存分配与逃逸分析/虚拟内存与各语言对比.md

特性 说明
地址隔离 每个进程的虚拟地址空间互不影响
页面机制 内存按页(4KB)管理,未使用的页面不占用物理内存
SWAP 冷热切换 不常用的页(冷页)换出到磁盘,常用的页(热页)留在内存

思考:SWAP 机制让"冷热动态切换"成为可能。这为什么是操作系统最精妙的设计之一?如果所有数据都常驻物理内存,会发生什么?

虚拟内存的好处:

  1. 无需物理连续:虚拟地址可以是连续的,物理页可以分散
  2. 按需分配:malloc 并不真正分配物理内存,只是映射虚拟地址
  3. 冷热分页:频繁访问的页常驻内存,低频页换出到 Swap

Golang 内存模型:三级缓存架构

Go 的内存分配器采用三级缓存 + 中央池 + 大对象分配的分层架构,详见 → DEV/GO/内存分配与逃逸分析/三级缓存架构.md

flowchart TD
    subgraph ThreadLocal["线程局部 (无锁)"]
        MCACHE["mcache per-P\n每 P 一个 mcache"]
    end

    subgraph CentralPool["中央池 (细粒度锁)"]
        MCENTRAL["mcentral\n固定大小的对象池"]
    end

    subgraph Heap["堆 (全局重锁)"]
        MHEAP["mheap\n向 OS 申请内存"]
    end

    subgraph OS["操作系统"]
        MEM["mmap 映射虚拟内存\n默认 64MB 起始"]
    end

    MCACHE -->|"缓存不够时"| MCENTRAL
    MCENTRAL -->|"所有规格都空时"| MHEAP
    MHEAP -->|"mmap 向 OS 申请"| MEM

    classDef local fill:#4caf50,color:#fff
    classDef central fill:#2196f3,color:#fff
    classDef heap fill:#ff9800,color:#fff
    classDef os fill:#9e9e9e,color:#fff
    class MCACHE local
    class MCENTRAL central
    class MHEAP heap
    class MEM os

第一级:mcache(线程级,无锁)

每个 P 拥有独立的 mcache,是 Goroutine 最近的内存分配点。

  • 无锁设计:因为每个 P 独占自己的 mcache,无需竞争
  • 缓存固定大小的对象:每种大小类(size class)缓存一个 object 链表
  • Go 定义了 67 种对象大小类别,覆盖 0 ~ 32KB 的对象
mcache 内部结构(简化):
  mcache.span[class0] → 缓存大小为 8 字节对象的 span
  mcache.span[class1] → 缓存大小为 16 字节对象的 span
  ...
  mcache.span[class66] → 缓存大小为 32768 字节对象的 span

第二级:mcentral(池级,细粒度锁)

mcentral 管理多种规格(span class)的内存池,每种规格有独立的 mspan。

  • 按大小分组:同样大小的对象放在同一个 mspan 中
  • 细粒度锁:每种规格的 mspan 有独立的锁,而非全局一把大锁
  • 当 mcache 的缓存不够时,向 mcentral 请求新的 span
mcentral 内部结构(简化):
  mcentral.spans[class0] → mspan (管理大小为 8 字节的对象)
  mcentral.spans[class1] → mspan (管理大小为 16 字节的对象)
  ...

第三级:mheap(堆级,全局锁)

mheap 是整个程序的"大仓库",直接向操作系统申请内存。

  • 全局锁保护:申请新内存时需要获取全局锁
  • mmap 映射:通过 mmap 向 OS 请求内存(初始约 64MB,可增长)
  • 分配 span 给 mcentral,形成完整的分配链路
mheap 内部结构(简化):
  mheap.all[] → 管理所有 span 的数组
  mheap.pageAlloc → 位图,标记每个页面是否已分配

大对象分配(>32KB)

超过 32KB 的对象跳过 mcache 和 mcentral,直接从 mheap 分配:

// 超过 32KB 的对象直接走 mheap 路径
func benchmarkLargeAlloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = make([]byte, 64*1024)  // 64KB,直接走 mheap
    }
}

// 小于 32KB 的对象走 mcache 路径(更快)
func benchmarkSmallAlloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = make([]byte, 1024)  // 1KB,走 mcache 路径
    }
}

思考:为什么 32KB 是一个分界线?这与 CPU 缓存行(cache line,通常 64 字节)和页大小(4KB)有什么关系?

锁粒度对比

层级 锁粒度 竞争程度 适用场景
mcache 无锁(每个 P 独立) 零竞争 频繁的小对象分配
mcentral 按大小类细粒度锁 低竞争 中等频率分配
mheap 全局锁 高竞争 大对象分配、扩容

设计精妙之处:Go 通过分层 + 无锁设计,让最常见的内存分配(小对象、同一 P 内)完全避免锁竞争。

逃逸分析

逃逸分析是编译器在编译期进行的静态分析,决定变量分配在栈还是堆上。

变量分配策略:
  - 栈:函数返回后自动释放,零 GC 开销
  - 堆:GC 管理生命周期,有 GC 开销但可超越函数作用域

逃逸规则(简化版):
  如果变量的生命周期在函数返回后仍然需要 → 逃逸到堆
  否则 → 留在栈上

逃逸场景

场景 1:闭包引用局部变量

func main() {
    x := 10  // 逃逸到堆!因为匿名函数在 main 返回后仍可能运行
    f := func() int {
        return x  // 闭包持有对 x 的引用
    }
    fmt.Println(f())
}

编译时 go build -gcflags="-m" 可以看到:

go build -gcflags="-m" main.go
# 输出:
# ./main.go:4:2: x escapes to heap

场景 2:返回局部变量的指针

func newInt() *int {
    x := 10  // 逃逸到堆!返回了 &x
    return &x  // 如果留在栈上,函数返回后 x 就被销毁了
}

场景 3:空接口(interface{})

func main() {
    x := 100
    var i interface{} = x  // x 逃逸到堆!
    // 因为 interface{} 是动态类型,编译器无法在编译期确定 x 的生命周期
}

场景 4:切片长度在运行时才确定

func main() {
    n := 10
    s := make([]int, n)  // n 是运行时确定的 → 切片逃逸到堆
    // 如果长度是常量:make([]int, 10) → 小切片可能留在栈上
}

场景 5:接口参数传递

func process(v interface{}) {
    // 参数 v 传入时,其底层数据可能逃逸
    _ = v
}

func main() {
    x := 42
    process(x)  // x 可能逃逸
}

逃逸分析实战

# 查看逃逸分析结果
go build -gcflags="-m" ./...

# 更详细的输出
go build -gcflags="-m -m" ./...
package main

func main() {
    // 不逃逸 - 分配在栈上
    x := 10

    // 逃逸 - 分配在堆上
    p := &x           // 取地址 → x 逃逸
    _ = *p
}

编译输出:

./main.go:7:2: &x escaped to heap

栈 vs 堆对比

维度 栈 堆
分配速度 极快(只需移动栈指针) 较慢(需要锁和内存管理)
释放速度 自动(函数返回时栈帧弹出) GC 回收(有延迟)
GC 开销 无 有
大小限制 有限(goroutine 栈初始 2KB) 几乎无限(受限于虚拟内存)
生命周期 函数作用域内 可超越函数作用域

优化建议:尽量减少逃逸到堆的变量数量,可以让 GC 负担更轻。

// ❌ 不推荐:不必要的指针传递导致逃逸
func getData() *Data {
    return &Data{Name: "test"}  // Data 逃逸到堆
}

// ✅ 推荐:值传递,避免不必要的指针
func getData() Data {
    return Data{Name: "test"}  // 小结构体留在栈上
}

总结

Go 内存管理的核心设计理念:

  1. 分层分配:mcache(无锁)→ mcentral(细锁)→ mheap(全局锁),按需降级
  2. 大对象快速通道:>32KB 的对象跳过中间层,直接走 mheap
  3. 编译期逃逸分析:尽可能让变量留在栈上,减少 GC 压力
  4. 虚拟内存抽象:按需映射,冷热分页,让程序拥有 128TB 地址空间

关联笔记