1. Bitmap 原理概述
Bitmap 是基于位的数据结构,每一位代表一个元素是否存在。
对于一个范围在 0 到 N - 1 的整数集合,如果使用普通的布尔数组来表示每个整数是否存在,需要占用 N 个字节的内存空间(假设布尔值占用 1 个字节)。
而使用 Bitmap,只需要 N / 8 个字节(因为 1 个字节有 8 位),大大节省了内存。
比如要存储 0 到 999999 的整数,普通布尔数组需要 1MB 内存,而 Bitmap 只需要 125KB,节省了 87.5% 的内存空间。
2. Bitmap 去重算法实现
2.1 数据结构设计
用字节数组来实现 Bitmap,每个字节包含 8 位,可以表示 8 个数字:
public class BitmapDeduplication {
// 定义 Bitmap 的大小,这里假设处理的数据范围在 0 到 999999
private static final int BITMAP_SIZE = 1000000;
// 字节数组用于存储 Bitmap 数据
private byte[] bitmap = new byte[BITMAP_SIZE / 8];
2.2 判断元素是否存在 - contains
方法
public boolean contains(int value) {
// 计算元素对应的字节索引
int byteIndex = value / 8;
// 计算元素在字节中的位索引
int bitIndex = value % 8;
// 通过位运算检查该位是否被设置
return (bitmap[byteIndex] & (1 << bitIndex))!= 0;
}
这个方法的逻辑很简单:先算出数字在哪个字节(除以8),再算出在字节的哪一位(取余8),最后用位运算检查这一位是不是1。
用生活例子来理解:
把Bitmap想象成一排开关,每个开关代表一个数字:
- 开关亮着(1)= 数字存在
- 开关关着(0)= 数字不存在
具体步骤(以数字19为例):
-
找到开关位置:
19 ÷ 8 = 2 余 3
- 意思是:在第2组开关的第3个位置
-
制作检查工具:
1 << 3
制作一个"探测器"00001000
- 这个探测器只能检查第3个位置
-
检查开关状态:
开关组状态: 01011010 (这是bitmap[2]的当前值,表示8个数字的存在状态)
探测器: 00001000 (专门检查第3位的工具)
检查结果: 00001000 (不为0,说明第3个开关是亮的,数字19存在)
解释:开关组状态就是内存中实际存储的字节值,每一位代表一个数字是否存在。
简单理解:就像用手电筒照特定位置,如果那个位置有光(1),手电筒就能照到;如果没光(0),就照不到。
2.3 添加元素 - add
方法
public void add(int value) {
int byteIndex = value / 8;
int bitIndex = value % 8;
// 使用位运算设置相应的位为 1
bitmap[byteIndex] |= (1 << bitIndex);
}
添加元素就是把对应的位设置为1。先找到位置,然后用按位或运算把那一位变成1,其他位保持不变。
用生活例子来理解:
添加数字就是"打开开关":
-
找到开关位置:
- 数字19在第2组开关的第3个位置
-
制作开关工具:
1 << 3
制作一个"开关器"00001000
- 这个工具专门用来打开第3个位置的开关
-
打开开关:
原来状态: 01010010 (bitmap[2]的原始值,第3位是0,表示数字19不存在)
开关工具: 00001000 (专门打开第3个位置的掩码)
操作结果: 01011010 (第3个开关被打开了,数字19现在存在)
解释:原来状态是操作前bitmap中的实际数据,通过位运算修改后变成新的状态。
简单理解:就像按电灯开关,不管原来是开是关,按了之后肯定是开的。其他开关不受影响。
为什么这样设计?
- 一个字节8位,可以表示8个数字的存在状态
- 比用8个布尔变量节省7倍内存
- 位运算速度极快,比逐个检查快很多
2.4 测试代码 - main
方法
public static void main(String[] args) {
BitmapDeduplication deduplicator = new BitmapDeduplication();
int[] data = {1, 2, 3, 4, 2, 5, 1, 6};
for (int num : data) {
if (!deduplicator.contains(num)) {
deduplicator.add(num);
System.out.println("Unique element: " + num);
}
}
}
运行这段代码,输出结果是:1, 2, 3, 4, 5, 6。重复的数字被自动过滤掉了。
3. Bitmap 去重的应用场景
- 大规模数据处理:处理几千万条日志数据时,用 Bitmap 去重比传统 HashSet 快几倍,内存占用也少得多。
- 数据库优化:电商网站统计男女用户数量,用 Bitmap 索引比普通索引快10倍以上。
- 资源管理:操作系统用 Bitmap 管理磁盘块的使用状态,一个位代表一个磁盘块是否被占用。
4. 优化Bitmap去重算法性能的方法
4.1 内存管理优化
- 动态调整Bitmap大小
如果数据范围是 100-200,没必要为 0-999999 分配内存。可以根据实际数据范围来分配:
public BitmapDeduplication(int minValue, int maxValue) {
BITMAP_SIZE = maxValue - minValue + 1;
bitmap = new byte[BITMAP_SIZE / 8];
}
这样内存使用量从固定的 125KB 降到只需要几十字节。
- 内存对齐优化
CPU 访问对齐的内存更快。虽然 Java 的sun.misc.Unsafe
不推荐使用,但在性能要求极高的场景下可以考虑。
4.2 算法操作优化
- 批量操作优化
如果要添加 100-200 这个范围的所有数字,逐个添加需要循环 101 次。批量操作可以直接设置对应的字节:
public void addRange(int start, int end) {
int startByteIndex = start / 8;
int endByteIndex = end / 8;
int startBitIndex = start % 8;
int endBitIndex = end % 8;
if (startByteIndex == endByteIndex) {
// 在同一个字节内
byte mask = (byte) ((1 << (endBitIndex - startBitIndex + 1)) - 1 << startBitIndex);
bitmap[startByteIndex] |= mask;
} else {
// 跨越多个字节
byte startMask = (byte) ((1 << (8 - startBitIndex)) - 1 << startBitIndex);
bitmap[startByteIndex] |= startMask;
for (int i = startByteIndex + 1; i < endByteIndex; i++) {
bitmap[i] = (byte) 0xff;
}
byte endMask = (byte) ((1 << (endBitIndex + 1)) - 1);
bitmap[endByteIndex] |= endMask;
}
}
批量操作的性能提升非常明显,特别是处理连续数据时。
- 位运算优化
每次都计算1 << bitIndex
很浪费。预先算好 8 个掩码,直接查表:
private static final byte[] BIT_MASKS = new byte[8];
static {
for (int i = 0; i < 8; i++) {
BIT_MASKS[i] = (byte) (1 << i);
}
}
public boolean contains(int value) {
int byteIndex = value / 8;
int bitIndex = value % 8;
return (bitmap[byteIndex] & BIT_MASKS[bitIndex])!= 0;
}
这个小优化在高频调用时能提升 10-20% 的性能。
4.3 多线程优化
- 并发安全处理
多个线程同时修改同一个字节会出问题。加锁是最简单的解决方案:
public synchronized boolean contains(int value) {
//...
}
public synchronized void add(int value) {
//...
}
更高级的做法是用 AtomicIntegerArray
,性能比 synchronized 好一些。
- 并行处理
处理 1 亿条数据时,可以分成 8 份给 8 个线程处理,最后把 8 个 Bitmap 用或运算合并起来。