单个java bitset无法覆盖40亿id空间,因其set(int)参数为有符号int,最大索引仅2147483647;需用双bitset分段(lower/upper)支持完整32位无符号id范围,内存512mb,精确过滤无误差。

BitSet 能做到亿级 ID 黑名单毫秒级过滤,但必须绕过 BitSet.set(int) 的 Integer.MAX_VALUE 索引限制,否则直接崩溃或漏判。
为什么单个 Java BitSet 无法覆盖 40 亿 ID 空间
Java 的 BitSet.set(int) 接收的是有符号 int,最大索引只能到 2147483647(≈21.5 亿)。而 32 位无符号整数(如 QQ 号、用户 UID)范围是 0~4294967295(≈42.9 亿),超限部分根本无法写入。直接调用 bitSet.set(3000000000) 会抛出 IndexOutOfBoundsException;若强行转成负数,则写入位置错乱,导致误判。
- 不要尝试用
long强转后传给set()—— 编译不报错但运行时行为未定义 - 不要用
BitSet.valueOf(long[])手动构造 —— 它按 long 数组元素顺序映射,不是按位地址映射,语义完全不符 - Redis 的
SETBIT没这问题,因为其底层用字符串偏移,支持 up to 2³²−1,但 Java 标准库不提供等价能力
双 BitSet 分段方案:安全覆盖完整 32 位地址空间
把 0~4294967295 拆成两个连续区间,各自用一个 BitSet 管理:
-
lower:索引 0 ~Integer.MAX_VALUE(0~2147483647),对应低位段 -
upper:索引 0 ~2147483647,但逻辑上表示 2147483648~4294967295,即对原始 ID 减去Integer.MAX_VALUE + 1L后再 set
封装成工具类后,使用保持简洁:
public class UInt32BitSet {
private final BitSet lower = new BitSet();
private final BitSet upper = new BitSet();
private static final long OFFSET = (long) Integer.MAX_VALUE + 1L;
<pre class="brush:java;toolbar:false;">public void set(long id) {
if (id <= Integer.MAX_VALUE) {
lower.set((int) id);
} else {
upper.set((int) (id - OFFSET));
}
}
public boolean get(long id) {
if (id <= Integer.MAX_VALUE) {
return lower.get((int) id);
} else {
return upper.get((int) (id - OFFSET));
}
}}
这个方案内存仍是 512MB(2³² bits),且无任何精度损失 —— 是精确黑名单,不是布隆过滤器。
AND/OR 运算必须手动分段,不能直接调用 BitSet.and()
两个 UInt32BitSet 实例之间做“取交集”(如用户标签 AND 黑名单),不能直接调用 BitSet.and(),因为那是针对单个实例的原生方法。你得分别对 lower 和 upper 做 and/or/xor:
- 黑名单 A 和 B 合并(OR):
a.lower.or(b.lower)+a.upper.or(b.upper) - 用户集合与黑名单求交(AND):先用
userBitSet.get(id)判断是否在黑名单中,再批量过滤;若需生成新 BitSet,也得分段操作 - 性能关键点:所有
and/or都是 native 级别位运算,百万次操作在纳秒级完成,但必须确保两段长度一致、对齐正确
线上部署时最容易被忽略的三个细节
内存看似固定,但实际受 JVM 参数和 GC 行为影响明显:
-
BitSet内部用long[]存储,每个long存 64 位;申请 2³² 位 ≈ 需要 67,108,864 个long,即约 512MB 堆内存 —— 必须预留足够 Metaspace 和 Direct Memory,否则 Full GC 频繁 - 避免在高频请求路径中反复创建
UInt32BitSet实例;应复用静态单例或对象池,否则 minor GC 压力陡增 - 如果黑名单更新不频繁(如每天凌晨加载一次),务必用
BitSet.flip()或 mmap 文件映射实现只读加载,减少堆内拷贝开销











