Go 语言学习笔记
一、基础语法
1.1 结构体与接口
Go 中结构体和接口通过**隐式实现(Duck Typing)**关联,不需要implements关键字。
typeSpeakerinterface{Speak()string}typeDogstruct{Namestring}func(d Dog)Speak()string{returnd.Name+": Woof!"}// Dog 自动满足 Speaker 接口,无需声明funcMakeItSpeak(s Speaker){fmt.Println(s.Speak())}方法集规则:
T值只能调用值接收者方法*T指针可调用值+指针接收者方法
编译期验证:
var_Speaker=Dog{}// 验证值接收者var_Speaker=(*Dog)(nil)// 验证指针接收者1.2 不同接口同名方法
| 场景 | 结果 |
|---|---|
| 同名同签名 | 一次实现,同时满足两个接口 |
| 同名同签名,语义不同 | 编译通过但逻辑错误,需拆分类型 |
| 同名不同签名 | 无法同时满足,编译错误 |
Go 只看方法签名,不看接口名字或语义。
1.3 切片(Slice)
底层结构
typeslicestruct{ptr*array// 指向底层数组的指针lenint// 当前长度capint// 容量}len vs cap
- len:当前可读写的元素个数(
s[i]中0 <= i < len合法) - cap:当前不重新分配底层数组,最多能装多少个元素
底层数组: [1] [2] [3] [4] [5] [6] [7] [8] ↑ ↑ ↑ ptr len cap[0, len)范围:可读可写[len, cap)范围:空间已分配,但无法直接访问,只能通过append逐个扩展- 超过 cap:自动扩容,不是硬限制
扩容规则(Go 1.18+)
| 条件 | 最终容量 |
|---|---|
| 期望容量 > 旧容量 * 2 | 直接用期望容量 |
| 旧容量 < 256 | 新容量 = 旧容量 * 2 |
| 旧容量 >= 256 | 新容量 = 旧容量 + 旧容量/4 + 192(平滑增长,趋近 ~1.25x) |
计算后还会进行内存对齐,实际 cap 可能更大。
扩容本质:分配新底层数组 + memmove 批量拷贝旧数据。
常见陷阱
// 1. 共享底层数组a:=[]int{1,2,3,4,5}b:=a[1:3]b=append(b,99)// cap够,不扩容,覆盖 a[3]!// 2. 三索引切割限制 capb:=a[1:3:3]// len=2, cap=2,限制 capb=append(b,99)// cap不够,扩容,不影响 a排序
sort.Slice(推荐):
sort.Slice(people,func(i,jint)bool{returnpeople[i].Age<people[j].Age// 升序})slices.SortFunc(Go 1.21+):
slices.SortFunc(people,func(a,b Person)int{returna.Age-b.Age// 升序:a-b,降序:b-a})cmp(a, b)返回负数 -> a 排前面,正数 -> b 排前面,0 -> 相等。
1.4 数组 vs 切片
| 数组 | 切片 | |
|---|---|---|
| 长度 | 固定,编译期确定 | 动态,运行时可变 |
| 类型 | [3]int和[5]int是不同类型 | []int统一类型 |
| 传参 | 值拷贝 | 传切片头(廉价) |
[...]int{1,2,3}是数组初始化的语法糖,编译器自动推断长度,结果等同于[3]int{1,2,3},仍是固定长度数组。不能用于函数参数或类型声明。
1.5 可变参数
funcsum(nums...int)int{...}// nums 内部是 []intsum(1,2,3)// 直接传sum(nums...)// 展开切片传入1.6 基本数据类型
| 分类 | 类型 |
|---|---|
| 布尔 | bool |
| 有符号整数 | int8int16int32int64int |
| 无符号整数 | uint8uint16uint32uint64uintuintptr |
| 浮点 | float32float64 |
| 复数 | complex64complex128 |
| 字符串 | string |
| 别名 | byte(uint8)rune(int32) |
1.7 rune 与 string
- rune:Unicode 码点,
int32,一个整数编号 - string:UTF-8 编码后的字节序列
varrrune='中'// 码点: 20013 (0x4E2D)varsstring="中"// UTF-8: E4 B8 AD(3字节)s:=string(r)// 码点 → UTF-8 编码r,_:=utf8.DecodeRuneInString(s)// UTF-8 → 码点字符串索引s[i]返回byte(uint8),range遍历返回rune(int32)。
二、make vs new
| new(T) | make(T) | |
|---|---|---|
| 返回值 | *T指针 | T值 |
| 初始化 | 零值(不初始化内部结构) | 完整初始化(可直接使用) |
| 适用范围 | 任何类型 | 仅 slice、map、channel |
| 类型 | make | 字面量 | var 声明 | var 后能否直接用 |
|---|---|---|---|---|
| 切片 | 可 | 可 | 可 nil切片 | append 可,读写可 |
| Map | 可 | 可 | 可 nil map | 读取可,写入 panic |
| Channel | 可 | 不可 | 可 nil channel | 发送/接收永久阻塞,close panic |
2.2 切片五种声明方式对比
| 方式 | 类型 | len | cap | == nil | 能直接用 |
|---|---|---|---|---|---|
make([]int, 0) | []int | 0 | 0 | false | 可 |
[]int{} | []int | 0 | 0 | false | 可 |
new([]int) | *[]int | 0 | 0 | *s == niltrue | 需解引用 |
var a []int | []int | 0 | 0 | true | append 可 |
var a *[]int | *[]int | — | — | a == niltrue | panic! |
new([]int)vsvar a *[]int
// new([]int) → 指针非 nil,指向一个 nil 切片b:=new([]int)// b != nil(指针本身有值)// *b == nil(指向的切片是 nil)// *b = append(*b, 1) ✅ 可以用// var a *[]int → 指针本身是 nil,什么都没指向vara*[]int// a == nil// *a → panic! 解引用 nil 指针// 必须先分配:a = new([]int) 或 a = &[]int{} 才能用三、并发编程
3.1 Channel
Channel 是 goroutine 之间传递数据和同步的管道。
Go 哲学:不要通过共享内存来通信,而要通过通信来共享内存。
无缓冲 vs 有缓冲
| 无缓冲 | 有缓冲 | |
|---|---|---|
| 创建 | make(chan int) | make(chan int, N) |
| 发送阻塞 | 没人接收就阻塞 | 缓冲区满才阻塞 |
| 接收阻塞 | 没人发送就阻塞 | 缓冲区空才阻塞 |
| 本质 | 同步,一对一握手 | 异步,生产者-消费者 |
关闭 Channel
| 规则 | 说明 |
|---|---|
| 谁发送谁关闭 | 只有发送方关闭 |
| 不能关闭已关闭的通道 | panic |
| 不能向已关闭的通道发送 | panic |
不是必须关闭,唯一必须关闭的场景是接收方用range遍历时。不关闭range会死锁。
方向限定
funcsend(chchan<-int){}// 只写funcrecv(ch<-chanint){}// 只读3.2 获取协程执行结果
goroutine 的返回值会被丢弃,只能通过 channel 获取结果。
typeResultstruct{IDintValueintErrerror}ch:=make(chanResult,3)fori:=0;i<3;i++{gofunc(idint){val,err:=doWork(id)ch<-Result{ID:id,Value:val,Err:err}}(i)}fori:=0;i<3;i++{r:=<-ch fmt.Printf("任务%d: 结果=%d\n",r.ID,r.Value)}3.3 GMP 调度模型
G - Goroutine 协程(用户态轻量线程) M - Machine 操作系统线程(内核态) P - Processor 逻辑处理器(调度上下文)P 是 GMP 的灵魂:M 必须持有 P 才能执行 G。
M 从哪拿 G(优先级)
1. P 的本地队列(LRQ) ← 优先,无锁 2. 全局队列(GRQ) ← 每 61 次取 1 个,有锁但极少 3. 其他 P 偷(work stealing) ← 本地空了才偷 4. 网络轮询器(netpoll) ← 等 IO 的 G 5. 阻塞等待 ← 实在没有就休眠两种阻塞的区别
| 系统调用阻塞 | Channel/IO 阻塞 | |
|---|---|---|
| 谁阻塞了 | M 阻塞(线程卡在内核) | G 阻塞(协程挂起) |
| P 要不要解绑 | 必须,M 卡住了 | 不需要,M 还活着 |
| M 的去向 | 等系统调用返回 [系统调用返回后,M 优先找回原 P,原 P 忙就找其他空闲 P,都没有就把 G 放全局队列、M 自己休眠。M 不会干等着,G 也不会丢。] | 继续执行其他 G |
| P 的去向 | 找新 M 绑定(hand off) | 不动,继续用当前 M |
全局队列的锁竞争
全局队列确实有锁,但访问频率极低:
- P 优先从本地无锁队列取 G(99%)
- 每 61 次调度才从全局队列取 1 个
- 取时批量获取,减少访问次数
- 本地队列满溢出时才放全局队列
抢占式调度
- G 执行超过 10ms 被标记为抢占
- Go 1.14+ 基于信号的抢占,即使死循环也能被踢走
3.4 Goroutine 轻量在哪里
3.4.1 栈空间小
操作系统线程:固定栈 1~8MB(创建即分配) Goroutine:初始栈 2KB,按需增长,最多到 1GB1GB 内存能跑多少? 线程:1GB / 8MB ≈ 125 个 Goroutine:1GB / 2KB ≈ 50 万个Goroutine 的栈是动态增长的:初始 2KB,不够用时自动扩容(复制到更大的栈)。
3.4.2 创建/销毁成本低
操作系统线程:创建要陷入内核(系统调用),分配栈内存,初始化内核数据结构 Goroutine:纯用户态创建,只分配一个 G 结构体 + 2KB 栈3.4.3 切换成本低
| 线程切换 | Goroutine 切换 | |
|---|---|---|
| 陷入内核 | 要 | 不要 |
| 切换页表 | 要 | 不要(共享地址空间) |
| 保存恢复寄存器 | 全部(16+个) | 少量(3~5个) |
| 缓存影响 | 大(TLB 刷新) | 小 |
| 耗时 | 1~10μs | 100~200ns |
3.4.4 调度在用户态
线程调度:内核调度器决定,无法控制何时切换 Goroutine调度:Go runtime(用户态)决定,GMP 模型调度- 线程阻塞 → 内核切换,整个线程卡住
- Goroutine 阻塞(channel)→ 只挂起 G,M 继续跑其他 G
总结对比
| 线程 | Goroutine | |
|---|---|---|
| 栈大小 | 1~8MB 固定 | 2KB 起步,动态增长 |
| 创建成本 | 系统调用,重 | 用户态,轻 |
| 切换成本 | 1~10μs | 100~200ns |
| 调度者 | 内核 | Go runtime(用户态) |
| 1GB 内存 | ~125 个 | ~50 万个 |
一句话:Goroutine 轻量在于初始栈仅 2KB(线程 1~8MB)、创建和切换都在用户态(不陷入内核)、切换只保存少量寄存器(约 100ns vs 线程 1~10μs),所以一台机器能轻松跑几十万个 Goroutine。
3.5 GOMAXPROCS
3.5.1 核心含义
GOMAXPROCS = P 的数量 = 最多同时运行的 M 数量 = 并行度// 默认值 = CPU 逻辑核心数runtime.NumCPU()// 比如 8runtime.GOMAXPROCS(0)// 查看当前值,返回 8// 手动设置runtime.GOMAXPROCS(4)// 限制只用 4 个线程3.5.2 对 GMP 的影响
GOMAXPROCS = 4 创建 4 个 P:P0 P1 P2 P3 最多 4 个 M 同时并行执行 G 即使你有 10 万个 goroutine,同时跑的也只有 4 个GOMAXPROCS=1 时: P0—M0 → 所有 G 排队轮流跑(并发,不并行) GOMAXPROCS=4 时: P0—M0 → G1 G3 G5 ... P1—M1 → G2 G4 G6 ... ← 4 个真正同时跑(并行) P2—M2 → G7 G9 ... P3—M3 → G8 G10 ...3.5.3 注意:M 的数量可以超过 GOMAXPROCS
runtime.GOMAXPROCS(4)// 4 个 P// 但 M 可能多于 4 个!// 原因:系统调用阻塞时 M 卡住,P 会找新 M// 所以 M 数量 = 活跃 M(≤P数量) + 阻塞中 MP0—M0(G1 系统调用阻塞) ← M0 卡住 P0—M1(G2) ← P0 解绑后找新 M1 此时 M 数量 = 2,但 P 仍然 = 13.5.4 可以设超过 CPU 核心数吗
技术上可以,但不推荐。
4核 CPU,设 GOMAXPROCS=100 → Go 创建 100 个 P,最多 100 个 M 同时抢 4 个核 → 4个真正在跑,96个在等待 → 内核线程切换开销暴涨(1~10μs/次) → TLB 缓存频繁失效 → CPU 缓存命中率下降 → 实际性能反而下降3.5.5 什么时候要改
| 场景 | 设置 |
|---|---|
| 日常开发 | 不用改,默认 = CPU 核心数 |
| CPU 密集型 | 保持默认,多了反而增加切换开销 |
| IO 密集型 | 可以适当调大(如 4 核设 6),让更多 G 并行等 IO |
| 容器环境 | 注意!默认取宿主机核心数,不是容器限制 |
3.5.6 容器中的坑
// 宿主机 32 核,容器限制 2 核runtime.GOMAXPROCS(0)// 返回 32!不是 2// Go 1.5~1.21 的坑:默认创建 32 个 P,但容器只有 2 核// → 线程竞争严重,性能下降// 解决方案runtime.GOMAXPROCS(2)// 手动设为容器限制的核心数// Go 1.22+ 自动识别 cgroup 限制,不再需要手动设一句话:GOMAXPROCS= P 的数量 = 最大并行度,默认等于 CPU 核心数。它限制的是同时执行 Go 代码的线程数,不限制 goroutine 数量,也不限制因系统调用阻塞而产生的额外线程数。设超过 CPU 核心数只会增加线程竞争和切换开销,降低性能。
四、垃圾回收(GC)
4.1 核心特点
| 特点 | 说明 |
|---|---|
| 算法 | 三色标记清除 |
| 触发方式 | 并发,不 Stop The World(大部分时间) |
| 不支持 | 无分代、无压缩 |
4.2 三色标记法
| 颜色 | 含义 |
|---|---|
| 白色 | 未被访问,GC 结束后回收 |
| 灰色 | 已访问,但引用的对象还没全访问 |
| 黑色 | 已访问,且引用的对象都已访问 |
过程:从根对象出发,标灰 → 标黑 + 引用标灰 → 直到没有灰色 → 剩余白色 = 垃圾。
4.3 golang V1.5
插入写屏障机制:并发标记时,用户代码可能修改引用关系导致漏标。写屏障在每次修改引用时,强制将被引用的对象标灰,防止漏标。
插入写屏障只对堆生效,栈上没有写屏障(栈操作太频繁,加屏障性能损失大)。
GC 完整流程:
1. 标记准备(STW,极短) → 开启插入写屏障,根对象标灰 2. 并发标记(和用户代码并行)→ 扫描灰色对象,将灰色对象标黑,且将其引用的对象标灰,插入写屏障保护,发生引用修改即新增一个对象引用另外一个对象时,将被引用对象标灰 3. 标记终止(STW,极短) → 重扫描栈,处理剩余灰色,关闭写屏障 4. 并发清除(和用户代码并行)→ 回收白色对象STW(Stop The World):暂停所有用户代码,只让 GC 干活。Go 只在开头和结尾各 STW 极短时间(微秒级)。
为什么不对栈上的对象开启写屏障: 因为go在并发运行时,大部分的操作都发生在栈上,函数的调用会非常频繁。数十万goroutine的栈堆进行屏障保护自然会有性能问题。
缺点:Go 1.5 的插入写屏障要求堆上对象在标记结束前必须是灰色或黑色,但栈上对象没有写屏障保护,所以必须在 STW 期间重新扫描所有栈。这是 Go 1.5 STW 偏长的根本原因。1.5 的标记终止 STW 不是微秒级,因为要重扫描所有 goroutine 的栈,通常在 10~100ms 级别。V1.8以后达到微妙级别。
4.4 golang V1.8
混合写屏障机制=插入写屏障+删除写屏障
- [1] GC刚开始的时候,会将栈上可达对象全部标记为黑色
- [2] GC期间,任何在栈上新创建的对象均为黑色
- [3] 堆上指针被覆盖时,旧值(被删除的引用)标灰 ← Yuasa 删除屏障
- [4] 堆上写入新指针时,新值(新引用的对象)标灰 ← Dijkstra 插入屏障
[1][2]步只有一个目的,将栈上的可达对象全部标黑,最后无需对栈进行STW[也就是V1.5的第三步的STW],就可以保证栈上的对象不会丢失。有人说,一直是黑色的对象,那么不就永远清除不掉了,这里强调一下,标记为黑色的是可达对象,不可达的对象一直会是白色,直到最后被回收。
GC完整流程:
1. 标记准备(STW,极短) → 开启混合写屏障,堆的根对象标灰,栈的所有对象标黑 2. 并发标记(和用户代码并行)→ 堆:扫描灰色对象,将灰色对象标黑,且将其引用的对象标灰,混合写屏障保护,新创建和删除的对象标灰;栈: 所有新创建的对象标黑 3. 标记终止(STW, 极短) → 关闭混合写屏障,无需重扫描栈,相对与V1.5可达到微妙级别 4. 并发清除(和用户代码并行)→ 回收白色对象4.5 面试角度总结
如果你准备面试,核心要记住这个演进线索:
Go 1.5:插入写屏障(仅堆)
→ 栈没有写屏障 → 标记终止要 STW 重扫描所有栈 → STW 10~100ms
Go 1.8:混合写屏障(插入+删除,仅堆)
→ GC 开始时栈全部标黑 + 新对象标黑 → 不需要重扫描栈
→ 标记终止 STW 降到微秒级
Go 1.14:基于信号的抢占式调度
→ 消除了死循环导致 GC 无法触发的问题
面试一句话答法:Go 1.5 用 Dijkstra 插入写屏障只保护堆,栈没屏障所以标记终止要 STW 重扫描栈,停顿在 10~100ms;Go 1.8 改用混合写屏障(插入+删除),GC 开始时把栈上可达对象全部标黑,期间新对象也标黑,这样就不需要重扫描栈了,STW 降到微秒级。
4.6 GC 触发条件
| 条件 | 说明 |
|---|---|
| 堆内存增长到阈值 | 默认 GOGC=100,堆翻倍时触发 |
| 2分钟未触发 | 强制触发一次 |
| 手动调用 | runtime.GC() |
4.7 减少GC压力
- 减少堆分配,用值类型
sync.Pool复用对象- 预分配切片
make([]int, 0, N) strings.Builder代替字符串拼接
五、init 函数
- 在 main 函数之前,包被导入时自动执行
- 不能被调用,不能有参数和返回值
- 一个文件可有多个 init,按定义顺序执行
- 跨包:依赖的包先初始化
- 只执行一次,即使包被多次导入
执行顺序:全局变量初始化 → init → main
六、defer
- 延迟执行,函数返回前调用,先进后出(栈)
- 参数在 defer 时就确定
- 最常用场景:资源释放(Close/Unlock)
- defer 可以修改命名返回值
- 循环中 defer 会堆积,改用匿名函数
funcwork(){mu.Lock()defermu.Unlock()// 不怕中间 return 忘记解锁}七、recover
只有在 defer 中调用 recover 才能捕获 panic,其他地方调用返回 nil。
deferfunc(){ifr:=recover();r!=nil{fmt.Println("捕获:",r)}}()八、装饰器
Go 没有装饰器语法,用函数包装实现:
functimer(fnfunc())func(){returnfunc(){start:=time.Now()fn()fmt.Printf("耗时: %v\n",time.Since(start))}}work:=timer(work)// 包装work()HTTP 中间件是最典型的装饰器模式。
九、JSON 处理
typeUserstruct{Namestring`json:"name"`Ageint`json:"age"`Emailstring`json:"email,omitempty"`Passstring`json:"-"`}// 解析varuser User json.Unmarshal([]byte(jsonStr),&user)// 必须传指针// 序列化data,_:=json.Marshal(user)| tag | 作用 |
|---|---|
json:"name" | JSON 字段名为 name |
json:"-" | 忽略该字段 |
json:"name,omitempty" | 为零值时省略 |
注意:数字默认解析为float64。
十、反射(reflect)
10.1 TypeOf vs ValueOf
reflect.TypeOf(x)→ reflect.Type → 类型信息(名字、字段、tag、方法签名) reflect.ValueOf(x)→ reflect.Value → 值信息(读值、改值、调方法)10.2 操作结构体
t:=reflect.TypeOf(u)// Type 上v:=reflect.ValueOf(u)// Value 上// 字段t.NumField()// 字段数量t.Field(i).Name// 字段名t.Field(i).Tag.Get("json")// tagv.Field(i)// 字段值v.FieldByName("Name")// 按名字取值// 方法t.NumMethod()// 方法数量t.Method(i).Name// 方法名v.MethodByName("X").Call(args)// 调用方法10.3 Map 相关
// Type 上t.Key()// map 的 key 类型t.Elem()// map 的 value 类型 / slice 元素类型 / chan 元素类型// Value 上v.MapKeys()// 所有 keyv.MapIndex(key)// 根据 key 取 value10.4 修改值(必须传指针)
v:=reflect.ValueOf(&x)v=v.Elem()// 解引用v.SetInt(100)// 修改10.5 Kind 种类
reflect.Int/reflect.String/reflect.Bool/reflect.Struct/reflect.Map/reflect.Slice/reflect.Ptr/reflect.Chan/reflect.Func十一、内存分配三级结构
11.1 整体架构
┌──────────────────────────────────────────────────┐ │ mheap │ │ 全局唯一,从 OS 申请大块内存,按类别管理 │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │mcentral │ │mcentral │ │mcentral │ ... │ │ │ (8B) │ │ (16B) │ │ (32B) │ │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ │ │mcache │ │mcache │ │mcache │ ... │ │ │(P0) │ │(P1) │ │(P2) │ │ │ └─────────┘ └─────────┘ └─────────┘ │ └──────────────────────────────────────────────────┘11.2 mcache(每个 P 一个)
类比:每个人的工具箱,无锁拿工具。
- 每个 P 绑定一个 mcache,无锁
- 分配速度最快,大部分小对象从这里分配
- mcache 没货了才找 mcentral
P0—mcache: [8B块][16B块][32B块][64B块]... P1—mcache: [8B块][16B块][32B块][64B块]...每种规格只有一个 mspan,用满了就找 mcentral 换一个新的(旧的还回去),一换一。
11.3 mcentral(每个规格一个)
类比:公共仓库,某个规格的工具箱空了来进货。
- 每种规格一个 mcentral(8B、16B、32B… 共 67 种)
- 有锁,但竞争不严重(只有 mcache 空了才来)
- mcentral 没货了才找 mheap
mcentral(8B): [span1][span2][span3]... ← 8字节规格 mcentral(16B): [span1][span2][span3]... ← 16字节规格 mcentral(32B): [span1][span2][span3]... ← 32字节规格 ...67种规格11.4 mheap(全局唯一)
类比:总仓库,从工厂(OS)进货。
- 全局唯一,大锁
- 管理所有内存页(8KB/页)
- 从 OS 申请内存(mmap)
- 大对象(>32KB)直接从 mheap 分配
11.5 mspan 是什么
mspan 是内存分配的基本单位,是连续的内存页。
一个 mspan = N 个连续页(每页 8KB) mspan(32B规格, 8页): ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ 32B │ ... │ 空闲 │ 已用 │ 空闲 │ 已用 │ 已用 │ 空闲 │ 空闲 │ 已用 │ └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘ 8页 × 8KB = 64KB ÷ 32B = 2048 个槽位11.6 内存规格(Size Class)
Go 把小对象分成 67 种规格:
class 0: 微小对象(≤16B),用 tiny 缓存 class 1: 8B class 2: 16B class 3: 32B class 4: 48B class 5: 64B ... class 67: 32768B (32KB)// 分配 24B → 找最接近的规格 → 32B// 分配 50B → 找最接近的规格 → 64B// 会有内部碎片,但避免了外部碎片11.7 分配流程
分配对象 │ ├─ 微小对象(≤16B) → mcache 的 tiny 缓存 │ ├─ 小对象(16B~32KB) → mcache → mcentral → mheap │ └─ 大对象(>32KB) → 直接从 mheap 分配分配 24 字节对象 │ │ 1. 确定规格:24B → 32B 规格 ↓ mcache(32B) 有空闲? ├─ 有 → 直接分配,无锁 ✅(99%走这里) │ │ 没有 ↓ mcentral(32B) 有空闲 mspan? ├─ 有 → 拿一个 mspan 给 mcache,再分配(有锁,但极少) │ │ 没有 ↓ mheap 有空闲页? ├─ 有 → 切成 mspan 给 mcentral → 给 mcache → 分配 │ │ 没有 ↓ 向 OS 申请内存(mmap) → 切成 mspan → mcentral → mcache → 分配11.8 回收流程
对象不被引用 │ ├─ GC 标记为垃圾 ↓ mcache 中的空闲 mspan │ ├─ 归还给 mcentral ↓ mcentral 中大量空闲的 mspan │ ├─ 归还给 mheap ↓ mheap 空闲页 │ ├─ 大量空闲时归还给 OS(madvise)11.9 三级对比
| mcache | mcentral | mheap | |
|---|---|---|---|
| 数量 | 每个 P 一个 | 每种规格一个 | 全局唯一 |
| 锁 | 无锁 | 有锁(每规格独立) | 全局大锁 |
| 速度 | 最快 | 较快 | 最慢 |
| 分配对象 | 小对象(≤32KB) | 给 mcache 补货 | 大对象 + 给 mcentral 补货 |
| 类比 | 个人工具箱 | 规格仓库 | 总仓库(从OS进货) |
11.10 为什么这样设计
核心目标:减少锁竞争 无 mcache(所有分配都找 mcentral): P0 → mcentral 🔒 P1 → mcentral 🔒 ← 所有 P 抢同一把锁 P2 → mcentral 🔒 有 mcache: P0 → mcache(无锁)✅ P1 → mcache(无锁)✅ ← 各取各的 P2 → mcache(无锁)✅ 只有 mcache 空了才找 mcentral(极少)和 GMP 的设计思路一样:本地优先,无锁快路径 + 全局慢路径兜底。