--- tags: [go, golang, runtime, 内存分配, mcache, mcentral, mheap] create time: 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 分配: ```go // 超过 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 内)完全避免锁竞争。 ## 关联笔记 - [[DEV/GO/内存分配与逃逸分析]] - [[DEV/GO/GMP调度模型]] - [[DEV/GO/GC垃圾回收]]