絮叨
HashMap作爲Java中最常用的數據結構之一,在工作中使用HashMap的頻率和你遇見NullPointException一樣多,在面試中被問到的概率和問你名字的概率一樣大。既然工作,面試經常遇到,我們有必要熟悉HashMap的每一個細節。作爲最常用的數據結構之一,我們都知道HashMap的容量爲2次冪。當被問到爲什麼是2次冪時,大家應該都能回答出來,是爲了均勻分散到數組中。但是2次冪爲什麼就能均勻分散,3次冪不能均勻分散嗎?如果你一頭霧水,看了這篇文章你就會明白爲什麼只有HashMap的容量爲2次冪能夠使鍵值對均勻分散。
說明一下文章中所有代碼來源於JDK1.8
正文
假設HashMap的初始容量爲17
我們來看看HashMap是如何存數據的。
HashMap底層數據結構爲數組+鏈表+紅黑樹。當我們向HashMap中插入一個對象,HashMap需要知道我這個對象要插入到數組的哪個位置。當得到插入到數組的下標index後,HashMap還需要去分辨這個位置有沒有值。如果此時沒有值,廢話不多話直接往這個位置放值。但是如果當前位置不是空怎麼辦,已經有一個對象在那呢,這很簡單,覆蓋就行了。可是如果有個鏈表佔着呢,鏈表?鏈表也沒事,遍歷這個鏈表,放到鏈表的尾部去。HashMap大哥!這個位置如果被紅黑樹佔着呢!紅黑樹啊,讓紅黑樹乖乖的給我找個位置放這個對象,改改顏色,調整一下樹結構,這對紅黑樹來說這麼簡單的事。至此一個結點被放到一個正確的位置。
我們來好好看看由key映射到index的算法。
int index = (n - 1) & hash
HashMap大哥!這個映射算法爲什麼要這麼寫啊?爲什麼不用%啊?
這個給你好好說道說道,拿小本本記下來!
1:&運算比%運算效率高很多
2:當n爲2次冪,(n-1)&hash等同於hash%n
嗷嗷,原來是這樣子!
那接下來我們來看一下當前HashMap的初始容量爲17,即n等於17時,會發生什麼。
1.n=17則n-1=16,16的二進制表示爲0010000。
2.如果我現在向HashMap中插入兩個鍵值對,key1=A,Key2=B
假設通過Hash算法得知A的hash值爲1010110,B的Hash值爲0110000
3.分別算出key等於A,B時所映射的index
0010000 0010000
1010110 0110000———————————
0010000 0010000
HashMap大大!爲什麼Hash值差別那麼大,得到的index值確實相同
這是由於&運算的特殊性導致的,在二進制中&符號兩邊的元素只要有一個爲0,"&"操作執行後的結果就爲0。
其實當n=17時,計算出來的index只有兩個值,0或者16。
如果我們向這個HashMap中保存大量的鍵值對,所有的鍵值對都會堆積在數據的第0個或者第16個位置,而第1-15個位置一直是null。
可是爲什麼當n爲二次冪時,可以讓鍵值對均勻分散,不會造成鍵值對堆積呢?
這個問題我反問你一下,當n爲二次冪,n-1的二進制表示是什麼樣子?
當n爲二次冪,n-1的二進制表示中所有的低位均爲1,如果n是16,n-1的二進制表示就是01111。這個時候通過(n-1)&hash得到的值能夠分散到0-15的每一個位置中,而且分散到每一個位置的概率都相同,不會發生鍵值對堆積在某幾個位置的現象。
講到這裏大家對HashMap的容量爲什麼是二次冪應該有了瞭解,爲了讓鍵值對均勻分散到數組中。如果鍵值對只是堆積在某幾個位置,這完全浪費了數組其餘位置的資源,同時還增加了HashMap獲取鍵值對的複雜度,對cpu資源也是一種浪費。
接下來的內容是對Hash算法和HashMap數組容量的引申內容,但是也是屬於HashMap中必知必會的知識點,一定要喫透理解透。
說到Hash算法,我們來思考這一個問題,如果有很多key的hash值低位不變化,只是在高位變化,這樣豈不是也會造成鍵值對的堆積。比如010110000,110110000,000010000。如果此時n等於32,計算出來的index都等於16。
這個問題在設計Hash算法的時候就想到啦,來看看我的hash值怎麼計算出來的你就明白啦。
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
仔細看看註釋,你這個問題在註釋裏就能找到啦。我來簡單的翻譯一下吧(英語有待加強)。
計算key的hashcode值,同時將hash值的高位傳遞給低位。因爲表的長度使用二次冪,因此僅在高位變化的散列總是發生衝突(已知的例子是在小的表中保存連續整數的浮點鍵)。所有我們就利用了一個轉換,將高位向下轉移。這是一個在速度,效率,位移動上的交易。因爲許多的Hash值是合理分佈的(所有不利於傳遞)。並且我們使用紅黑樹去處理更多hash值的碰撞情況。所有我們用一些減少系統消耗並且廉價的方式去轉移一些字節,以及可以利用高位的值來計算index值,否則高位不會參與到index的計算中。
HashMap大大!我明白了!爲了避免這種Hash值低位不變,僅高位變化的情況,所有HashMap的設計者在設計Hash算法時利用“^”運算使得Hash值的高位與低位相互碰撞,讓Hash值的高位與低位同時參與index的計算中。HashMap大大!還有一個問題,在HashMap的構造方法中我可以傳入容量這個參數啊,如果我傳入的值是非二次冪,那豈不是會造成鍵值對堆積的情況。
你還是太年輕了,雖然你傳進的容量值是一個非二次冪,但是初始化的時候HashMap真正的容量值可是和你傳入的值不一樣哦。來,仔細看看HashMap這個構造方法。
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
情況1.如果傳入的initialCapacity小於0,拋異常。
情況2.如果initialCapacity大於最大容量,將最大容量賦予給initialCapacity。
最後3.調用tableSizeFor方法,將initialCapacity轉化爲一個二次冪的值,然後賦予給threshold。
Tip:HashMap初始化時,使用threshold作爲HashMap數組的長度。
HashMap大大!如果我通過HashMap的構造函數傳入的容量是一個非二次冪,HashMap會通過tableSizeFor方法將這個值轉化爲二次冪,這麼理解對嗎?
對,總結很到位,我們來看看tableSizeFor方法如何將一個非二次冪轉化爲一個二次冪
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
這部分有點難理解,舉個例子說明
假設傳入的參數cap是一個非二次冪,cap=17
1.n-1=16。二次冪表示爲0010000(一個1)
2.n |= n >>> 1。計算得到n等於0011000(兩個1)
3.n |= n >>> 2。計算得到n等於0011110(四個1)
4.n |= n >>> 4。計算得到n等於0011111(五個一)
5.n |= n >>> 8。計算得到n等於0011111(五個一)
6.n |= n >>> 16。計算得到n等於0011111(五個一)
7.n+1等於0100000,十進制等於32。
好精妙的算法啊!
哈哈,學無止境,現在到我們的總結時間了
總結
1.當HashMap的數組長度爲二次冪,能夠將鍵值對均勻分散到數組的每一個位置上。
2.index映射算法和%能夠達成一樣的效果,並且效率更高效。
3.Hash算法在設計時利用移位和異或運算符“^”來避免Hash碰撞。
4.如果通過HashMap的構造函數傳入的容量是一個非二次冪,HashMap會通過tableSizeFor方法將這個值轉化爲二次冪的值。