快速搞懂 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
可能同时存在,但值指针指向同一个entry
amended
的一些细节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性能的核心就是晋升,它会持有锁,并且产生数据迁移成本