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

138 lines
4.8 KiB
Markdown
Raw Normal View History

2026-04-24 23:45:19 +08:00
---
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垃圾回收]]