原理
布隆过滤器,英文BloomFilter,是一个时间复杂度和空间复杂度很低,并用来检测元素是否存在的一种数据结构。它本质上是一个位图,把元素通过多次的hash计算出来的值当作索引,如果索引对应的位图的二进制位为0,说明该元素不存在,如果都为1,该元素可能存在。“可能存在"我们称为布隆过滤器的误判率,这是由于hash冲突导致的。从这可以看出来,布隆过滤器适用与对大数据集进行去重、检验是否存在等场景,但由于它的误判率,并不适用"零错误的场景”。下面我们来看看它的原理。
上图就是布隆过滤器的原理,中间就是bitmap,每一位都是二进制位。然后上面的5和7就是已经存在的元素,他们分别经过3次hash运算,将bitmap对应的位改为了1。下面的10是一个不存在的元素,但是由于hash冲突,导致它计算出来的3个位都为1,这就会让布隆过滤器发生误判。由此可以得知,误判率上升有以下几种因素:1.bitmap太小,导致频繁的hash冲突;2.本身存在的元素过多,导致bitmap大多数位都为1;3.hash函数散列较差。并且可以发现,布隆过滤器是不支持删除元素的,如果将5删除,那么下标为3的位就会变为0,这样也会把7进行"误删"。
简单实现
上面了解过布隆过滤器的原理后,那我们就自己来实现一个简单版的。我才用了long数组做为bitmap,在java中,long为64位,也就是说,当数组长度为10时,bitmap可以表示640位。
public class BloomFilter<T> {
//bitmap
private long bitmap[];
//bitmap的真实长度,数组长度 * 64
private int realLength;
public BloomFilter(int length){
this.bitmap = new long[length];
//左移6位相当于 *64
this.realLength = length << 6;
}
//向布隆过滤器添加key
public void add(T key){
//计算当前key在整个bitmap中(640位)的索引
int index1 = Math.abs(hash1(key) % this.realLength);
int index2 = Math.abs(hash2(key) % this.realLength);
int index3 = Math.abs(hash3(key) % this.realLength);
location(index1);
location(index2);
location(index3);
}
private void location(int index){
//这里计算出来的i,相当于在bitmap数组中的哪个桶位
//因为我们最终是要去桶位的long上进行修改,所以要先计算出桶位
int i = index >>> 6;
//这里就是计算出bitmap[i]的具体二进制位
int j = index & (64 - 1);
//将具体的那一位改为1
bitmap[i] |= 1L << j;
}
//判断布隆过滤器是否存在当前key
public boolean contains(T key){
int index1 = Math.abs(hash1(key) % this.realLength);
int index2 = Math.abs(hash2(key) % this.realLength);
int index3 = Math.abs(hash3(key) % this.realLength);
return hasKey(index1) && hasKey(index2) && hasKey(index3);
}
public boolean hasKey(int index){
//i和j的算法同上
int i = index >>> 6;
int j = index & (64 - 1);
long temp = bitmap[i];
//temp | 1L << j 会让其中的j位变为1
//如果下面的等式为true,说明原本j位就是1,否则说明当前key不存在
return temp == (temp | 1L << j);
}
//3个hash函数,可以自行实现散列能力更好的
//这里借鉴了HashMap的hash函数
private int hash1(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 4);
}
private int hash2(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 3);
}
private int hash3(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 16);
}
}
class Test{
public static void main(String[] args) {
BloomFilter<String> filter = new BloomFilter(10);
filter.add("abc");
filter.add("def");
filter.add("xyz");
System.out.println(filter.contains("abc")); //true
System.out.println(filter.contains("def")); //true
System.out.println(filter.contains("xyz")); //true
System.out.println(filter.contains("afwafwa")); //false
}
}
上面这个实现用了一些巧妙的位运算,大家可以自行计算一下,比较简单。使用long的64位来做为bitmap,可以节省很多的空间,事实上Google的guava库中实现的布隆过滤器也是这样做的,下面我们来分析一下它的源码。
guava的布隆过滤器使用就不说明了,大家自己去查阅相关资料。具体路径在com.google.common.hash的#BloomFilter,不过guava使用了下面这个类来封装具体的bitmap实现。
private final LockFreeBitArray bits;
我们简单分析以下核心方法
static final class LockFreeBitArray {
private static final int LONG_ADDRESSABLE_BITS = 6;
//使用原子类来保证并发安全,这个data相当于上面自实现的bitmap
final AtomicLongArray data;
//bitIndex就是整个bitmap的索引
boolean set(long bitIndex) {
//先判断是否有值,有就直接返回
if (get(bitIndex)) {
return false;
}
//计算data数组的下标
int longIndex = (int) (bitIndex >>> LONG_ADDRESSABLE_BITS);
//计算具体要修改的data[longIndex]的哪一位
long mask = 1L << bitIndex; // only cares about low 6 bits of bitIndex
long oldValue;
long newValue;
//自旋cas修改
do {
//获取data[longIndex]
oldValue = data.get(longIndex);
//与运算修改具体的那一位
newValue = oldValue | mask;
//相等,说明那一位已经是1了
if (oldValue == newValue) {
return false;
}
} while (!data.compareAndSet(longIndex, oldValue, newValue));
return true;
}
//判断bitIndex是否为1
boolean get(long bitIndex) {
return (data.get((int) (bitIndex >>> LONG_ADDRESSABLE_BITS)) & (1L << bitIndex)) != 0;
}
}
在本文中只是简单的分析了一下guava实现的布隆过滤器的数据结构,它的hash算法也是比较出色的,这一块感兴趣的就自己去搜下吧。
计数布隆过滤器
在分析了基础的布隆过滤器之后,我们知道原版是不支持删除的,那如果我们对每一位都进行计数,删除时也对每一位进行-1不就可以了吗?直接上代码。
public class CountingBloomFilter<T> {
private byte[] bitmap;
public CountingBloomFilter(int length){
this.bitmap = new byte[length];
}
public void add(T key){
int length = bitmap.length;
int index1 = Math.abs(hash1(key) % length);
int index2 = Math.abs(hash2(key) % length);
int index3 = Math.abs(hash3(key) % length);
location(index1);
location(index2);
location(index3);
}
private void location(int index){
this.bitmap[index]++;
}
public boolean contains(T key){
int length = bitmap.length;
int index1 = Math.abs(hash1(key) % length);
int index2 = Math.abs(hash2(key) % length);
int index3 = Math.abs(hash3(key) % length);
return hasKey(index1) && hasKey(index2) && hasKey(index3);
}
public boolean hasKey(int index){
return this.bitmap[index] > 0;
}
public void remove(T key){
int length = bitmap.length;
int index1 = Math.abs(hash1(key) % length);
int index2 = Math.abs(hash2(key) % length);
int index3 = Math.abs(hash3(key) % length);
this.bitmap[index1]--;
this.bitmap[index2]--;
this.bitmap[index3]--;
}
private int hash1(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 4);
}
private int hash2(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 8);
}
private int hash3(T key){
int hashCode = key.hashCode();
return hashCode ^ (hashCode >>> 16);
}
}
class Test{
public static void main(String[] args) {
CountingBloomFilter<String> filer = new CountingBloomFilter<>(5);
filer.add("abc");
filer.add("bcd");
filer.add("cde");
System.out.println(filer.contains("abc"));//true
System.out.println(filer.contains("bcd"));//true
System.out.println(filer.contains("cde"));//true
filer.remove("abc");
System.out.println(filer.contains("abc"));//false
System.out.println(filer.contains("bcd"));//true
System.out.println(filer.contains("cde"));//true
filer.remove("bcd");
System.out.println(filer.contains("abc"));//false
System.out.println(filer.contains("bcd"));//false
System.out.println(filer.contains("cde"));//true
filer.remove("cde");
System.out.println(filer.contains("abc"));//false
System.out.println(filer.contains("bcd"));//false
System.out.println(filer.contains("cde"));//false
}
}
这里采用了字节数组来做为bitmap,不过相比于原版的布隆过滤器,计数的方式缺点很明显,空间上使用1个字节表示原本的1位,然后就是误判率会增高。当然上面的代码也只是一个很简单的思路,并不能和生产环境的去比较。但在这我也想过,用byte实现计数的话,1个字节可以表示127的计数。如果用long实现,将long的低16位做为bitmap,高48位做为计数,每一个低位能分配3位,也就是低位能计数7个,那么相比于byte实现,long表示的bitmap位数是byte实现的16倍,但是计数缺比较低。这些也只是我个人的一个思考,真正的还是根据具体的业务场景选型。