前言
布隆過濾器的作用是判斷一個元素是否存在於一個集合中。
比如有一個集合存儲了全國所有人的身份證號碼,那么該集合大小有十幾億的大小,此時如果判斷一個身份證是否存在於該集合中,最簡單也是最笨的辦法就是遍歷集合,挨個判斷是否和校驗的身份證號碼相同來判斷。而布隆過濾器就是通過一個提高空間和時間效率的一種算法,來快速判斷一個元素是否存在於集合中。
另外還有一個問題,如果采用遍歷的方式,還有一個比較大的問題就是內存的問題,假設現有一個場景,有10億個整數,需要判斷一個整數是否存在於這個整數集合中。那么首先需要創建一個int類型的數組,int類型占用4個字節也就是32位,10億個int類型占用的空間大小就是 4*1000000000/1024/1024/1024 = 3.72G,可以算出10億個整數存在內存中至少就需要占用3.72個G的內存,如果是20億,就是7個G的內存,很容易就會造成內存溢出了,所以大數據的情況下,通過遍歷的方式顯然是不行的。此時就可以通過bitmap來實現。
一、bitmap
bitmap也叫做位圖,是一種數據結構。可以理解為是一個很長的數組,每一個數組元素是一個二進制值,非0即1,也就是說bitmap本質上是一個數組,每個數組元素是一個位的值。
如果通過bitmap來存在10億個int類型,bitmap的大小為1000000000位/8/1024/1024 = 0.12G,可以發現通過為位圖來表示10億個整數值僅僅只需要120M大小的空間就可以表示,占用的內存大小大大減少。那么接下來就了解下bitmap的結構
1.1、bitmap的結構
int類型占用4個字節,1個字節占8位,也就是一個int類型占用32位,而bitmap是一位表示一個數字,所以可以容納的數據是int類型的32倍。因為判斷是否存在於一個集合中,只需要得到一個結果:在還是不在,那么就可以通過0和1來表示在還是不在。
如下圖:

上圖中是一個int類型值在內存中的存儲結構,一共占用了32位,每一位都對應了一個數字,對應的位置如果值為1,那么表示對應位置的值是存在的。如上圖中可以分表表示 2、9、12、27、31的值是存在的,而這整個32位對應一個int類型的數字。相當於一個int類型的值可以表示32個數字是否存在。
所以可以用一個int類型的數組來表示一個bitmap,每個int值可以代表32個bit值。
1.2、bitmap保存數據
將數字100保存到bitmap中,那么首先需要知道100需要存在int數組的那個位置,通過將 100/32=3可得到結果,則100位於int數字的第3個int值上
知道了數組的那一位之后,接下來就需要設置當前數組位置上對應位的值。通過100%32=4可知位於int值的第4位,那么此時可以通過或運算進行設置 將當前位置的int值和2的4次方的值進行或運算設置結果
偽代碼如下:
1 /** int數組表示bitmap */ 2 static int[] bitmap = new int[]; 3 4 /** 5 * @param value:需要保存的值 6 * */ 7 public static void putValue(int value){ 8 /** 數組的每個int值可以保存32個數字, 通過除以32得到位於那個數組的位置 */ 9 int index = value/32; 10 /** 計算偏移位置,通過對32取余數得到位於int數字的具體位置 */ 11 int offset = value % 32; 12 /** 修改數組index位置的值,將當前的值和2的offset次方進行或運算*/ 13 bitmap[index] = bitmap[index] | (1<<offset); 14 }
主要分成三步,第一步是計算數組的下標值,第二步是計算數組對應位置數字的第幾位表示當前值,第三步是通過或運算修改當前數組位置上的值
如現在需要判斷10000個數字的bitmap,那么就需要創建10000/32長度的int數組。
第一次向bitmap中插入數字35,那么步驟如下:
1、計算index值,35/32=1;那么對應的數值為bitmap[1]的數值,此時bitmap[1] = 0
2、計算偏移量,35%32=3;那么表示需要在bitmap[1]的數值的第3位設置為1,此時通過將當前的值和2的3次方進行或運算,如下:
0000 0000 0000 0000 0000 0000 0000 0000 | 0000 0000 0000 0000 0000 0000 0000 1000 = 8
3、此時bitmap[1]的值為8
4、再次向bitmap中插入數組40,index值為 40/32 = 1;同樣對應bitmap[1]的值,此時bitmap[1] = 8
5、計算偏移量,40%32=8;那么需要將當前bitmap[1]的值和2的8次方進行或運算,如下:
0000 0000 0000 0000 0000 0000 0000 1000 | 0000 0000 0000 0000 0000 0001 0000 0000 = 0000 0000 0000 0000 0000 0001 0000 1000 = 264
6、此時bitmap[1]的值為264
同樣的插入其他值的過程如出一轍。而刪除的邏輯基本一致,第一步和第二步一模一樣,第三步的話是通過和對應值進行取反操作並和當前值進行與運算即可,如從bitmap中刪除40,過程如下:
/** 2的8次方為256,對於256進行取反操作,再和當前值進行與運算 */ bitmap[i] = bitmap[1] & (~ 256);
1.3、bitmap判斷數據是否存在
判斷數據是否存在的方式和存儲的邏輯類似,首先第一步和第二步都是先的計算數組的下標值index和對應的位數偏移量offset
然后需要判斷bitmap[index]的值對應的offset位置是否值為1即可,判斷過程是將2的offset次方的值和bitmap[index]的值進行與運算,然后判斷結果是否大於0,如果大於0則表示對應位的值就是1,否則就不是
如對上面的例子進行判斷,首先依次插入35和40之后,分表對35和36進行判斷是否存在於bitmap中,過程分別如下:
1、計算35對應的index值為1,偏移量offset值為3,那么2的3次方值為8,二進制為0000 0000 0000 0000 0000 0000 0000 1000
2、將8和當前數組對應位置的值bitmap[1],也就是264,進行與運算,如下:
0000 0000 0000 0000 0000 0001 0000 1000 & 0000 0000 0000 0000 0000 0000 0000 1000 = 0000 0000 0000 0000 0000 0000 0000 1000 = 8
3、由於最后的結果8>0,所以得出結果為35存在於bitmap中
4、計算36對應的index值為1,偏移量offset值為4,那么2的4次方值為16,二進制為0000 0000 0000 0000 0000 0000 0001 0000
5、將16和當前數組對應位置的值bitmap[1],也就是264,進行與運算,如下:
0000 0000 0000 0000 0000 0001 0000 1000 & 0000 0000 0000 0000 0000 0000 0001 0000 = 0000 0000 0000 0000 0000 0000 0000 0000 = 0
6、由於最后的結果為0,所以得出結果為37不存在於bitmap中
總結:
1、bitmap通過每一位表示一個整數來判斷一個整數是否存在,所以應用場景就可以保護所有判斷整數是否存在的場景。
2、bitmap同樣會存在數據稀疏的問題,比如如果需要存在兩個特別大的值,如存100個大於2的30次方的值,那么就需要分配相當大的存儲空間,就會造成內存空間的浪費。所以bitmap僅僅適合數據量較大的情況下使用,對於集合數據量小的場景不實用
3、bitmap僅僅只能用於整數的判斷,無法判斷字符串是否存在
二、布隆過濾器的實現
由上一節可知bitmap可以實現從一個比較大的整數集合中判斷一個數字是否存在,但是實際場景中往往還會有其他的場景,比如從10億個身份證判斷某個身份證號碼是否存在,很顯然采用bitmap就無法實現了,因為bitmap只能判斷整數是否存在。所以如果有一種方式能夠將身份證號碼的字符串轉換成一個整數,那么就可以使用bitmap來實現判斷字符串是否存在於一個集合中的需求了。而通過字符串轉換成整數的方式也很普遍,那么就是采用hash函數通過計算字符串的hashCode來轉換成整數。
而布隆過濾器實際就是一系列的hash函數+bitmap實現的。
1、字符串存入bitmap中
布隆過濾器是通過bitmap實現的,只不過在bitmap之上添加了多個hash函數來對傳入的數據轉換正常整數類型。如下圖示

字符串hello和字符串world,通過hash計算之后分別hashCode值為1和8,那么就可以通過bitmap的功能將1和8分別存入bitmap中,就相當於hello和world兩個字符串存入了bitmap中。判斷字符串是否存在時就可以通過計算hashCode的方式,判斷對應的hash值是否存在於bitmap中即可可以判斷字符串是否存在於bitmap中了
2、hash碰撞問題
雖然通過字符串計算hash值存入bitmap中表面上沒有什么問題,但是hash函數是存在一定的碰撞概率的,也就是多個字符串計算出來的hash值是一樣的,此時就會出現誤判的情況。

如上圖,判斷字符串abcde是否存在,就需要先計算hashCode值,結果為8,此時判斷結果為hashCode為8已經存在於bitmap中的,此時就會得到錯誤的判斷是字符串abcde已經存在了,但是實際是並不存在的,而是出現了hashCode碰撞的情況。但是如果對應的hashCode在bitmap中不存在,那么就可以確認當前字符串不存在。而hashCode存在的情況下,只能說明當前字符串是可能存在。
所以你通過布隆過濾器只能實現的功能為:能夠確認一個字符串不存在於集合中,但是無法確認一個字符串存在於集合中。
3、hash碰撞問題的優化
由於hash函數會存在hash碰撞的情況,就導致布隆過濾器的功能會出現比較大的誤差,那么既然一個hash函數存在hash碰撞,就可以采用多個hash函數來降低hash碰撞的概率。比較不同的字符串通過多個不同的hash函數還碰撞的概率會大大降低。如下圖:

字符串hello通過三個hash函數分別計算出來的hash值為1、8、25;字符串world通過三個hash函數計算出來的hash值為5、15、25,雖然hash值為25發生了hash碰撞的情況,但是兩位兩個hash值均沒有發生hash碰撞,只有當通過三個hash函數計算出來的hash值都存在時才能夠判斷一個字符串可能存在,如果某個字符串通過三個hash函數計算出來的hash值只有部分存在,那么就是存在hash碰撞,且給字符串肯定不存在。
雖然通過多個hash函數可以對於誤判的情況進行優化,但是並沒有本質上解決誤判的情況,因為畢竟從理論上還是可能會存在多個hash值發生了hash碰撞的情況的。比如一個字符串通過三個hash函數計算的值分別為1、5、15,那么雖然和上面兩個字符串都不是全部沖突了,但是1和hello發生了沖突,5和15和world發生了沖突,如果hello和world都存在,那么就會導致hash值為1、5、15的字符串產生誤判的情況。
4、布隆過濾器刪除元素
bitmap是支持刪除元素的,因為bitmap不存在沖突的情況,每一個數字只會對應一個元素,而布隆過濾器的每一個元素都有可能會對應多個元素,所以不能通過刪除的方式刪除元素,因為這樣可能會導致其他元素查詢的結果不正確。
比如上圖的例子,如果將world字符串刪除,那么就需要將5、15、25三個位置的值置為0,此時再判斷hello是否存在結果25的位置為0,那么就導致判斷結果為hello字符串不存在了。
可以通過對每一位數字計算的方式判斷每一位被hash沖突了多少次來實現刪除元素的方式,但是每一位增加計算就會大大增加存儲的空間。
總結:
布隆過濾器的本質是一個很長的位數組和一系列隨機映射哈希函數
布隆過濾器判斷存在的數據可能存在,布隆過濾器判斷不存在的數據肯定不存在;
實際存在的數據布隆過濾器肯定判斷存在,實際不存在的數據布隆過濾器可能會判斷存在。
三、布隆過濾器的Java實現
google的guava包中提供了布隆過濾器的Java實現,對應的類為BloomFilter。使用案例如下:
1 public static void main(String[] args){ 2 int capacity = 100000; 3 /** 初始化容量為10萬大小的字符串布隆過濾器,默認誤差率為0.03 4 * 布隆過濾器容量為10萬並非指bitmap的長度就是10萬,因為需要考慮到存在hash沖突的情況,所以bitmap的實際長度要比10萬要大很多 5 * bitmap長度比需要存在的數據量大小越大,誤差率會越低 6 * */ 7 BloomFilter bloomFilter =BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), capacity); 8 9 Set<String> sets = new HashSet<>(); 10 List<String> lists = new ArrayList<>(); 11 12 for (int i =0;i< capacity; i++){ 13 String str = UUID.randomUUID().toString(); 14 bloomFilter.put(str); 15 sets.add(str); 16 lists.add(str); 17 } 18 19 int existsCount = 0; 20 int mightExistsCount = 0; 21 22 for(int i=0;i<10000;i++){ 23 //如果i為100倍數,取實際的值;否則就隨機一個字符串 24 String data = i%100==0?lists.get(i/100):UUID.randomUUID().toString(); 25 /** 通過布隆過濾器判斷字符串是否存在*/ 26 if(bloomFilter.mightContain(data)){ 27 /** 如果布隆過濾器認為存在,則表示可能存在的數量mightExistsCount自增1*/ 28 mightExistsCount++; 29 /** 如果set中存在則existsCount自增1*/ 30 if(sets.contains(data)){ 31 existsCount++; 32 } 33 } 34 } 35 36 //測試總次數 37 BigDecimal total = new BigDecimal(10000); 38 //錯誤總次數 39 BigDecimal error = new BigDecimal(mightExistsCount - existsCount); 40 //誤差率 41 BigDecimal rate = error.divide(total, 2, BigDecimal.ROUND_HALF_UP); 42 43 System.out.println("初始化10萬條數據,判斷100個真實數據,9900個不存在數據"); 44 System.out.println("實際存在的字符串個數為:" + existsCount); 45 System.out.println("布隆過濾器認為存在的個數為:" + mightExistsCount); 46 System.out.println("誤差率為:" + rate.doubleValue()); 47 }
測試兩次結果分別如下:
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:441 4 誤差率為:0.03
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:406 4 誤差率為:0.03
在案例中通過BloomFilter類的靜態方法create方法創建一個布隆過濾器,並初始化了需要存儲數據的類型和數量,如案例中是存放String類型,並且容量為10萬條數據。雖然容量是10萬但是bitmap的實際長度遠不止10萬的長度,因為bitmap長度越大,hash碰撞導致的誤差率就會越低。BloomFilter默認的誤差率為0.03,也就是3%,可以通過初始化BloomFilter指定誤差率。
案例中將10萬條數據存入布隆過濾器,然后挑選100條真實存在的數據和9900條不存在的數據判斷是否存在,結果顯示為真實存在的100條,而布隆過濾器認為存在的數據條數為441條,也就是誤差率為(441-100)/10000 = 0.03
同樣誤差率是可以調整的,但是不能調整為0,因為誤差率為0從理論上是不可能的,只能通過擴大bitmap來盡量降低誤差率。如上述案例設置誤差率為0.01,那么初始化方法如下:
1 /** 指定誤差率為0.01 */ 2 BloomFilter bloomFilter =BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), capacity, 0.01);
繼續測試上述案例,測試兩次結果如下:
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:186 4 誤差率為:0.01
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:208 4 誤差率為:0.01
可以看出雖然每次誤差的數量不同,但是誤差率都始終保持在了設定的范圍之內。同樣可以繼續縮小誤差率,比如將誤差率分別設置為0.005和0.001,測試結果分別如下:
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:150 4 誤差率為:0.005
1 初始化10萬條數據,判斷100個真實數據,9900個不存在數據 2 實際存在的字符串個數為:100 3 布隆過濾器認為存在的個數為:111 4 誤差率為:0.001
所以可以看出布隆過濾器只能控制誤差率,但是永遠也做不到沒有誤差,只能通過設置誤差率盡量降低誤差,但是永遠不能設置為0,如果初始化設置為0那么會直接拋異常。另外誤差率必須是小於1的值
四、布隆過濾器的源碼簡析
1、首先需要初始化布隆過濾器,通過BloomFilter的靜態方法create方法創建,源碼如下:
1 /** 2 * @param funnel:存入元素的類型 3 * @param expectedInsertions:期望保存元素的個數 4 * */ 5 public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions) { 6 //調用重載函數 7 return create(funnel, (long) expectedInsertions); 8 } 9 10 public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) { 11 /** 默認誤差率為0.03 */ 12 return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions 13 } 14 15 /** 16 * @param fpp : 誤差率 17 * */ 18 public static <T> BloomFilter<T> create( 19 Funnel<? super T> funnel, long expectedInsertions, double fpp) { 20 return create(funnel, expectedInsertions, fpp, BloomFilterStrategies.MURMUR128_MITZ_64); 21 } 22 23 /** 24 * @param strategy : 哈希函數的策略 25 * */ 26 static <T> BloomFilter<T> create( 27 Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) { 28 //參數校驗,誤差率必須為大於0且小於1 29 checkNotNull(funnel); 30 checkArgument( 31 expectedInsertions >= 0, "Expected insertions (%s) must be >= 0", expectedInsertions); 32 checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp); 33 checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp); 34 checkNotNull(strategy); 35 36 /** 期待容量不可為0*/ 37 if (expectedInsertions == 0) { 38 expectedInsertions = 1; 39 } 40 41 /** 根據期待容量和誤差率,計算bitmap的位數 */ 42 long numBits = optimalNumOfBits(expectedInsertions, fpp); 43 /** 根據期待容量和bitmap的位數,計算需要的hash函數的數量 */ 44 int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits); 45 try { 46 /** 創建BloomFilter對象 */ 47 return new BloomFilter<T>(new LockFreeBitArray(numBits), numHashFunctions, funnel, strategy); 48 } catch (IllegalArgumentException e) { 49 throw new IllegalArgumentException("Could not create BloomFilter of " + numBits + " bits", e); 50 } 51 }
create方法重載比較多,主要都是在初始化參數,核心參數為需要保存元素的個數、誤差率。然后通過容量和誤差率計算bitmap需要的位數,並且計算需要經歷多少次hash函數。
2、向布隆過濾器中插入元素
BloomFilter插入元素調用了BloomFilter內部類Strategy的實現類的put方法,源碼如下:
1 public <T> boolean put( 2 T object, Funnel<? super T> funnel, int numHashFunctions, LockFreeBitArray bits) { 3 /** bitmap長度 */ 4 long bitSize = bits.bitSize(); 5 long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong(); 6 int hash1 = (int) hash64; 7 int hash2 = (int) (hash64 >>> 32); 8 9 boolean bitsChanged = false; 10 /** 遍歷每個哈希函數 */ 11 for (int i = 1; i <= numHashFunctions; i++) { 12 int combinedHash = hash1 + (i * hash2); 13 // Flip all the bits if it's negative (guaranteed positive number) 14 if (combinedHash < 0) { 15 combinedHash = ~combinedHash; 16 } 17 /** 修改對應hash值上面的值 */ 18 bitsChanged |= bits.set(combinedHash % bitSize); 19 } 20 return bitsChanged; 21 }
邏輯不復雜,就是通過計算出來的hash函數的個數,遍歷執行多少次hash函數,修改bitmap上對應位置的值
3、查詢布隆過濾器是否存在元素
1 /** 判斷元素是否可能存在,false則肯定不存在,true則表示可能存在 */ 2 public boolean mightContain(T object) { 3 return strategy.mightContain(object, funnel, numHashFunctions, bits); 4 } 5 6 public <T> boolean mightContain(T object, Funnel<? super T> funnel, int numHashFunctions, BloomFilterStrategies.LockFreeBitArray bits) { 7 long bitSize = bits.bitSize(); 8 long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong(); 9 int hash1 = (int)hash64; 10 int hash2 = (int)(hash64 >>> 32); 11 /** 遍歷執行多次hash函數 */ 12 for(int i = 1; i <= numHashFunctions; ++i) { 13 int combinedHash = hash1 + i * hash2; 14 if (combinedHash < 0) { 15 combinedHash = ~combinedHash; 16 } 17 /** 如果存在hash函數位不存在,直接返回false*/ 18 if (!bits.get((long)combinedHash % bitSize)) { 19 return false; 20 } 21 } 22 /** 如果所有hash函數都命中,則返回true*/ 23 return true; 24 }
五、布隆過濾器的應用
1、redis緩存穿透問題的解決,先將需要查詢的數據存入布隆過濾器,如果布隆過濾器不存在則直接返回;如果布隆過濾器存在則再從redis查詢(此時只會有少數誤差數據);如果redis中還不存在則查詢數據庫(此時的訪問很小了),並在查詢數據庫可以通過並發加鎖處理,保證只有一個線程可以查詢該數據並寫入緩存,從而避免了緩存穿透的問題
2、爬給定網址的時候對已經爬取過的URL去重
3、郵箱的垃圾郵件過濾、黑名單等
4、經典面試題:一個10G大小的文件,存儲內容為自然數,一行一個亂序排放,需要對其進行排序操作,但是機器的內存只有2G。
此時就可以通過布隆過濾器進行操作。首先將10G大小文件通過工具分隔成多個小文件,然后依次讀取數據將數據存入bitmap中,10G的大小的自然數差不多可以存儲27億個左右的整數。
27億個整數存入bitmap需要占用的空間為 2700000000/8/1024/1024 = 320M左右,所以內存是足夠的。然后從1到最大值進行遍歷判斷是否存在於bitmap中從而達到排序的效果。
