程序世界的算法都要在時間,資源佔用甚至正確率等多種因素間進行平衡。一樣的問題,所屬的量級或場景不一樣,所用算法也會不一樣,其中也會涉及不少的trade-off。html
If there’s one rule in programming, it’s this: there will always be trade-offs.java
今天咱們就來探討如何判斷一個值是否存在於已有的集合問題。這類問題在不少場景下都會遇到,好比說防止緩存擊穿,爬蟲重複URL檢測,字典糾纏和CDN代理緩存等。node
咱們以網絡爬蟲爲例。網絡間的連接錯綜複雜,爬蟲程序在網絡間「爬行」極可能會造成「環」。爲了不造成「環」,程序須要知道已經訪問過網站的URL。當程序又遇到一個網站,根據它的URL,怎麼判斷是否已經訪問過呢?算法
第一個想法就是將已有URL放置在HashSet
中,而後利用HashSet
的特性進行判斷。它只花費O(1)的時間。可是,該方法消耗的內存空間很大,就算只有1億個URL,每一個URL只算50個字符,就須要大約5GB內存。sql
如何減小內存佔用呢?URL可能太長,咱們使用MD5等單向哈希處理後再存到HashSet中吧,處理後的字段只有128Bit,這樣能夠節省大量的空間。咱們的網絡爬蟲程序又能夠繼續執行了。數組
可是好景不長,網絡世界浩瀚如海,URL的數量急速增長,以128bit的大小進行存儲也要佔據大量的內存。緩存
這種狀況下,咱們還可使用BitSet
,使用哈希函數將URL處理爲1bit,存儲在BitSet中。可是,哈希函數發生衝突的機率比較高,若要下降衝突機率到1%,就要將BitSet
的長度設置爲URL個數的100倍。bash
可是衝突沒法避免,這就帶來了誤判。理想中的算法老是又準確又快捷,可是現實中每每是「一地雞毛」。咱們真的須要100%的正確率嗎?若是須要,時間和空間的開銷沒法避免;若是可以忍受低機率的錯誤,就有極大地下降時間和空間的開銷的方法。網絡
因此,一切都要trade-off。布隆過濾器(Bloom Filter)就是一種具備較低錯誤率,可是極大節約空間消耗的算法。數據結構
Bloom Filter是一種空間效率很高的隨機數據結構,它利用位數組很簡潔地表示一個集合,並能判斷一個元素是否屬於這個集合。Bloom Filter的這種高效是有必定代價的:在判斷一個元素是否屬於某個集合時,有可能會把不屬於這個集合的元素誤認爲屬於這個集合(false positive)。所以,Bloom Filter不適合那些「零錯誤」的應用場合。而在能容忍低錯誤率的應用場合下,Bloom Filter經過極少的錯誤換取了存儲空間的極大節省。
A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not, thus a Bloom filter has a 100% recall rate. In other words, a query returns either 「possibly in set」 or 「definitely not in set」.
上述描述引自維基百科,特色總結爲以下:
布隆過濾器的使用場景不少,除了上文說的網絡爬蟲,還有處理緩存擊穿和避免磁盤讀取等。Goole Bigtable,Apache HBase和Postgresql等都使用了布隆過濾器。
咱們就如下面這個例子具體描述使用BloomFilter的場景,以及在此場景下,BloomFilter的優點和劣勢。
一組元素存在於磁盤中,數據量特別大,應用程序但願在元素不存在的時候儘可能不讀磁盤,此時,能夠在內存中構建這些磁盤數據的BloomFilter,對於一次讀數據的狀況,分爲如下幾種狀況:
咱們知道HashMap或者Set等數據結構也能夠支持上述場景,這裏咱們就具體比較一下兩者的優劣,並給出具體的數據。
精確度量十分重要,對於算法的性能,咱們不能只是簡單的感官上比較,要進行具體的計算和性能測試。找到不一樣算法之間的平衡點,根據平衡點和現實狀況來決定使用哪一種算法。就像Redis同樣,它對象在不一樣狀況下使用不一樣的數據結構,好比說列表對象的內置結構能夠爲ziplist
或者linkedlist
,在不一樣的場景下使用不一樣的數據結構。
請求的元素不在磁盤中,若是BloomFilter返回不存在,那麼應用不須要走讀盤邏輯,假設此機率爲P1。若是BloomFilter返回可能存在,那麼屬於誤判狀況,假設此機率爲P2。請求的元素在磁盤中,BloomFilter返回存在,假設此機率爲P3。
若是使用HashMap
等數據結構,狀況以下:
假設應用不讀盤邏輯的開銷爲C1,走讀盤邏輯的開銷爲C2,那麼,BloomFilter和hashmap的開銷分別爲
所以,BloomFilter至關於以增長P2 * (C2 - C1)的時間開銷,來得到相對於HashMap
而言更少的空間開銷。
既然P2是影響BloomFilter性能開銷的主要因素,那麼BloomFilter設計時如何下降機率P2(即誤判率false positive probability)呢?,接下來的BloomFilter的原理將回答這個問題。
初始狀態下,布隆過濾器是一個包含m位的位數組,每一位都置爲0。
爲了表達S={x1, x2,…,xn}這樣一個n個元素的集合,Bloom Filter使用k個相互獨立的哈希函數,它們分別將集合中的每一個元素映射到{1,…,m}的範圍中。對任意一個元素x,第i個哈希函數映射的位置hi(x)就會被置爲1(1≤i≤k)。注意,若是一個位置屢次被置爲1,那麼只有第一次會起做用,後面幾回將沒有任何效果。在下圖中,k=3,且有兩個哈希函數選中同一個位置(從左邊數第五位)。
在判斷y是否屬於這個集合時,咱們對y應用k次哈希函數,若是全部hi(y)的位置都是1(1≤i≤k),那麼咱們就認爲y是集合中的元素,不然就認爲y不是集合中的元素。下圖中y1就不是集合中的元素。y2則可能屬於這個集合,或者恰好是一個誤判。
下面咱們來看一下具體的例子,哈希函數的數量爲3,首先加入1,10兩個元素。經過下面兩個圖,咱們能夠清晰看到1,10兩個元素被三個不一樣的韓系函數映射到不一樣的bit上,而後判斷3是否在集合中,3映射的3個bit都沒有值,因此判斷絕對不在集合中。
關於誤判率,實際的使用中,指望能給定一個誤判率指望和將要插入的元素數量,能計算出分配多少的存儲空間較合適。這涉及不少最優數值計算問題,好比說錯誤率估計,最優的哈希函數個數和位數組的大小等,相關公式計算感興趣的同窗能夠自行百度,重溫一下大學的計算微積分時光。
這就又要提起咱們的Guava了,它是Google開源的Java包,提供了不少經常使用的功能,好比說咱們以前總結的超詳細的Guava RateLimiter限流原理解析 。
Guava中,布隆過濾器的實現主要涉及到2個類,BloomFilter
和BloomFilterStrategies
,首先來看一下BloomFilter
的成員變量。須要注意的是不一樣Guava版本的BloomFilter
實現不一樣。
/** guava實現的以CAS方式設置每一個bit位的bit數組 */
private final LockFreeBitArray bits;
/** hash函數的個數 */
private final int numHashFunctions;
/** guava中將對象轉換爲byte的通道 */
private final Funnel<? super T> funnel;
/**
* 將byte轉換爲n個bit的策略,也是bloomfilter hash映射的具體實現
*/
private final Strategy strategy;
複製代碼
這是它的4個成員變量:
LockFreeBitArray
是定義在BloomFilterStrategies
中的內部類,封裝了布隆過濾器底層bit數組的操做。numHashFunctions
表示哈希函數的個數。Funnel
,它和PrimitiveSink
配套使用,能將任意類型的對象轉化成Java基本數據類型,默認用java.nio.ByteBuffer
實現,最終均轉化爲byte數組。Strategy
是定義在BloomFilter
類內部的接口,代碼以下,主要有2個方法,put
和mightContain
。interface Strategy extends java.io.Serializable {
/** 設置元素 */
<T> boolean put(T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits);
/** 判斷元素是否存在*/
<T> boolean mightContain(
T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits);
.....
}
複製代碼
建立布隆過濾器,BloomFilter
並無公有的構造函數,只有一個私有構造函數,而對外它提供了5個重載的create
方法,在缺省狀況下誤判率設定爲3%,採用BloomFilterStrategies.MURMUR128_MITZ_64
的實現。
BloomFilterStrategies.MURMUR128_MITZ_64
是Strategy
的兩個實現之一,Guava以枚舉的方式提供這兩個實現,這也是《Effective Java》書中推薦的提供對象的方法之一。
enum BloomFilterStrategies implements BloomFilter.Strategy {
MURMUR128_MITZ_32() {//....}
MURMUR128_MITZ_64() {//....}
}
複製代碼
兩者對應了32位哈希映射函數,和64位哈希映射函數,後者使用了murmur3 hash生成的全部128位,具備更大的空間,不過原理是相通的,咱們選擇相對簡單的MURMUR128_MITZ_32
來分析。
先來看一下它的put
方法,它用兩個hash函數來模擬多個hash函數的狀況,這是布隆過濾器的一種優化。
public <T> boolean put(
T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
long bitSize = bits.bitSize();
// 先利用murmur3 hash對輸入的funnel計算獲得128位的哈希值,funnel現將object轉換爲byte數組,
// 而後在使用哈希函數轉換爲long
long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
// 根據hash值的高低位算出hash1和hash2
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
boolean bitsChanged = false;
// 循環體內採用了2個函數模擬其餘函數的思想,至關於每次累加hash2
for (int i = 1; i <= numHashFunctions; i++) {
int combinedHash = hash1 + (i * hash2);
// 若是是負數就變爲正數
if (combinedHash < 0) {
combinedHash = ~combinedHash;
}
// 經過基於bitSize取模的方式獲取bit數組中的索引,而後調用set函數設置。
bitsChanged |= bits.set(combinedHash % bitSize);
}
return bitsChanged;
}
複製代碼
在put
方法中,先是將索引位置上的二進制置爲1,而後用bitsChanged
記錄插入結果,若是返回true代表沒有重複插入成功,而mightContain
方法則是將索引位置上的數值取出,並判斷是否爲0,只要其中出現一個0,那麼當即判斷爲不存在。
public <T> boolean mightContain(
T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
long bitSize = bits.bitSize();
long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
for (int i = 1; i <= numHashFunctions; i++) {
int combinedHash = hash1 + (i * hash2);
// Flip all the bits if it's negative (guaranteed positive number) if (combinedHash < 0) { combinedHash = ~combinedHash; } // 和put的區別就在這裏,從set轉換爲get,來判斷是否存在 if (!bits.get(combinedHash % bitSize)) { return false; } } return true; } 複製代碼
Guava
爲了提供效率,本身實現了LockFreeBitArray
來提供bit數組的無鎖設置和讀取。咱們只來看一下它的put
函數。
boolean set(long bitIndex) {
if (get(bitIndex)) {
return false;
}
int longIndex = (int) (bitIndex >>> LONG_ADDRESSABLE_BITS);
long mask = 1L << bitIndex; // only cares about low 6 bits of bitIndex
long oldValue;
long newValue;
// 經典的CAS自旋重試機制
do {
oldValue = data.get(longIndex);
newValue = oldValue | mask;
if (oldValue == newValue) {
return false;
}
} while (!data.compareAndSet(longIndex, oldValue, newValue));
bitCount.increment();
return true;
}
複製代碼
歡迎你們留言和持續關注我。