1. 位图核心思想把“数据本身”丢掉只留“存在与否”很多朋友第一次接触位图Bitset时脑子里冒出来的问题是这玩意儿不就是个数组吗无非是把数组里的每个元素从int换成了boolean有什么好讲的如果你也这样想那就错过了位图真正值钱的地方。它不是在“省一个字节”而是把数据结构的整个存储维度都换了。1.1 从生活例子理解位图的“位置即数据”逻辑想象一下你开了一家电影院一共有 1000 个座位你想快速知道某个座位有没有人。最笨的办法是拿个本子每卖出一张票就写一行“3排7座张三”。查的时候翻本子翻半天。聪明一点的办法是拿一张座位图在 3排7座 那个格子上画个勾查的时候直接看那个格子就行了。位图就是这个“座位图”而且它比“画勾的格子纸”更极端——每个格子只占 1 个 bit也就是二进制的一位。0 表示“没人”1 表示“有人”。你不需要记录任何人的名字你只需要知道“有没有人”。大多数业务场景里我们想查的恰恰就是这个“有没有”。这里的核心思维转变是下标即数据bit 即状态。传统的数组或集合存的是“数据本身”比如HashSetInteger里存的是整数 42 这个具体的值。位图反过来它把整数 42 当成“第 42 个位置”然后在这个位置上放 0 或 1。你要判断 42 存不存在不需要遍历不需要哈希计算直接看第 42 个 bit 是 0 还是 1。这个逻辑听起来简单但实际工程里很多人栽就栽在没转过弯来。有位同事问我为什么我用HashMapInteger, Boolean也能实现同样的功能完全可以但你算一笔账就明白了一个HashMap里的每个键值对光 Java 对象的头就有十六字节再加上哈希表的桶、指针、扩容余量存 1 亿个数差不多要吃掉几个 GB 的内存。而位图存 1 亿个状态只要 12.5 MB。1.2 为什么位图能省这么多空间一次具体计算我习惯用具体数字来说话。假设你要判断 10 亿个不重复的整数32 位无符号中某个数是否存在。用HashSetInteger每个Integer在 64 位 JVM 上对象头 16 字节 4 字节 int 值 对齐填充至少 24 字节再加上HashMap节点约 32 字节和哈希表负载因子导致的空桶摊下来每个元素实际占用 50 字节以上。10 亿个就是 50 亿字节差不多 5 GB 内存。很多服务器根本扛不住。用位图我们需要的 bit 总数等于数值范围。如果是 32 位整数全部覆盖需要 2^32 个 bit也就是 2^32 / 8 512 MB。如果你只需要覆盖 10 亿以内的数那么 10 亿 bit 125 MB 左右。更极端一点很多业务场景里的 ID 是通过自增主键产生的一个亿级用户平台用户 ID 大概在几千万到几亿这个量级。此时用位图管理所有用户的状态内存开销是最大 ID 数目除以 8 字节。一亿用户12.5 MB。这个数字小到可以在服务端随便开好几份。对比一下任何基于对象的容器在这个量级下都不可能做到这个体积。这里还有一层“为什么”值得展开哈希表存数据要同时存“键”和“值”而且哈希冲突时要额外存储指针维持链表或红黑树结构堆上的对象还有对齐填充。位图的结构本质上是一个连续内存数组没有任何元信息开销它就是“裸”的内存块。所以它不是“比哈希表优化了一点点”而是把常数因子从几十降到了 1。1.3 位操作的温度set、get、clear 背后的位运算理解了“位置即数据”下一步就得能动手操作。位图底层通常是个long[]数组Java 里或std::bitsetC 里一次操作一个 64 位长的字。对一个整数n你想把它所在的位置置为 1核心代码其实就是三行int wordIndex n 6; // n / 64找到落在哪个 long 字里 int bitIndex n 63; // n % 64找到在这个字里的第几位 words[wordIndex] | (1L bitIndex); // 把这一位置 1判断是否存在更简单return (words[n 6] (1L (n 63))) ! 0;很多初学者会对n 6和n 63感到别扭其实它们就是整除和取模的位运算写法因为 64 是 2 的幂编译器会把除以 64 优化成右移 6 位把模 64 优化成与 63。这个套路在你手工实现位图时会反复出现务必背下来。说到这我想起一个真实的线上事故。某个服务用位图存用户的“已读状态”结果测试环境一切正常一上生产就数组越界。查了半天发现测试数据里的用户 ID 都是从 1 开始连续递增而生产环境接入了某个老系统的 ID直接从 5 亿开始。位图的数组长度取决于你创建时给定的 range一旦访问超出 range 的位置就会越界。这不是位图本身的问题是你设计时没考虑清楚范围上界的问题。2. 三个最能体现位图价值的高频场景位图不是什么万金油它是“存在性判断 大数据集合操作”这个窄赛道里的王者。我整理了一下下面三个场景是我在真实项目和面试题里见的最多的也是你学了立刻能用的。2.1 场景一亿级数据的存在性查询与去重先看一个经典面试题给 40 亿个不重复的无符号整数没排过序。现在给你一个数如何快速判断它是否在这 40 亿个数中。限制条件很苛刻内存只有 1 GB 左右。很多人第一反应是排序 二分但 40 亿个 int 排序后的数组也占 16 GB内存直接爆掉。换成位图2^32 个 bit 就是 512 MB完全装得下。把 40 亿个数全部写入位图然后对目标数做一次 O(1) 的查询。整个流程的时间复杂度建图 O(n)查询 O(1)。这是我个人非常喜欢的一道题因为它直白地展示了位图“用空间换时间”的另一种形态——这里的“空间”反而是被压缩的。哈希表在同样场景下连数据都装不下位图却轻松搞定。你把这道题吃透了以后遇到任何“海量数据 快速判断是否存在”的需求第一反应就应该是位图。实际业务中这个场景最常见的形态是“白名单/黑名单”。比如你要判断 IP 是否命中某个恶意库或者判断某个手机号是否是注册用户。这类判断请求量很大用数据库查询扛不住用哈希集合内存又太大位图就是那个“既能扛住高并发、又不会把服务器内存打爆”的方案。2.2 场景二统计活跃度与集合运算位图还有一个很容易被忽略的强项集合运算。因为它本质上就是一堆 bit所以“交集”“并集”“差集”直接对应位运算AND、OR、AND NOT。一台 64 位的 CPU 一次就能处理 64 个元素的集合运算1 亿个元素的集合求交集循环一百多万次而已耗时在毫秒级。举个例子运营要查“过去 7 天里既看过 A 视频、又点过 B 视频的活跃用户有哪些”。如果每个用户的活跃记录都放在数据库里这个查询要 JOIN 两张巨大的表跑一次可能要几十秒。但如果每天维护一个“当日活跃用户位图”那这个需求就是一个AND操作把 7 张位图按日期 AND 一下扫一遍就出结果。你可能会问这难道不是把数据库的活搬到内存里做对就是因为数据库的 JOIN 代价太高位图才值得被拿出来做这样的事。Redis 里的SETBIT/BITOP命令可以干完全相同的事很多大数据平台也内置了位图索引来加速多维组合查询。只要你把“日期”和“用户 ID”这两个维度对齐位图的集合运算就是开挂级的表现。另一个常见用法是“用户留存率”。某天新增用户做一张位图第二天的活跃用户做一张位图两者AND之后再统计 1 的个数就是次日留存。统计 1 的个数在 Java 里就是cardinality()在 C 里可以用std::bitset::count()底层通常都用了 CPU 指令级优化速度极快。2.3 场景三位图与布隆过滤器的关系与边界提到位图几乎一定会有人提到布隆过滤器Bloom Filter。这里要理清一个关系布隆过滤器是一个“基于位图”的扩展结构它解决的是位图的一个天然软肋——当数据范围很大但实际元素很少时位图空间浪费严重以及当元素本身不是可用作下标的整数时比如字符串 URL没法直接映射。布隆过滤器的做法是用 k 个哈希函数把同一个元素映射到位图的 k 个不同位置全部置 1。查询时再看这 k 个位置是否全部为 1如果是判断“可能存在”只要有一个为 0就判断“一定不存在”。代价是它有一定的误判率false positive但绝不会漏判。这里的关键选型建议是如果数据本身就是紧凑的整数 ID直接用位图精确、无误差如果数据是字符串、URL、任意字节串或者数值范围巨大但元素稀疏那就用布隆过滤器。很多新手搞反了拿布隆过滤器去处理整数 ID纯属多绕了一圈还引入误判。记住这句话布隆过滤器解决的是“不可索引”和“稀疏大范围”的问题它不是位图的替代品而是位图思想的延伸。3. 落地实操Java BitSet 内存 API 演示与内存对比前面讲了一堆理念这章我直接带你跑一遍代码。我以 Java 为例因为它的BitSet类使用最广泛而且很多读者学这个是为了应对后端开发或算法面试。C 的std::bitset以及 Python 的int位运算和bitarray库后面会单独提一下行为差异。3.1 Java BitSet 核心 API 速览Java 的java.util.BitSet用起来非常直观。我最常用的方法有这么几个set(int index)把第 index 位置 1set(int fromIndex, int toIndex)把 [fromIndex, toIndex) 区间全部置 1适合批量处理连续段get(int index)查询第 index 位是否为 1clear(int index)把第 index 位置 0cardinality()返回置 1 的位数也就是集合大小nextSetBit(int fromIndex)从 fromIndex 开始找下一个 1返回下标找不到返回 -1and(BitSet set)、or(BitSet set)、xor(BitSet set)集合运算。有一个细节容易踩坑BitSet的size()返回的是底层long[]数组的长度乘以 64表示“底层空间能容纳多少位”而length()返回的是最高置 1 位的下标加 1。比如你new BitSet(1000)但只 set 了第 10 位那size()可能是 1024因为内部会按 64 对齐分配字而length()是 11。查“位图占了多少内存”时应该看size()而不是length()。另外遍历位图时千万别写for (int i 0; i bitSet.size(); i) { if (bitSet.get(i)) ... }这种循环。如果位图覆盖范围是几十亿位你的查询又只分布在几个点上这种遍历会白白跑几十亿次get()。正确做法是用nextSetBitfor (int i bitSet.nextSetBit(0); i 0; i bitSet.nextSetBit(i 1)) { // 只处理置 1 的位置 }这个差别在小数据量下看不出来到亿级数据就是毫秒和秒级的差距。我在压测里实测过同样遍历 1 亿个 bitget()循环大约耗时 400 毫秒nextSetBit如果只有几万个 1耗时不到 1 毫秒。差距三百倍以上。3.2 完整示例用位图管理一亿用户的注册状态这里我写一个完整的小案例。假设我们有个电商平台用户 ID 最多到 999999991 亿以内需要快速判断任意用户是否已注册。public class UserRegistry { // 最大用户 ID 1创建位图时明确给定范围避免扩容开销 private static final int MAX_USER_ID 100_000_000; private final BitSet registered new BitSet(MAX_USER_ID); public void register(int userId) { checkRange(userId); registered.set(userId); } public boolean isRegistered(int userId) { checkRange(userId); return registered.get(userId); } public int totalRegistered() { return registered.cardinality(); } private void checkRange(int userId) { if (userId 0 || userId MAX_USER_ID) { throw new IllegalArgumentException(userId out of range: userId); } } public static void main(String[] args) { UserRegistry registry new UserRegistry(); registry.register(42); registry.register(88888888); System.out.println(registry.isRegistered(42)); // true System.out.println(registry.isRegistered(43)); // false System.out.println(registry.totalRegistered()); // 2 } }注意我在创建BitSet时就指定了容量因为BitSet默认初始大小是 64 位每次set()超过当前容量会自动扩容扩容涉及整个底层数组的复制。如果你明确知道上界一次性给足容量后面的set()就不会有扩容的复制开销。这一点在数据量大时特别明显我测过连续插入 5000 万条时提前指定容量能省掉大约 20% 到 30% 的时间。运行这个程序后内存占用大可以简单估算1 亿个 bit 约 12.5 MB。作为对比用HashSetInteger存 5000 万注册用户实测大概要吃 2 GB 以上。我把两种方案放在一起对比方案存储 5000 万用户查询一条耗时约内存开销HashSetInteger完整存储GC 压力大O(1)但哈希碰撞严重时退化2 GB 以上BitSet12.5 MB1 亿位O(1)纯位运算12.5 MB数据库索引磁盘存储毫秒级需要网络往返取决于 DB 配置这张表每次讲给团队听大家都会重新思考一下“用户状态到底存哪里”。不是说什么都用位图而是这类“存在性判断”的业务位图往往是最优解你就别再扛着HashSet硬上了。3.3 手写一个最简位图看底层那几行代码读源码不如自己写一遍。我用long[]手写一个极简版位图去掉所有花哨的 API只保留核心逻辑帮你看清楚位图到底是怎么运转的public class SimpleBitSet { private final long[] words; public SimpleBitSet(int capacity) { // 每个 long 有 64 位向上取整的除法 this.words new long[(capacity 63) 6]; } public void set(int index) { words[index 6] | (1L (index 63)); } public boolean get(int index) { return (words[index 6] (1L (index 63))) ! 0; } public void clear(int index) { words[index 6] ~(1L (index 63)); } public int cardinality() { int count 0; for (long word : words) { count Long.bitCount(word); } return count; } }Long.bitCount()底层会用到 CPU 的POPCNT指令一次能数完 64 位里有几个 1比一位一位遍历快太多了。手写这个类的最大意义是你能真切感受到位图不是魔法它就是数组加位运算。理解了这三行以后看任何语言的 bitset 实现你都不会发怵。4. 位图上生产后容易踩的坑以及改良方向前面说的都是位图怎么用、怎么省内存但这东西真要上生产环境坑也不少。我踩过的或是在代码评审里见过的挑几个最有代表性的说说。4.1 稀疏数据位图最大的敌人位图的致命弱点是空间由“值域范围”决定而不是由“元素个数”决定。如果你要标记的数字是从 0 到 10 亿的范围内随机分布的 100 个点位图照样需要 125 MB。而如果换成HashSet存 100 个整数只要几 KB。这种场景下位图就是灾难。我在一个推荐系统项目里见过真实案例团队用位图存“用户已读的 feed 消息 ID”结果消息 ID 是全局递增的雪花号量级到 2^63。为了覆盖所有可能的 ID他们创建了一个超大位图内存直接打爆。后来换成了采用分段处理低 32 位 高 32 位分桶的改进方案才解决。如果你遇到类似的复杂场景业界有成熟方案Roaring Bitmap。它的核心思路是把 32 位整数拆成高 16 位和低 16 位按高 16 位分桶每个桶内根据元素密度选择容器类型稀疏时用short数组稠密时用位图。它有一个内部策略通常是桶内元素数超过 4096 就切换成位图否则保留数组。这样无论数据是密集还是稀疏都能控制在相对较小的内存里。Java 里可以直接用org.roaringbitmap.RoaringBitmap性能比java.util.BitSet在某些场景下还要好因为它做了剪枝和压缩。4.2 并发安全与扩容问题java.util.BitSet不是线程安全的底层long[]的读写和修改操作没有加锁。多线程同时set()同一个位图轻则覆盖丢数据重则数组越界扩容过程中另一个线程读。我建议的解法有两种读取为主、写入集中的场景用一个synchronized包一层或者用ReentrantReadWriteLock因为位图的读操作不会修改结构可并发读写多读少的高压场景优先考虑用无锁实现或直接让每个线程持有一份独立位图最后再or()合并。最后合并的操作天然就是并行的这种设计反而更干净。还有一个很多人不知道的问题位图的序列化。如果你要把位图存到 Redis 或传给下游服务注意不要直接 JDK 序列化BitSet对象那会带上类描述信息白白多占几倍体积。正确做法是toLongArray()拿到long[]对long[]做压缩或者直接按字节流传输读出时再用valueOf(long[])还原。我见过因为序列化方式不对12 MB 的位图存到 Redis 里变成 50 MB 的案例。4.3 生产环境改良Roaring Bitmap 与其他选择上面提到了 Roaring Bitmap我再说几个值得关注的使用经验。首先Roaring Bitmap 最适合的领域是“索引”和“标签体系”。比如你在做用户画像每个用户身上挂了若干个标签每个标签单独一张 RoaringBitmap 存用户 ID标签之间的组合筛选就是多个 bitmap 的and/or运算。这个模式在很多公司的大数据架构里都有落地比传统的倒排索引省内存且更快。其次不同语言里的 bitset 行为差异很大。C 的std::bitsetN必须在编译期确定大小运行时动态大小的可以用boost::dynamic_bitset或std::vectorbool。但std::vectorbool是个出了名的坑货因为它内部做了位压缩导致bool引用返回的不是真正的引用而是代理对象模板泛型代码里很容易踩雷。Java 的BitSet没有boolean数组这种问题API 也更顺手。Python 里如果你不想装第三方库直接用整数类型做位运算也能模拟位图——Python 的 int 是无限位宽的左移一位等于 set 一个位置但也意味着单次操作的常数因子很大数据量大了不划算。如果你是做 Go 的标准库没有内置位图一般用github.com/willf/bitset或者github.com/RoaringBitmap/roaring。Go 的uint64切片实现位图思路和 Java 完全一样只是 API 风格不同核心思想完全通用。我想强调一个选型原则如果你的数据是紧凑的整数 ID且值域范围已知、密集程度够高直接用最朴素的 bitset如果值域巨大且数据稀疏用 RoaringBitmap如果数据是非整数类型再考虑布隆过滤器。千万别一上来就上最复杂的方案很多时候简单位图 一个long[]就够用了。说到底位图是个“思路”而非“库”。我见过一个老项目没有引入任何位图工具库就在业务代码里用byte[]加几行位运算照样把亿级用户状态的判断优化到了极致。位图的美妙之处就在于一旦你接受了“下标即数据”这个视角你在任何语言、任何场景下都会自然地想到它。希望大家读完这篇不只是记住 API而是把这种“用空间形态换查询速度”的思维带进日常的架构设计里。下次再遇到大数据集合判断的需求先别急着上数据库或 Redis花五分钟算一下位图的内存和耗时说不定你会得到远超预期的结果。