布隆過濾器(BloomFilter)是一種你們在學校沒怎麼學過,但在計算機不少領域很是經常使用的數據結構,它能夠用來高效判斷某個key是否屬於一個集合,有極高的插入和查詢效率(O(1)),也很是省存儲空間。固然它也不是天衣無縫,它也有本身的缺點,接下來跟隨我一塊兒詳細瞭解下BloomFilter的實現原理,以及它優缺點、應用場景,最後再看下Google guava包中BloomFilter的實現,並對比下它和HashSet在不一樣數據量下內存空間的使用狀況。 java
學過數據結構的人都知道,在計算機領域咱們常常經過犧牲空間換時間,或者犧牲時間換空間,BloomFilter給了咱們一種新的思路——犧牲準確率換空間。是的,BloomFilter不是100%準確的,它是有可能有誤判,但絕對不會有漏判斷,說通俗點就是,BloomFilter有可能錯殺好人,但不會放過任何一個壞人。BloomFilter最大的優勢就是省空間,缺點就是否是100%準確,這點固然和它的實現原理有關。編程
在講解BloomFilter的實現前,咱們先來了解下什麼叫Bitmap(位圖),先給你一道《編程珠璣》上的題目。數組
給你一個有100w個數的集合S,每一個數的數據大小都是0-100w,有些數據重複出現,這就意味着有些數據可能都沒出現過,讓你以O(n)的時間複雜度找出0-100w之間沒有出如今S中的數,儘量減小內存的使用。
既然時間複雜度都限制是O(N)了,意味着咱們不能使用排序了,咱們能夠開一個長爲100w的int數組來標記下哪些數字出現過,在就標1,不在標0。但對於每一個數來講咱們只須要知道它在不在,只是0和1的區別,用int(32位)有點太浪費空間了,咱們能夠按二進制位來用每一個int,這樣一個int就能夠標記32個數,空間佔用率一會兒減小到原來的1/32,因而咱們就有了位圖,Bitmap就是用n個二進制位來記錄0-m之間的某個數有沒有出現過。 瀏覽器
Bitmap的侷限在於它的存儲數據類型有限,只能存0-m之間的數,其餘的數據就存不了了。若是咱們想存字符串或者其餘數據怎麼辦?其實也簡單,只須要實現一個hash函數,將你要存的數據映射到0-m之間就好了。這裏假設你的hash函數產生的映射值是均勻的,咱們來計算下一個m位的Bitmap到底能存多少數據?
當你在Bitmap中插入了一個數後,經過hash函數計算它在Bitmap中的位置並將其置爲1,這時任意一個位置沒有被標爲1的機率是:數據結構
$$ 1 - \frac{1}{m} $$app
當插入n個數後,這個機率會變成:函數
$$ (1 - \frac{1}{m})^n $$性能
因此任意一個位置被標記成1的機率就是:測試
$$ P_1 = 1 - (1 - \frac{1}{m})^n $$大數據
這時候你判斷某個key是否在這個集合S中時,只須要看下這個key在hash在Bitmap上對應的位置是否爲1就好了,由於兩個key對應的hash值多是同樣的,因此有可能會誤判,你以前插入了a,可是hash(b)==hash(a),這時候你判斷b是否在集合S中時,看到的是a的結果,實際上b沒有插入過。
從上面公式中能夠看出有 $P_1$ 的機率可能會誤判,尤爲當n比較大時這個誤判機率仍是挺大的。 如何減小這個誤判率?咱們最開始是隻取了一個hash函數,若是說取k個不一樣的hash函數呢!咱們每插入一個數據,計算k個hash值,並對k位置爲1。在查找時,也是求k個hash值,而後看其是否都爲1,若是都爲1確定就能夠認爲這個數是在集合S中的。
問題來了,用k個hash函數,每次插入均可能會寫k位,更耗空間,那在一樣的m下,誤判率是否會更高呢?咱們來推導下。
在k個hash函數的狀況下,插入一個數後任意一個位置依舊是0的機率是:
$$ (1 - \frac{1}{m})^k $$
插入n個數後任意一個位置依舊是0的機率是:
$$ (1 - \frac{1}{m})^{kn} $$
因此可知,插入n個數後任意一個位置是1的機率是
$$ 1 - (1 - \frac{1}{m})^{kn} $$
由於咱們用是用k個hash共同來判斷是不是在集合中的,可知當用k個hash函數時其誤判率以下。它必定是比上面1個hash函數時誤判率要小(雖然我不會證實)
$$ \left(1-\left[1-\frac{1}{m}\right]^{k n}\right)^{k} < (1 - \left[1 - \frac{1}{m}\right]^n) $$
維基百科也給出了這個誤判率的近似公式(雖然我不知道是怎麼來的,因此這裏就直接引用了)
$$ \left(1-\left[1-\frac{1}{m}\right]^{k n}\right)^{k} \approx\left(1-e^{-k n / m}\right)^{k} $$
到這裏,咱們從新發明了Bloomfilter,就是這麼簡單,說白了Bloomfilter就是在Bitmap之上的擴展而已。對於一個key,用k個hash函數映射到Bitmap上,查找時只須要對要查找的內容一樣作k次hash映射,經過查看Bitmap上這k個位置是否都被標記了來判斷是否以前被插入過,以下圖。
經過公式推導和了解原理後,咱們已經知道Bloomfilter有個很大的缺點就是否是100%準確,有誤判的可能性。可是經過選取合適的bitmap大小和hash函數個數後,咱們能夠把誤判率降到很低,在大數據盛行的時代,適當犧牲準確率來減小存儲消耗仍是很值得的。
除了誤判以外,BloomFilter還有另一個很大的缺點 __只支持插入,沒法作刪除__。若是你想在Bloomfilter中刪除某個key,你不能直接將其對應的k個位所有置爲0,由於這些位置有多是被其餘key共享的。基於這個缺點也有一些支持刪除的BloomFilter的變種,適當犧牲了空間效率,感興趣能夠自行搜索下。
知道原理後再來了解下怎麼去實現,咱們在決定使用Bloomfilter以前,須要知道兩個數據,一個是要存儲的數量n和預期的誤判率p。bitmap的大小m決定了存儲空間的大小,hash函數個數k決定了計算量的大小,咱們固然都但願m和k都越小越好,如何計算兩者的最優值,咱們大概來推導下。(備註:推導過程來自Wikipedia)
由上文可知,誤判率p爲
$$ p \approx \left(1-e^{-k n / m}\right)^{k} \ (1) $$
對於給定的m和n咱們想讓誤判率p最小,就得讓
$$ k=\frac{m}{n} \ln2 \ (2) $$
把(2)式代入(1)中可得
$$ p=\left(1-e^{-\left(\frac{m}{n} \ln 2\right) \frac{n}{m}}\right)^{\frac{m}{n} \ln 2} \ (3) $$
對(3)兩邊同時取ln並簡化後,獲得
$$ \ln p=-\frac{m}{n}(\ln 2)^{2} $$
最後能夠計算出m的最優值爲
$$ m=-\frac{n \ln p}{(\ln 2)^{2}} $$
由於誤判率p和要插入的數據量n是已知的,因此咱們能夠直接根據上式計算出m的值,而後把m和n的值代回到(2)式中就能夠獲得k的值。至此咱們就知道了實現一個bloomfilter所須要的全部參數了,接下來讓咱們看下Google guava包中是如何實現BloomFilter的。
BloomFilter<T>沒法經過new去建立新對象,而它提供了create靜態方法來生成對象,其核心方法以下。
static <T> BloomFilter<T> create( Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) { checkNotNull(funnel); checkArgument( expectedInsertions >= 0, "Expected insertions (%s) must be >= 0", expectedInsertions); checkArgument(fpp > 0.0, "False positive probability (%s) must be > 0.0", fpp); checkArgument(fpp < 1.0, "False positive probability (%s) must be < 1.0", fpp); checkNotNull(strategy); if (expectedInsertions == 0) { expectedInsertions = 1; } long numBits = optimalNumOfBits(expectedInsertions, fpp); int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits); try { return new BloomFilter<T>(new LockFreeBitArray(numBits), numHashFunctions, funnel, strategy); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Could not create BloomFilter of " + numBits + " bits", e); } }
從代碼能夠看出,須要4個參數,分別是
從上面代碼可知,BloomFilter建立過程當中先檢查參數的合法性,以後使用n和p來計算bitmap的大小m(optimalNumOfBits(expectedInsertions, fpp)),經過n和m計算hash函數的個數k(optimalNumOfHashFunctions(expectedInsertions, numBits)),這倆方法的具體實現以下。
static int optimalNumOfHashFunctions(long n, long m) { // (m / n) * log(2), but avoid truncation due to division! return Math.max(1, (int) Math.round((double) m / n * Math.log(2))); } static long optimalNumOfBits(long n, double p) { if (p == 0) { p = Double.MIN_VALUE; } return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2))); }
其實就是上文中列出的計算公式。
後面插入和查找的邏輯就比較簡單了,這裏再也不贅述,有興趣能夠看下源碼,咱們這裏經過BloomFilter提供的方法列表瞭解下它的功能就行。
從上圖能夠看出,BloomFilter除了提供建立和幾個核心的功能外,還支持寫入Stream或從Stream中從新生成BloomFilter,方便數據的共享和傳輸。
……
咱們一直在說BloomFilter有巨大的存儲優點,作個優點到底有多明顯,咱們拿jdk自帶的HashSet和guava中實現的BloomFilter作下對比,數據僅供參考。
測試平臺 Mac
guava(28.1)BloomFilter,JDK11(64位) HashSet
使用om.carrotsearch.java-sizeof計算實際佔用的內存空間
分別往BloomFilter和HashSet中插入UUID,總計插入100w個UUID,BloomFilter誤判率爲默認值0.03。每插入5w個統計下各自的佔用空間。結果以下,橫軸是數據量大小,縱軸是存儲空間,單位kb。
能夠看到BloomFilter存儲空間一直都沒有變,這裏和它的實現有關,事實上你在告訴它總共要插入多少條數據時BloomFilter就計算並申請好了內存空間,因此BloomFilter佔用內存不會隨插入數據的多少而變化。相反,HashSet在插入數據愈來愈多時,其佔用的內存空間也會愈來愈多,最終在插入完100w條數據後,其內存佔用爲BloomFilter的100多倍。
在不一樣的誤判率下,插入100w個UUID,計算其內存空間佔用。結果以下,橫軸是誤判率大小,縱軸是存儲空間,單位kb。
fpp,size 0.1,585.453125 0.01,1170.4765625 1.0E-3,1755.5 1.0E-4,2340.53125 1.0E-5,2925.5546875 1.0E-6,3510.578125 1.0E-7,4095.6015625 1.0E-8,4680.6328125 1.0E-9,5265.65625 1.0E-10,5850.6796875
能夠看出,在同等數據量的狀況下,BloomFilter的存儲空間和ln(fpp)呈反比,因此增加速率其實不算快,即使誤判率減小9個量級,其存儲空間也只是增長了10倍。