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

4.8 KiB
Raw Blame History

tags, create time
tags create time
go
golang
runtime
内存分配
mcache
mcentral
mheap
2026-04-24 10:30

三级缓存架构详解

概述

Go 的内存分配器采用 三级缓存 + 中央池 + 大对象快速通道 的分层架构,核心理念是"让最常见的操作最快"。本文通过生活化类比和底层实现两层视角,帮你彻底理解这个设计。

生活化类比:连锁便利店的仓储体系

🏬 个人抽屉 → mcache

每个店长(P / Goroutine)都有自己的抽屉,里面常备着最常用的商品(固定大小的小物件)。

  • 不需要请示任何人,直接伸手拿,零等待
  • 抽屉空间有限,装不了太多
  • 90% 的日常需求在这里就满足了

📦 门店后仓 → mcentral

店长的抽屉快空了,去后仓拿。

  • 后仓按商品类别整齐排列(8 字节的放一起、16 字节的放一起……共 67 个类别)
  • 拿的时候可能需要稍等(要拿钥匙 / 找店员,对应细粒度锁)
  • 容量比抽屉大得多

🏭 区域总仓 → mheap

门店后仓也空了?打电话给总仓库。

  • 总仓库什么都有,容量巨大
  • 但手续繁琐(全局锁,要等)
  • 只有实在缺货时才走这条路

🚚 超大订单(>32KB)→ 专车直送

如果你要订 1000 箱货(大对象),店长懒得经手中转,直接从总仓库专车送到你店里,跳过抽屉和后仓。

为什么这么设计?

核心思想就是:让最常见的操作最快。

场景 占比 处理方式 等待时间
小物件、同一个 goroutine ~90% 抽屉直接拿 0
偶尔缺货 ~9% 去后仓拿 轻微
真正缺大牌货 ~1% 找总仓库 明显

对比一下,如果所有东西都要去总仓库拿(比如某些语言的每次 malloc 都全局加锁),那整个体系就堵死了。

底层实现

第一级: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 内)完全避免锁竞争。

关联笔记