HashMap是Java程序員使用頻率最高的用於映射(鍵、值對)處理的數據類型,它根據鍵的hashCode值存儲數據,大多數狀況下能夠直接定位到它的值,於是具備很快的訪問速度,但遍歷順序倒是不肯定的。 HashMap最多隻容許一條記錄的鍵爲null,容許多條記錄的值爲null,且HashMap不是線程安全的類,可使用ConcurrentHashMap和Collections的SynchronizedMap方法使HashMap具備線程安全的能力。在JDK1.8中對HashMap底層的實現進行了優化,引入紅黑樹、擴容優化等。那就從新認識一下JDK1.8的HashMap,來看看對其作了什麼優化。java
要搞清楚HashMap,首先須要知道HashMap是什麼,即它的存儲結構;其次弄明白它能幹什麼,即它的功能如何實現。咱們都知道HashMap使用哈希表來存儲的數據的,根據key的哈希值進行存儲的,可是不一樣的key之間可能存在相同的哈希值,這樣就會產生衝突;哈希表爲解決衝突,能夠採用開放地址法和鏈地址法等來解決問題,Java中的HashMap採用了鏈地址法來解決哈希衝突,簡單來講就是數組加鏈表的結合。在每一個數組元素上都一個鏈表結構,當存放數據的時候若是產生了哈希衝突,先獲得數組下標,把數據放在對應下標元素的鏈表上。這裏咱們思考一個問題,即便哈希算法設計的再合理,也免不了會出現拉鍊過長的狀況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能,在JDK1.8版本中,對數據結構作了進一步的優化,引入了紅黑樹;當鏈表長度太長(默認超過7)時,鏈表就轉換爲紅黑樹,以下圖所示。node
HashMap是根據key的哈希值進行存取的,那個HashMap的性能和哈希算法的好壞有着直接的關係,哈希算法計算結果越分散均勻,哈希碰撞的機率就越小,map的存取效率就會越高。固然,也和哈希數組的大小有關係,若是哈希數組很大,即便較差的哈希算法也會比較分散,若是哈希數組較小,即便較好的哈希算法也會出現較多的碰撞,因此就須要權衡空間和時間成本,找到比較平衡的值。程序員
JDK1.8版本也是權衡了時間、空間成本以及效率,對以前的版本作出了不少優化;不只對數據結構進行了優化,除此以外還對擴容進行的優化,大大的提升的HashMap的性能。下面咱們經過源碼來一塊兒看一下具體的實現。算法
咱們來看一下HashMap中比較重要的幾個屬性。數組
//默認的初始容量,必須是2的冪次方.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//所能容納 key-value 個數的極限,當Map 的size > threshold 會進行擴容 。容量 * 擴容因子
int threshold;
//hashMap最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//HashMap 默認的桶數組的大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
//默認的加載因子.當容量超過 0.75*table.length 擴容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap的加載因子,在構造器中指定的.
final float loadFactor;
//鏈表節點數大於8個鏈表轉紅黑樹
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹節點轉換鏈表節點的閾值, 6個節點轉
static final int UNTREEIFY_THRESHOLD = 6;
//以Node數組存儲元素,長度爲2的次冪。
transient Node<K,V>[] table;
// 轉紅黑樹, table的最小長度
static final int MIN_TREEIFY_CAPACITY = 64;
// 鏈表節點, 繼承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ... ...
}
// 紅黑樹節點
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
// ...
}
複製代碼
HashMap中的屬性仍是比較好理解的。其實到這裏會有一個疑問,爲何默認的哈希桶數組table的長度爲16,並且長度必須爲2的n次方呢?安全
這裏咱們先說一下爲何哈希數組的長度是2的n次方。bash
其實不論是在JDK1.7仍是JDK1.8中,計算key索引位置都是經過hash & (length-1)計算得來的。數據結構
咱們應該知道 hash % length 等價於 hash & (length - 1)。併發
假若有一個key的哈希值二進制以下:這裏咱們就只看低位。
hahsCode 0010 0011 ———————轉成十進制—————————> 35
& %
(length-1)=15: 0000 1111 length = 16
-----------------------------------------------------------------------------------------------
(二進制) 0011 = (十進制)3 3
複製代碼
爲何不用 hash % length 計算索引位,要使用 hash & (length -1)來計算呢?計算機底層是二進制數進行計算和存儲,&是接近計算機底層運算,相比於% 運算效率上應該會快。app
那爲何length必須是2的n次方呢?
hahsCode 0010 0011 0010 1111
&
(length-1)=15: 0000 1111 (length-1) = 13: 0000 1111
----------------------------------------------------------------------------------------------
0011 1111
複製代碼
hahsCode 0010 1110 1110 1100
&
(length-1)=13: 0000 0101 (length-1) = 13: 0000 0101
----------------------------------------------------------------------------------------------
0100 0100
複製代碼
其實咱們能夠發現,當哈希數組的長度爲2的n次方時,length - 1的二進制碼全都是1,這樣的話索引的位置,徹底依賴於hash值的低位值,並且產生衝突的概率要比容量不是2的n次方的機率要低,索引位就徹底依賴於哈希值的低位,因此只要哈希值分佈均勻,那產生衝突的機率就會低不少,故而length是2的n次方更優。
其次,當length爲2的n次方時,也方便作擴容,JDK1.8在擴容算法上也進行了優化,使用的方法也很是巧妙。會在擴容方法的時候講到。
不論是增長、刪除、查找,都須要定位到哈希桶的數組位置,前面也說過HashMap的數據結構是數組和鏈表的結合,因此咱們固然但願這個HashMap裏面的元素位置儘可能分佈均勻些,儘可能使得每一個位置上的元素數量只有一個,那麼當咱們用hash算法求得這個位置的時候,立刻就能夠知道對應位置的元素就是咱們要的,不用再遍歷鏈表查詢,大大優化了查詢效率。
tableSizeFor()這個方法,就是保證在HashMap()進行初始化的時候,哈希桶數組的大小永遠是2^n。
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 = 3 那 n = 2 對應的二進制 爲 10 n = n | n>>>1 10|01 獲得 11 .... .... n = 11(二進制) = (10進制) 3 最後return 返回的是4 */
}
複製代碼
//JDK1.8的Hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// JDK 1.7的Hash算法
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//索引位置
index = hash & (length-1);
//JDK1.7 使用hashCode() + 4次位運算 + 5次異或運算(9次擾動)
//JDK 1.8 簡化了hash函數 = 只作了2次擾動 = 1次位運算 + 1次異或運算。
複製代碼
在JDK1.8的實現中,優化了高位運算的算法,經過hashCode()的高16位異或低16位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,相比於JDK1.7來講,JDK1.8下降了哈希函數擾動的次數,也算是優化了hash算法。這麼作能夠在HashMap容量較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。
假若有一個key的哈希值二進制以下
hahsCode 0000 0000 0011 0011 0111 1010 1000 1011
hahsCode>>>16 0000 0000 0000 0000 0000 0000 0011 0011
———————————————————————————————————————————————————————————————
位或^運算 0000 0000 0011 0011 0111 1010 1011 1000
&
HashMap.size()-1 0000 0000 0000 0000 0000 0000 0000 1111
———————————————————————————————————————————————————————————————
0000 0000 0000 0000 0000 0000 0000 1000 轉成十進制是 8
複製代碼
從網上找到了一個流程圖,感受很不錯,就直接拿過來用了,嘿嘿....畫的也是比較清楚的。看着流程圖,再結合源碼一看就明白。
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)
//若是哈希桶數組爲空,對其進行初始化。默認的桶數組大小爲16
n = (tab = resize()).length;
//若是桶數組不爲空,獲得計算key的索引位置,判斷此索引所在位置是否已經被佔用了。
if ((p = tab[i = (n - 1) & hash]) == null)
//若是沒有被佔用,那就封裝成Node節點,放入哈希桶數組中。
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//若是要插入的Node節點已經存在,那就將舊的Node替換。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//若是不存在,那就插入,判斷插入的節點是否是樹節點。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//若是是普通節點,循環哈希桶對應的鏈表,將節點插入到鏈表末尾
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {d
p.next = newNode(hash, key, value, null);
//若是鏈表的長度大於7,就把節點轉成樹節點
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//若是鏈表中節點已經存在,那就將舊的節點替換。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//若是超過了擴容的臨界點,就進行擴容。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製代碼
get方法相對於put方法可能簡單一點,經過源碼一看就能明白。廢話很少說,直接上代碼看一下吧。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//哈希桶數組不爲空,且根據傳入的key計算出索引位置的Node不爲空。
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//若是計算出來的第一個哈希桶位置的Node就是要找的Node節點,直接返回。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//若是是樹節點,直接經過樹節點的方式查找。
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//循環遍歷哈希桶所在的鏈表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return 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;
//若是老的HashMap容量不爲空
if (oldCap > 0) {
//若是容量大於或者等於這個擴容的臨界點
if (oldCap >= MAXIMUM_CAPACITY) {
//修改閾值爲2^31-1
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 沒超過最大值,就擴充爲原來的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//若是老的容量爲0, 老的閾值大於0, 是由於初始容量沒有被放入閾值,則將新表的容量設置爲老表的閾值
else if (oldThr > 0)
newCap = oldThr;
else {
//老表的容量爲0, 老表的閾值爲0,這種狀況是沒有傳初始容量,將閾值和容量設置爲默認值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 將當前閾值設置爲剛計算出來的新的閾值,定義新表,容量爲剛計算出來的新容量。將舊Hash桶中的元素,移動到新的Hash數組中。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 若是原來的容量不爲空,把每一個bucket都移動到新的buckets中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 將老表的節點設置爲空, 以便垃圾收集器回收空間
oldTab[j] = null;
//哈希桶位置只有一個節點。rehash以後再放到newTab裏面去
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//若是是紅黑樹節點,則進行紅黑樹的重hash分佈
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//若是是普通的鏈表節點,則進行普通的重hash分佈
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//若是要移動節點的hash值與老的容量進行與運算爲0,則擴容後的索引位置跟老表的索引位置同樣
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//若是e的hash值與老表的容量進行與運算不爲0,則擴容後的索引位置爲:老表的索引位置+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製代碼
在源碼中有這麼一段(e.hash & oldCap) == 0,怎麼理解這個呢,咱們經過下面的來看一下
假設擴容以前 數組大小爲16
假若有兩個key:
key1(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
key2(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
&
length-1 = 15 0000 0000 0000 0000 0000 0000 0000 1111
——————————————————————————————————————————————————————————————————
key1: 1000 轉成十進制 8
key2: 1000 轉成十進制 8
哈希衝突的兩個key,在擴容到32以後
key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
&
length-1 = 31 0000 0000 0000 0000 0000 0000 0001 1111
——————————————————————————————————————————————————————————————————
key1: 1 1000 轉乘二進制 24=16+8
key2: 0 1000 轉乘二進制 8
複製代碼
經過上面咱們也能看到,原來在同一個位置上的兩個key,經過擴容以後的位置要不在原來的位置上,要不在oldCap+原位置上。這樣不須要像JDK1.7的實現那樣從新計算hash,只須要看看原來的hash值新增的那個bit是1仍是0就行了,是0的話索引沒變,是1的話索引變成「原索引+oldCap」。同時也是更加充分的說明了,爲何HashMap的容量必須是2的n次方了。
JDK1.8的這個設計確實很是的巧妙,既省去了從新計算hash值的時間,並且同時,因爲新增的1bit是0仍是1能夠認爲是隨機的,所以resize的過程,均勻的把以前的衝突的節點分散到新的bucket了。