vault backup: 2026-04-24 23:45:19
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
---
|
||||
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垃圾回收]]
|
||||
@@ -0,0 +1,97 @@
|
||||
# 为什么 Go 打破了"栈/堆分工"规则?
|
||||
|
||||
## 传统语言的设计哲学:静态可知
|
||||
|
||||
C/C++/Java 等语言遵循一个简单规则:**编译期能确定生命周期的放栈上,不能确定的放堆上**。
|
||||
|
||||
```
|
||||
传统语言的直觉逻辑:
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 局部变量 → 编译期知道作用域 → 栈 │
|
||||
│ new/malloc → 用户显式申请 → 堆 │
|
||||
│ │
|
||||
│ 规则是"语法驱动"的:new 就是堆, │
|
||||
│ 声明就是栈。程序员一目了然。 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
这个设计的好处是**直观**:程序员清楚地知道自己在哪分配内存,什么时候释放。
|
||||
|
||||
## Go 的突破:编译器做决定
|
||||
|
||||
Go 的逃逸分析把决策权从**程序员**转移到了**编译器**:
|
||||
|
||||
```
|
||||
Go 的逻辑:
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 编译器问:这个变量的生命周期多长? │
|
||||
│ │
|
||||
│ · 函数返回后不再用到 → 栈(快,零GC) │
|
||||
│ · 函数返回后还要用 → 堆(慢,有GC) │
|
||||
│ │
|
||||
│ 规则是"语义驱动"的: │
|
||||
│ new/malloc 出来的也可能在栈上! │
|
||||
│ 局部变量也可能逃到堆上! │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 打破规则的关键机制
|
||||
|
||||
### 1. `make` / `new` 不一定是堆分配
|
||||
|
||||
```go
|
||||
func f() {
|
||||
// 如果这个切片在函数返回后不再使用,
|
||||
// 编译器可以把它留在栈上!
|
||||
s := make([]int, 1000)
|
||||
_ = s[0]
|
||||
} // s 在这里销毁,不需要 GC 介入
|
||||
|
||||
// 只有当它"逃逸"时才会去堆上
|
||||
func g() []int {
|
||||
s := make([]int, 1000)
|
||||
return s // 逃逸!调用者返回后还要用
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 局部变量取地址也可能逃逸
|
||||
|
||||
```go
|
||||
func f() {
|
||||
x := 10
|
||||
_ = &x // 取地址后,编译器必须把 x 放堆上
|
||||
}
|
||||
```
|
||||
|
||||
## 为什么 Go 敢这么做?
|
||||
|
||||
核心原因是 **Go 有 GC**。
|
||||
|
||||
传统语言(特别是 C/C++)没有自动 GC,一旦变量逃逸到栈外就是 UB(未定义行为)。所以只能让用户用 `malloc` 显式控制。
|
||||
|
||||
Go 有 GC 兜底,编译器可以放心地说:
|
||||
|
||||
> "不管我把你放哪,你都不会被提前释放——我分析过你的生命周期了。"
|
||||
|
||||
这带来了三个优势:
|
||||
|
||||
| 优势 | 说明 |
|
||||
|------|------|
|
||||
| **自动优化** | 编译器自动选择最优位置,减少手动调优 |
|
||||
| **零 GC 开销** | 大部分局部变量留在栈上,不产生 GC 压力 |
|
||||
| **安全性** | 不会出现悬垂指针(dangling pointer),编译器保证不会引用已释放的栈内存 |
|
||||
|
||||
## 本质区别
|
||||
|
||||
```
|
||||
传统语言:语法决定分配位置
|
||||
局部变量声明 → 栈(默认)
|
||||
new/malloc → 堆(显式)
|
||||
→ 程序员负责生命周期管理
|
||||
|
||||
Go 语言:语义决定分配位置
|
||||
编译器分析生命周期 → 选最优位置
|
||||
→ 编译器负责,程序员专注正确性
|
||||
```
|
||||
|
||||
这正是 Go 的设计哲学缩影:**把程序员容易出错的低层细节交给编译器,让程序员关注更高层的语义正确性。**
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
tags: [go, golang, 虚拟内存, 内存管理, 操作系统]
|
||||
create time: 2026-04-24 10:00
|
||||
---
|
||||
|
||||
# 虚拟内存:Go 与其他语言的差异
|
||||
|
||||
## 概述
|
||||
|
||||
虚拟内存本身是操作系统提供的抽象,所有语言共享同一套底层机制(页表、MMU、page fault、swap)。但不同语言在**如何与虚拟内存交互**、**谁来管理分配**上有显著差异。理解这些差异,能帮助你更好地把握 Go 内存管理的独特之处。
|
||||
|
||||
> **思考**:既然虚拟内存是 OS 的特性,那语言的"内存管理"到底在管什么?——语言管的是**从虚拟内存到对象**之间的分配策略,而虚拟内存只是舞台背景。
|
||||
|
||||
## 内存分配"控制权"的归属
|
||||
|
||||
不同语言的内存分配器层级不同:
|
||||
|
||||
| 语言 | 分配链路 | 谁决定物理内存何时分配? |
|
||||
|------|---------|------------------------|
|
||||
| **C/C++** | 程序 → `malloc/new` → 系统库 → OS `mmap/brk` | 程序员 + malloc 库实现 |
|
||||
| **Go** | 程序 → Go `malloc` → mcache→mcentral→mheap → `mmap` | Go 运行时(自己当"二道贩子") |
|
||||
| **Java** | 程序 → JVM 堆管理器 → `mmap` 一大块 → 对象分配 | JVM(启动时 mmap 一大块,不主动归还) |
|
||||
| **Python** | 程序 → CPython pymalloc → `malloc` | CPython(小对象 pymalloc,大对象直接 malloc) |
|
||||
|
||||
### Go 的独特之处:自己当二道贩子
|
||||
|
||||
C 语言直接调系统 `malloc`(底层可能调 `mmap` 或 `brk`),而 Go 运行时**自己实现了从 OS 到对象的全套分配逻辑**:
|
||||
|
||||
```
|
||||
Go 的分配链路:
|
||||
程序 malloc → Go mcache → mcentral → mheap → mmap(64MB) → OS
|
||||
|
||||
C 的分配链路:
|
||||
程序 malloc → glibc/tcmalloc → mmap/brk → OS
|
||||
```
|
||||
|
||||
Go 先 `mmap` 一大块虚拟内存(默认 64MB),再自己管理 span 怎么分片。好处是**不需要依赖系统 malloc 的行为**,每一块内存的分配和回收都在 Go 自己的掌控之中。
|
||||
|
||||
## 栈 vs 堆:谁来决定?
|
||||
|
||||
这是 Go 和很多语言最大的区别:
|
||||
|
||||
```go
|
||||
// Go:编译器在编译期做逃逸分析,自动决定
|
||||
x := 10 // 可能留在栈上(零 GC 开销)
|
||||
p := &x // 逃逸到堆(GC 管理)
|
||||
|
||||
// Java:几乎全部对象都在堆上,栈上只有引用
|
||||
Object o = new Object(); // new 的一定在堆上
|
||||
|
||||
// C:完全由程序员决定
|
||||
int x = 10; // 栈(默认)
|
||||
int *p = malloc(4); // 堆,不 free 就泄漏
|
||||
```
|
||||
|
||||
Go 打破了"局部变量在栈、动态分配在堆"的传统分工——编译器会根据**生命周期**自动决定,程序员几乎不用操心。
|
||||
|
||||
> **思考**:如果你能交给编译器做决定,为什么不是所有语言都这么做?——答案在传统语言没有 GC,一旦变量逃逸到栈外就是 UB(未定义行为),所以只能让用户显式控制。
|
||||
|
||||
## GC 与虚拟内存的协作
|
||||
|
||||
| 语言 | GC 策略 | 和虚拟内存的关系 |
|
||||
|------|---------|-----------------|
|
||||
| **Go** | 并发三色标记-复制 | GC 后把不用的页 **`munmap` 还给 OS**,降低 RSS |
|
||||
| **Java** | CMS/G1/ZGC 等 | 通常**不主动还内存给 OS**(除非特殊配置) |
|
||||
| **C/C++** | 无 GC | 程序员 `free` 后,库可能还回 OS,也可能留着下次用 |
|
||||
|
||||
### Go 的"弹性"行为
|
||||
|
||||
当 GC 回收了大量内存后,Go 运行时会把空闲的页通过 `munmap` 直接还给操作系统,让 RSS 降下来:
|
||||
|
||||
```go
|
||||
// Go 进程的内存占用是弹性的
|
||||
func main() {
|
||||
data := make([]byte, 100<<20) // 100MB
|
||||
_ = data // RSS 涨
|
||||
data = nil
|
||||
runtime.GC() // RSS 回落!munmap 还给了 OS
|
||||
}
|
||||
```
|
||||
|
||||
这意味着 Go 进程的内存占用**随 workload 动态变化**——忙的时候涨,闲的时候退。而 Java 的 JVM 通常会"拿了就不还",吃内存比较"霸道"。
|
||||
|
||||
## 内存增长模式对比
|
||||
|
||||
```
|
||||
Go: [||||||||||||][ ][||||| ]
|
||||
↑ mmap 64MB → GC 后 munmap 还一部分 → 再次增长
|
||||
|
||||
Java: [|||||||||||||||||||||||||||||||||||][ ]
|
||||
↑ 启动时 mmap 一大块,几乎只增不减
|
||||
|
||||
C: [||][ ][|||||||][ ]
|
||||
↑ malloc/free 自由穿插,取决于程序逻辑
|
||||
```
|
||||
|
||||
Go 介于 Java 的"霸道"和 C 的"随意"之间——有自动增长的策略,但也有弹性收缩的机制。
|
||||
|
||||
## 大对象处理差异
|
||||
|
||||
Go 对 **>32KB 的对象**有特殊处理:跳过 mcache/mcentral,直接从 mheap 分配。这是因为大对象不值得放进细粒度池里管理。
|
||||
|
||||
| 语言 | 大对象处理 |
|
||||
|------|-----------|
|
||||
| **Go** | >32KB 直接走 mheap,跳过中间层 |
|
||||
| **Java** | G1 GC 把大对象放在 "humongous regions" |
|
||||
| **C** | glibc 对大对象通常直接 `mmap` |
|
||||
|
||||
## 按需分配的直观验证
|
||||
|
||||
```go
|
||||
// 这两行并不会真正占用 1GB 物理内存
|
||||
data := make([]byte, 1<<30) // 1GB 虚拟地址空间
|
||||
|
||||
// 直到你真正写入那一刻,操作系统才开始分配物理页
|
||||
data[0] = 1 // 触发 page fault,分配第一页(4KB)
|
||||
data[1024*1024] = 1 // 触发第二页
|
||||
|
||||
// ps 看 RSS 可能只有几 KB
|
||||
```
|
||||
|
||||
你 `make` 了 1GB,但实际物理内存只用了几 KB。这就是虚拟内存的 **Lazy Allocation** 机制——你**承诺**要用多大,操作系统**实际**只给多少。
|
||||
|
||||
> **思考**:既然物理内存没真正分配,那为什么 `make([]byte, 1<<30)` 还是会让进程变慢?——因为建立页表条目本身是有开销的,而且一旦触发 page fault,性能会有瞬时抖动。
|
||||
|
||||
## 总结
|
||||
|
||||
| 维度 | Go 的特色 |
|
||||
|------|----------|
|
||||
| **分配器** | 自己实现从 mmap 到对象的全链路,不依赖系统 malloc |
|
||||
| **栈/堆决策** | 编译期逃逸分析自动决定,程序员专注正确性 |
|
||||
| **内存弹性** | GC 后主动 munmap 还给 OS,RSS 可回落 |
|
||||
| **并发友好** | mcache 每 P 一份无锁缓存,天然配合 GMP 模型 |
|
||||
| **按需分配** | 和所有语言一样利用虚拟内存,但 Go 自己的分配策略更细粒度 |
|
||||
|
||||
**虚拟内存本身没变,但 Go 把它用得更"自动化"了**——你不需要像 C 那样担心泄漏,也不需要像 Java 那样忍受僵化的内存占用。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[DEV/GO/内存分配与逃逸分析]]
|
||||
- [[DEV/GO/GMP调度模型]]
|
||||
- [[DEV/GO/GC垃圾回收]]
|
||||
Reference in New Issue
Block a user