BloomFilter布隆过滤器

BloomFilter布隆过滤器

转载声明:

本文系转载自以下文章:

  • 如何判断一个元素在亿级数据中是否存在?
    作者:程序猿DD

转载仅为方便学习查看,一切权利属于原作者,本人只是做了整理和排版,如果带来不便请联系我删除。

0x01 摘要

最近有朋友问我这么一个面试题目:
现在有一个非常庞大的数据,假设全是 int 类型。现在我给你一个数,你需要告诉我它是否存在其中(尽量高效)。
需求其实很清晰,只是要判断一个数据是否存在即可。但这里有一个比较重要的前提:非常庞大的数据。

0x02 HashMap

先不考虑这个条件,我们脑海中出现的第一种方案是什么?我想大多数想到的都是用 HashMap 来存放数据,因为它的写入查询的效率都比较高。

写入和判断元素是否存在都有对应的 API,所以实现起来也比较简单。

为此我写了一个单测,利用 HashSet 来存数据(底层也是 HashMap );同时为了后面的对比将堆内存写死:

-Xms64m
-Xmx64m
-XX:+PrintHeapAtGC 
-XX:+HeapDumpOnOutOfMemoryError

为了方便调试加入了 GC 日志的打印,以及内存溢出后 Dump 内存。

@Test  
public void hashMapTest(){       
   long star = System.currentTimeMillis();       
   Set<Integer> hashset = new HashSet<>(100) ;       
   for (int i = 0; i < 10000000; i++) {           
   	hashset.add(i) ;      
   }       
   Assert.assertTrue(hashset.contains(1));       
   Assert.assertTrue(hashset.contains(2));       
   Assert.assertTrue(hashset.contains(3));       
   long end = System.currentTimeMillis();       
   System.out.println("执行时间:" + (end - star));   
}

写入 1000W 数据试试:
执行后马上就内存溢出。
BloomFilter布隆过滤器_第1张图片

可见在内存有限的情况下我们不能使用这种方式。

实际情况也是如此;既然要判断一个数据是否存在于集合中,考虑的算法的效率以及准确性肯定是要把数据全部 load 到内存中的。

0x03 Bloom Filter

3.1 引子

基于上面分析的条件,要实现这个需求最需要解决的是 如何将庞大的数据load到内存中。

而我们是否可以换种思路,因为只是需要判断数据是否存在,也不是需要把数据查询出来,所以完全没有必要将真正的数据存放进去。伟大的科学家们已经帮我们想到了这样的需求。BurtonHowardBloom 在 1970 年提出了一个叫做 BloomFilter(中文翻译:布隆过滤)的算法。它主要就是用于解决判断一个元素是否在一个集合中,但它的优势是只需要占用很小的内存空间以及有着高效的查询效率。所以在这个场景下在合适不过了。

3.2 Bloom Filter 原理

官方的说法是:它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。流程图如下:
BloomFilter布隆过滤器_第2张图片

如上图所示:

  1. 首先需要初始化一个二进制bit数组,长度设为 L(图中为 8),同时初始值全为 0 。
  2. 当写入一个A1=1000 的数据时,需要进行 H 次 hash 函数的运算(这里为 2 次);与 HashMap 有点类似,通过算出的 HashCode 与 L 取模后定位到 0、2 处,将这两处的值设为 1。
  3. A2=2000 也是同理计算后将 4、7 位置设为 1。
  4. 当有一个新的 B1=1000 需要判断是否存在时,也是做两次 Hash 运算,定位到 0、2 处,此时他们的值都为 1 ,所以认为 B1=1000 存在于集合中。
  5. 当有一个B2=3000 时,也是同理。第一次 Hash函数运算结果 定位到 index=4 时,数组中的值为 1,所以再进行第二次 Hash函数运算,结果定位到 index=5 的值为 0,所以认为 B2=3000 不存在于集合中。

整个的写入、查询的流程就是这样,汇总起来就是:

  1. 对写入的数据做 H 次 hash运算定位到数组中的位置,同时将数据改为1
  2. 当有数据查询时也是同样的方式定位到数组中。
  3. 一旦其中的有一位为 0 则认为数据肯定不存在于集合
  4. 如果全部hash结果位置都为1,则认为数据可能存在于集合中,也可能是假阳性,因为结果可能是因为别的数字放入后hash计算改恰好也改了那几个bit位为1

所以布隆过滤有以下几个特点:

  • 只要返回数据不存在,则肯定不存在。
  • 返回数据存在,但只能是大概率存在。
  • 同时不能清除其中的数据。

第一点应该都能理解,重点解释下 2、3 点。

  • 为什么返回存在的数据却是可能存在呢,这其实也和 HashMap 类似。在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。这时拿 B 进行查询时那自然就是误报了。

  • 删除数据也是同理,当我把 B 的数据删除时,其实也相当于是把 A 的数据删掉了,这样也会造成后续的误报。

基于以上的 Hash 冲突的前提,所以 BloomFilter 有一定的误报率,这个误报率和 Hash算法的次数 H,以及数组长度 L 都是有关的。

3.3 自己实现一个布隆过滤器

算法其实很简单不难理解,于是利用 Java 实现了一个简单的雏形:

public class BloomFilters {   
    /**
     * 数组长度
     */
    private int arraySize;   
    /**
     * 数组
     */
    private int[] array;   
    
    public BloomFilters(int arraySize) {
        this.arraySize = arraySize;
        array = new int[arraySize];
    }   
    
    /**
     * 写入数据
     * @param key
     */
    public void add(String key) {
        int first = hashcode_1(key);
        int second = hashcode_2(key);
        int third = hashcode_3(key);       
        array[first % arraySize] = 1;
        array[second % arraySize] = 1;
        array[third % arraySize] = 1;   
    }   
    
    /**
     * 判断数据是否存在
     * @param key
     * @return
     */
    public boolean check(String key) {
        int first = hashcode_1(key);
        int second = hashcode_2(key);
        int third = hashcode_3(key);       
        int firstIndex = array[first % arraySize];
        if (firstIndex == 0) {
            return false;
        }       int secondIndex = array[second % arraySize];
        if (secondIndex == 0) {
            return false;
        }       int thirdIndex = array[third % arraySize];
        if (thirdIndex == 0) {
            return false;
        }       return true;   
    }
    
    /**
     * hash 算法1
     * @param key
     * @return
     */
    private int hashcode_1(String key) {
        int hash = 0;
        int i;
        for (i = 0; i < key.length(); ++i) {
            hash = 33 * hash + key.charAt(i);
        }
        return Math.abs(hash);
    }  
     
    /**
     * hash 算法2
     * @param data
     * @return
     */
    private int hashcode_2(String data) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < data.length(); i++) {
            hash = (hash ^ data.charAt(i)) * p;
        }
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;
        return Math.abs(hash);
    }   

	/**
     *  hash 算法3
     * @param key
     * @return
     */
    private int hashcode_3(String key) {
        int hash, i;
        for (hash = 0, i = 0; i < key.length(); ++i) {
            hash += key.charAt(i);
            hash += (hash << 10);
            hash ^= (hash >> 6);
        }
        hash += (hash << 3);
        hash ^= (hash >> 11);
        hash += (hash << 15);
        return Math.abs(hash);
    }
}

实现逻辑其实就和上文描述的一样:

  1. 首先初始化了一个 int 数组。
  2. 写入数据的时候进行三次 hash 运算,同时把对应的位置置为 1。
  3. 查询时同样的三次 hash 运算,取到对应的值,一旦值为 0 ,则认为数据不存在。

3.4 测试

下面来测试一下,同样的参数:

-Xms64m
-Xmx64m
-XX:+PrintHeapAtGC 
-XX:+HeapDumpOnOutOfMemoryError

3.4.1 测试1

先测下1千万数据,测试代码如下:

@Test
public void bloomFilterTest(){
	long star = System.currentTimeMillis();
	BloomFilters bloomFilters = new BloomFilters(10000000) ;
	for (int i = 0; i < 10000000; i++) {
		bloomFilters.add(i + "") ;
	}
	Assert.assertTrue(bloomFilters.check(1+""));
	Assert.assertTrue(bloomFilters.check(2+""));
	Assert.assertTrue(bloomFilters.check(3+""));
	Assert.assertTrue(bloomFilters.check(999999+""));
	Assert.assertFalse(bloomFilters.check(400230340+""));
	long end = System.currentTimeMillis();
	System.out.println("执行时间:" + (end - star));
}

执行结果如下:
BloomFilter布隆过滤器_第3张图片

只花了 3 秒钟就写入了 1000W 的数据同时做出来准确的判断。

3.4.2 测试2

把数组长度缩小到了 100万,结果如下:
BloomFilter布隆过滤器_第4张图片

此时就出现了一个假阳性误报,即 400230340 这个数明明没在集合里,却返回了存在。这也体现了 BloomFilter 的误报率。

我们提高数组长度以及 hash 计算次数可以降低误报率,但相应的 CPU、内存的消耗就会提高;这就需要根据业务需要自行权衡。

0x04 Google guava BloomFilter 实现

4.1 例子

刚才的方式虽然实现了功能,也满足了大量数据。但其实观察 GC 日志非常频繁,同时老年代也使用了 90%,接近崩溃的边缘。

总的来说就是内存利用率做的不好。

其实 Google Guava 库中也实现了该算法,下面来看看业界权威的实现。

-Xms64m
-Xmx64m
-XX:+PrintHeapAtGC 
-XX:+HeapDumpOnOutOfMemoryError
public void guavaTest() {
    long star = System.currentTimeMillis();
    BloomFilter<Integer> filter = BloomFilter.create(
        Funnels.integerFunnel(),
        10000000,
        0.01);       
    for (int i = 0; i < 10000000; i++) {
    	filter.put(i);
	 }       
    Assert.assertTrue(filter.mightContain(1));
    Assert.assertTrue(filter.mightContain(2));
    Assert.assertTrue(filter.mightContain(3));
    Assert.assertFalse(filter.mightContain(10000000));
    long end = System.currentTimeMillis();
    System.out.println("执行时间:" + (end - star));
}

也是同样写入了 1000W 的数据,执行结果正确,具体如下:
BloomFilter布隆过滤器_第5张图片
观察 GC 日志会发现没有一次 fullGC,同时老年代的使用率很低。和刚才的一对比这里明显的要好上很多,也可以写入更多的数据。

4.2 源码分析

4.2.1 构建BloomFilter

BloomFilter布隆过滤器_第6张图片
构造方法中有两个比较重要的参数,一个是预计存放多少数据,一个是可以接受的误报率。 我这里的测试 demo 分别是 1000W 以及 0.01。

Guava版本的BloomFilter 会通过你预计的数量以及误报率帮你计算出你应当会使用的数组大小 numBits 以及需要计算几次 Hash 函数numHashFunctions 。

这个算法计算规则可以参考维基百科。

4.2.2 put

真正存放数据的 put 函数如下:

@Override
public <T> boolean put(
    T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
  long bitSize = bits.bitSize();
  // 根据 murmur3_128 方法的到一个 128 位长度的 byte数组
  byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
  // 分别取高低 8 位的到两个 hash 值。
  long hash1 = lowerEight(bytes);
  long hash2 = upperEight(bytes);

  boolean bitsChanged = false;
  long combinedHash = hash1;
  // 再根据初始化时得到的执行 hash 的次数numHashFunctions进行 hash 运算。
  for (int i = 0; i < numHashFunctions; i++) {
    // Make the combined hash positive and indexable
    // hash值取模后得到目标位置
    // 将该位置bit位置为1,然后与bitsChanged逻辑或
    bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
    combinedHash += hash2;
  }
  return bitsChanged;
}

下面看看重要的bits类和相关方法。

// 可以看到这是一个LockFree-锁无关的
// 一个 LockFree 的程序能够确保执行它的所有线程中至少有一个能够继续往下执行。
static final class LockFreeBitArray {
	private static final int LONG_ADDRESSABLE_BITS = 6;
	final AtomicLongArray data;
	private final LongAddable bitCount;
	
	LockFreeBitArray(long bits) {
	  this(new long[Ints.checkedCast(LongMath.divide(bits, 64, RoundingMode.CEILING))]);
	}
	
	// Used by serialization
	LockFreeBitArray(long[] data) {
	  checkArgument(data.length > 0, "data length is zero!");
	  this.data = new AtomicLongArray(data);
	  this.bitCount = LongAddables.create();
	  long bitCount = 0;
	  for (long value : data) {
	    bitCount += Long.bitCount(value);
	  }
	  this.bitCount.add(bitCount);
	}
	    
	// 当bit位成功改变值时返回true
	boolean set(long bitIndex) {
	  // 在 set 之前先通过 get() 判断这个数据是否存在于集合中
	  // 如果已经存在则直接返回告知客户端写入失败。
	  if (get(bitIndex)) {
	    return false;
	  }
	
	  int longIndex = (int) (bitIndex >>> LONG_ADDRESSABLE_BITS);
	  // 按bitIndex构建一个 00000100000类似的二进制数,只有一位为1
	  long mask = 1L << bitIndex; // only cares about low 6 bits of bitIndex
	
	  long oldValue;
	  long newValue;
	  do {
	    // 获取旧值
	    oldValue = data.get(longIndex);
	    // 用逻辑或的方式得到新值
	    newValue = oldValue | mask;
	    // 与旧值相等就返回了
	    if (oldValue == newValue) {
	      return false;
	    }
	    // 最后cas的方式设置新值,如果失败就重新上述过程
	  } while (!data.compareAndSet(longIndex, oldValue, newValue));
	
	  // We turned the bit on, so increment bitCount.
	  // 达到这里说明设置新值成功,将元素个数统计值加一
	  bitCount.increment();
	  return true;
	}
	
	boolean get(long bitIndex) {
	  return (data.get((int) (bitIndex >>> 6)) & (1L << bitIndex)) != 0;
	}
}

其实 set 方法是 LockFreeBitArray 中的一个方法, LockFreeBitArray 就是真正存放数据的底层数据结构。利用了一个 LongAddable bitCount来存放数据。

4.2.3 mightContain

该方法名字就体现出了BloomFilter的非100%精确性。

@Override
// 该方法和put方法差不多
public <T> boolean mightContain(
    T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) {
  long bitSize = bits.bitSize();
  byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
  long hash1 = lowerEight(bytes);
  long hash2 = upperEight(bytes);

  long combinedHash = hash1;
  for (int i = 0; i < numHashFunctions; i++) {
    // Make the combined hash positive and indexable
    // for循环,用迭代计算的combinedHash,求模的方式判断该为是否为0 
    // 为0 就返回false
    if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {
      return false;
    }
    combinedHash += hash2;
  }
  return true;
}

4.3 源码小结

这段话是我自己加的,不是转载来源文中所有:
guava 24.0版本的BloomFilter代码已经和转载的文章中的版本相差甚远,我认为应该是google的大神们为了进一步提高效率,用复杂的代码构建了极致的执行速度,看起来可读性较差,可能是因为我太菜了。。。

0x05 总结

布隆过滤的应用还是蛮多的,比如数据库、爬虫、防缓存击穿等。特别是需要精确知道某个数据不存在时做点什么事情就非常适合布隆过滤。

如果对你有帮助那就分享一下吧。

本问的示例代码参考这里:
https://github.com/crossoverJie/JCSprout

你可能感兴趣的:(数据结构)