原文鏈接:https://www.changxuan.top/?p=1208
前兩天,我在一位同學提交中看到了下面這樣的一行代碼。
Map<String, String> temp = new HashMap<>(6);
我給他說,你這樣實例化 Map
對象不好用,他不服氣。我說小朋友:如果想指定 HashMap
對象的容量得用2的N次方 。假如不是2的N次方那么在第一次put
元素的時候也會自動把容量設置為比傳入參數大的最小的2的N次方,並不是你指定的這個值。他說你這也沒用。我說,我這個有用,這樣才能充分利用分配的內存空間,減少哈希碰撞次數。他非和我試試,我說可以,咱們先來看看源碼。
什么是HashMap?
在弄懂標題的問題之前,首先需要清楚 HashMap
的概念。HashMap
是基於哈希表的 Map
接口的實現,線程不安全,且不保證映射順序。
HashMap
存儲數據依賴的是數組和[鏈表|紅黑樹],具體鏈表和紅黑樹之間如何轉換的細節此文不做詳細介紹。而本文開頭提到的實例化容量大小指的則是數組的大小。
如何計算元素在數組中所對應的下標?
首先計算元素的哈希值,方法如下:
static final int hash(Object key) {
int h;
/*
* h = key.hashCode();
* h = h ^ (h >>> 16)
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
為什么不直接使用 key.hashCode()
的值,我們后面會提到。
計算出來哈希值后,由於數組容量相對來說較小肯定不能直接使用哈希值當作索引值。所以需要使用哈希值對數組長度減一后的值取模。不過在在 HashMap
中可不是直接使用 %
運算符來操作的。為了提高效率,采用的是與運算的方式,代碼如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/* n 為數組容量, (n-1) & hash 則是計算索引值 */
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
... ...
}
}
既然清楚了計算元算在數組中所對應下標的方法,那么證明為什么實例化 HashMap
對象的容量要使用2的N次方就簡單多了。
假如初始容量為2的3次方數字8,當哈希值與容量大小減一的值進行與運算時可以保證結果比較均勻的分布在數組上。
10100101 11000100 00100101
& 00000000 00000000 00000111 // 7
----------------------------------
00000000 00000000 00000101 // 結果可以是[0,7]中的任一數字
如果初始容量為6,那么出現哈希沖突的幾率就會增加了。
10100101 11000100 00100101
& 00000000 00000000 00000101 // 5
----------------------------------
00000000 00000000 00000101 // 5
10100101 11000100 00100111
& 00000000 00000000 00000101 // 5
----------------------------------
00000000 00000000 00000101 // 5
如果下面的值低位全是1,那么上面的這次哈希沖突則可以避免。
那么你想想,假如指定的容量大小為5又會怎么樣呢?如果是5,那么就會出現非常嚴重的哈希碰撞,所以為了避免這種情況出現。HashMap
並沒有傻乎乎的直接使用用戶指定的容量大小。而是在實例化 HashMap
對象時,如果初始容量大小不是2的N次方則會把 threshold
設置成比傳入初始容量大的最小的2的N次方。代碼如下:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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;
/* 設置 threshold */
this.threshold = tableSizeFor(initialCapacity);
}
/* 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;
}
在第一次調用 put
方法時,由於未初始化數組則會調用 resize()
方法初始化數組,而 threshold
參數則是初始化數組的長度。代碼如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/* 初始化數組 */
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
... ...
}
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
... ...
}
else if (oldThr > 0)
newCap = oldThr;
else {
... ...
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
/* 初始化數組 */
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
... ...
return newTab;
}
其實2的N次方數字-1的二進制形式這個特性在好多地方會很好用,可以在小本本記上。
哦,前面說為什么計算出來的散列值需要再讓高16位和低十六位做異或運算,主要是讓參與與運算的位同時具有高位和低位的特征,來減少哈希碰撞次數。
最后,雖然你指定了容量大小,但是程序並沒有按照你的意願進行初始化數組,而且對你的“錯誤”行為進行了糾錯。
小朋友,還試不試啦!