快速搞懂 sync.Map
1. sync.Map长啥样
1.1 结构定义
1.1.1 Map顶层结构
type Map struct {
mu sync.Mutex // 一把大锁,只用于 dirty 晋升、miss 清零等低频动作
read atomic.Pointer[readOnly] // 读热路径,CAS 无锁
dirty map[any]*entry // 写操作的主战场,需要 mu 保护
misses int // read 未命中次数,达到 len(dirty) 触发晋升
}
read是一个原子指针,指向只读结构 readOnlydirty是普通map,由锁mu保护misses用于dirty晋升的计数器,用来决定何时把 dirty 整体搬进 read
1.1.2 readOnly
type readOnly struct {
m map[any]*entry // 只读 map
amended bool // true ⇒ dirty 里还有 read 中没有的 key
}
readOnly.m与dirty中的key可能同时存在,但值指针指向同一个entryamended的一些细节amended的影响效果为
false时,读操作可以完全不走锁为
true时,读未命中需要 加锁再到dirty里找
初始状态:
read.amended == false同时dirty == nil变更时机:在
Store时:key既不在read.m也不在dirty(因为 dirty 可能是 nil)于是需要先把
read中未被删除的key复制出来初始化 dirty(dirtyLocked)紧接着执行
m.read.Store(readOnly{m: read.m, amended: true})把amended设为true
只要
dirty里还有read中没有的新key,amended就一直是true当
misses == len(dirty)触发晋升,或手动调用 Range 强制晋升时,dirty被整体提升为read,dirty置为nil,amended重新变回false
1.1.3 entry
type entry struct {
p atomic.Pointer[any] // 实际存储 *value 的指针
}
p有三种形态正常指针:存储
*value的指针nil:已删除(key仍在,但值已经删除,lazy清理)expunged:晋升时,由源码统一把当前p为nil的entry标记为expunged,从而告诉后续写入“这条 key 在 dirty 里已不存在”
entry为expunged的一些细节:晋升时,只把未删除 (非 expunged) 的
key/entry复制到新 read,而expunged的 key 被自然丢弃 (不再放进新的 readOnly.m)晋升完成后,旧
readOnly对象成为无人引用的垃圾,整个 map,连同里面的expunged entry一起被 GC 回收如果 key 只在 read 里且已 expunged,而 dirty 为 nil,后续也没有任何 Store 再出现这个 key,那么:
read里的这条记录会一直存在直到下一次晋升,或整个 Map 被释放时,才会随旧 read 一起被 GC
1.2 Map的三种场景
一句话:读路径只用原子指令,写路径先 dirty,再定期把 dirty 整体搬进 read,实现 无锁读 + 批量写。
2. Load怎么读的?
无锁
直接
CAS读read.m[key],找到entry且p != nil && p != expunged→ 立即返回加锁
amended == true且read.m没找到 → 加锁再到dirty里找无论找没找到,
misses++,当misses == len(dirty)触发 dirty 晋升
dirty 晋升
将 dirty 整体提升为新的 read
(readOnly.m = dirty),重建 dirty 为 nil,并重置misses = 0晋升时会把所有
nil entry的p置为expunged,后续插入走dirty
一句话:读尽可能无锁,miss 累积到一定程度,把 dirty 整体搬到 read,摊销锁开销
3. Store怎么写的?
无锁
如果
key在read.m已存在且entry.p != expunged→CAS更新,全程无锁加锁
先加锁
如果
dirty为nil→ 先复制 read 中所有未删除的key到 dirty,再插入新值一次性复制可以把本来每次写都可能发生的复制成本,集中在一小段时间里一次性完成
只有当
dirty==nil且 需要写新key时才会触发,频率远低于每次写都复制
若
key在dirty已存在 → 更新entry.p若
key不存在 → 先检查key在read中是否被标记expunged,是的话重新放进dirty,再更新指针
一句话:写优先CAS更新旧值,无旧值则加锁走dirty,无dirty还得把read中有效都key刷到dirty
LoadOrStore
Load + Store 的组合,但把两个操作放在同一次锁临界区内,避免并发双写,保证原子性
4. Delete怎么删?
无锁
如果key在read.m,则直接CAS把entry.p置nil加锁
加锁后在dirty中delete(dirty, key)真正删除key何时清理
dirty晋升时不复制expunged key,read中所有nil key会置为expunged并在下一轮晋升被自然淘汰
5. Range的遍历过程
加锁 mu,然后
dirty晋升(保证一致性)遍历
readOnly.m,对每个非 nil 非 expunged entry 回调f(k, v)
Range 自身不会修改 map,但会触发强制晋升
6. 面试题速查
6.1 内存与 GC
6.1.1 entry 被 GC 扫描吗?
entry本身是指针,readOnly.m和dirty都是普通 map,key/value按指针规则扫描p为nil/expunged时,value目标的引用已清除,GC 不再追踪
6.1.2 sync.Map 会内存泄漏吗?
不会。晋升时把已删除 key 自然清理;无额外链表,生命周期随 Map 实例
6.2 并发与调度
6.2.1 为什么读能无锁?
read字段用atomic.Pointer,整个readOnly不可变,读操作只读map + entry,无数据竞争写操作通过新建 readOnly原子替换指针,实现 RCU(Read-Copy-Update)思想
6.2.2 写操作何时会阻塞?
仅当需要创建/重建 dirty 或执行晋升时会持 mu,其他并发写排队,短暂阻塞
6.3 性能陷阱
6.3.1 为什么 sync.Map 不适合写远大于读?
每次写大概率触发 dirty 重建 → 全表复制 + 全 map 扫描,O(n) 开销
高并发写会导致 dirty 频繁晋升,退化为 map + Mutex
6.3.2 Range 会阻塞写吗?
会持 mu 晋升 dirty,短暂阻塞所有写,但读仍可走旧 read
大量 Range 建议异步或合并,避免抖动
6.3.3 value存指针还是值?
与
channel类似,大于128 B建议存指针,减少晋升时复制成本value含锁或通道时,一律存指针,避免复制语义导致状态分裂
影响sync.Map性能的核心就是晋升,它会持有锁,并且产生数据迁移成本