应使用[]uint64配合位运算手写位图,因其内存紧凑(125kb vs 1mb)、缓存友好、支持原子批量操作,且可规避越界、伪共享与批量操作缺失三类问题。

直接用 []uint64 + 位运算手写位图,别封装成泛型或依赖第三方默认实现;核心不是“有没有位图”,而是避开越界、伪共享、批量操作缺失这三类坑。
为什么不用 []bool 或 go-stl/bitset 默认配置
Go 的 []bool 每个元素占 1 字节,100 万个 bool 就是 1MB;[]uint64 存同样数量只需约 125KB。这不是省内存的问题——make([]bool, 1e7) 分配慢、GC 扫描压力大、CPU 缓存行(64 字节)只能塞下 8 个 bool,但能塞下 512 个 bit,局部性差一个数量级。实测在布隆过滤器、权限位等场景中,延迟高 3–5 倍。
第三方库如 go-stl/bitset 默认带自动扩容和边界检查,但高频写入时会频繁 realloc 和 panic 捕获,反而拖慢吞吐。真正压测下来,手写裸 []uint64 的 Set/Get 吞吐比封装版高 2–3 倍。
-
[]bool无法原子批量操作(比如一次置 64 个 bit),而uint64天然支持整字长 atomic 操作 - go-stl/bitset 的
Set内部用int索引,在 32 位环境或超2^31的位图中会溢出 - 其
ToString()等调试方法会遍历全部字,对稀疏位图(比如只设了 offset=1 和 1000000)造成严重浪费
Set 和 Get 必须带模运算和越界检查
常见错误是写成 bits[i/64] |= 1 或 <code>(bits[i/64] >> i) & 1,这两种写法在 i >= 64 时行为未定义:1 在 Go 中结果为 0,判断永远失败;右移带符号扩展可能误判。
正确写法必须拆两步:先算 word 索引和位偏移,再校验边界:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
func (b *Bitmap) Set(i uint64) {
wordIdx := i / 64
bitIdx := i % 64
if uint64(len(b.bits))
- 索引变量统一用
uint64,禁用int—— 用户 ID、时间戳 offset 等都可能超2^31 -
expandTo应预分配(如make([]uint64, wordIdx+1)),避免 append 触发多次 copy - Get 对越界返回
false是合理设计,但必须文档化,否则调用方可能混淆“未设置”和“不存在”
并发写不同 bit 却落在同一 uint64 上?那是伪共享
Go 的 atomic 包不支持单 bit 原子操作,只能对整个 uint64 做 LoadUint64/CAS。如果两个 goroutine 同时写第 3 位和第 5 位,而它们属于同一个 b.bits[0],CPU 缓存行会被反复无效化,性能骤降。
解决方案不是全局锁,而是分段(sharding):
- 把大位图拆成多个桶,例如每桶 64K bit(即 8KB 的
[]uint64) - 桶索引由
i / bitsPerShard算出,确保Get(i)和Set(i)落在同一桶 - 每个桶配独立
sync.Mutex或轻量atomic.Uint64标记状态,避免热点集中 - 别用哈希分桶——get/set 必须确定性落桶,否则逻辑错乱
批量操作才是性能分水岭
高频场景(如 ID 池、连接句柄管理)真正卡点从来不是单 bit 设置,而是找下一个空闲位。朴素循环扫描 8 个 bit 是 O(8),而预计算 m2id[256] 查表可降到 O(1)。
例如找最低空闲位:
var m2id = [256]byte{
0, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0,
// ... 全部 256 个字节的最低置 0 位索引
}
func (b *Bitmap) NextClear(i uint64) uint64 {
wordIdx := i / 64
bitIdx := i % 64
for wordIdx = bitIdx {
return wordIdx*64 + uint64(idx)
}
}
wordIdx++
bitIdx = 0
}
return ^uint64(0) // not found
}
- 查表数组
m2id静态初始化,零分配、零 GC - 清空连续区间不要循环调用
Clear,先算起始/结束 word 索引,中间整块用0赋值,首尾残块用掩码逐 bit 清 - 掩码构造别反了:清 bit 3~7,掩码是
0x78(二进制000001111000),不是0x1E
真正难的不是怎么写 Set/Get,而是怎么让批量查找、并发分片、内存对齐这三件事同时成立——稍有不慎,性能就从线性退化到抖动。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










