位图用[]uint64实现去重适用于上亿非负整数且内存敏感、高并发场景,比map[uint64]struct{}节省内存;需分桶加锁或atomic.or64免锁设位,但不支持负数、动态范围及高效遍历还原。

Go 语言里用 []uint64 实现位图去重,不是为了炫技,而是当你要处理上亿个非负整数(比如用户 ID、日志序号、爬虫 URL 的哈希值),且内存敏感、并发写入频繁时,map[uint64]struct{} 会吃掉几 GB 内存,而位图可能只占 100MB —— 这才是它该出场的时刻。
为什么不能直接用 map[uint64]bool 或 sync.Map
原生 map 并发写必 panic:fatal error: concurrent map writes,哪怕只是两个 goroutine 同时设 seen[123] = true 和 seen[456] = true,只要底层哈希桶冲突或触发扩容,就崩。这不是概率问题,是确定性崩溃。
sync.Map 不适合位图场景:它为「key 类型不确定 + 读多写少」设计,内部是分段哈希+读写锁,对密集整数索引(0, 1, 2…)毫无优势;更关键的是,它不支持原子位操作,没法高效 set/clear 单个 bit。
用 sync.RWMutex 包整个 map?可以,但吞吐卡在单锁上——所有写操作排队,一写一等,压根没发挥多核能力。
[]uint64 分块 + 独立锁的实操要点
核心是把大位图切分成多个“桶”,每个桶管 64 个连续 bit,用一个 uint64 存;桶之间互不干扰,写不同桶完全不用争锁。
- 桶索引计算:bit 位置
n对应桶下标n >> 6(即n / 64),桶内偏移n & 63(即n % 64) - 设置位:
bits[idx] |= (1 ,记得先加锁再改对应桶 - 桶数组长度预估:若最大 ID 是 1e9,需
(1e9 + 63) / 64 ≈ 15.6M个uint64,约 125MB - 锁数组大小建议:和桶数一致(最简单),或取其平方根(如 1024 把锁管 1M 桶),避免锁太多开销,又防竞争过热
用 atomic.Or64 免锁的前提与限制
Go 1.19+ 支持 atomic.Or64,如果业务只要「设位」不关心返回值(比如日志去重、事件标记),可直接免锁:
atomic.Or64(&bits[n>>6], 1
但注意这招只适用于纯 set 场景:
- 查是否存在(
Get())仍需atomic.LoadUint64(&bits[idx]) & mask,没问题 - 需要 CAS(“若未存在则设”)、清零某 bit、统计已设位数?必须回退到带锁分块方案
- 低于 Go 1.19 的环境,
atomic.Or64不存在,手写汇编成本高,不如老实用 mutex
容易被忽略的边界与陷阱
位图不是万能胶布——它只对非负整数有效,且范围要预先知道。如果数据里混着负数、字符串、结构体,得先做映射(比如 hash(string) % MAX),但哈希碰撞后就得二次校验,反而增加复杂度。
初始化桶数组时别用 make([]uint64, cap) 就完事:如果最大 ID 是 1e9,桶数算出来是 15.6M,但你只 make 了 15M,访问第 15.1M 个桶时 panic index out of range。
还有个隐形坑:位图本身不存原始值,只存“存在性”。如果后续需要还原出所有去重后的 ID,得遍历整个 []uint64 扫描每个 bit,O(N) 时间 —— 别指望它像 map 那样能直接 range 出 key。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











