HashMap概述
HashMap存儲的是key-value的鍵值對,允許key為null,也允許value為null。HashMap內部為數組+鏈表的結構,會根據key的hashCode值來確定數組的索引(確認放在哪個桶里),如果遇到索引相同的key,桶的大小是2,如果一個key的hashCode是7,一個key的hashCode是3,那么他們就會被分到一個桶中(hash沖突),如果發生hash沖突,HashMap會將同一個桶中的數據以鏈表的形式存儲,但是如果發生hash沖突的概率比較高,就會導致同一個桶中的鏈表長度過長,遍歷效率降低,所以在JDK1.8中如果鏈表長度到達閥值(默認是8),就會將鏈表轉換成紅黑二叉樹。
HashMap數據結構
1 2 //Node本質上是一個Map.存儲着key-value 3 static class Node<K,V> implements Map.Entry<K,V> { 4 final int hash; //保存該桶的hash值 5 final K key; //不可變的key 6 V value; 7 Node<K,V> next; //指向一個數據的指針 8 9 Node(int hash, K key, V value, Node<K,V> next) { 10 this.hash = hash; 11 this.key = key; 12 this.value = value; 13 this.next = next; 14 }
從源碼上可以看到,Node實現了Map.Entry接口,本質上是一個映射(k-v)
剛剛也說過了,有時候兩個key的hashCode可能會定位到一個桶中,這時就發生了hash沖突,如果HashMap的hash算法越散列,那么發生hash沖突的概率越低,如果數組越大,那么發生hash沖突的概率也會越低,但是數組越大帶來的空間開銷越多,但是遍歷速度越快,這就要在空間和時間上進行權衡,這就要看看HashMap的擴容機制,在說擴容機制之前先看幾個比較重要的字段
1 //默認桶16個 2 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 3 4 //默認桶最多有2^30個 5 static final int MAXIMUM_CAPACITY = 1 << 30; 6 7 //默認負載因子是0.75 8 static final float DEFAULT_LOAD_FACTOR = 0.75f; 9 10 //能容納最多key_value對的個數 11 int threshold; 12 13 //一共key_value對個數 14 int size;
threshold=負載因子 * length,也就是說數組長度固定以后, 如果負載因子越大,所能容納的元素個數越多,如果超過這個值就會進行擴容(默認是擴容為原來的2倍),0.75這個值是權衡過空間和時間得出的,建議大家不要隨意修改,如果在一些特殊情況下,比如空間比較多,但要求速度比較快,這時候就可以把擴容因子調小以較少hash沖突的概率。相反就增大擴容因子(這個值可以大於1)。
size就是HashMap中鍵值對的總個數。還有一個字段是modCount,記錄是發生內部結構變化的次數,如果put值,但是put的值是覆蓋原有的值,這樣是不算內部結構變化的。
因為HashMap擴容每次都是擴容為原來的2倍,所以length總是2的次方,這是非常規的設置,常規設置是把桶的大小設置為素數,因為素數發生hash沖突的概率要小於合數,比如HashTable的默認值設置為11,就是桶的大小為素數的應用(HashTable擴容后不能保證是素數)。HashMap采用這種設置是為了在取模和擴容的時候做出優化。
hashMap是通過key的hashCode的高16位和低16位異或后和桶的數量取模得到索引位置,即key.hashcode()^(hashcode>>>16)%length,當length是2^n時,h&(length-1)運算等價於h%length,而&操作比%效率更高。而采用高16位和低16位進行異或,也可以讓所有的位數都參與越算,使得在length比較小的時候也可以做到盡量的散列。
在擴容的時候,如果length每次是2^n,那么重新計算出來的索引只有兩種情況,一種是 old索引+16,另一種是索引不變,所以就不需要每次都重新計算索引。
確定哈希桶數據索引位置
1 //方法一: 2 static final int hash(Object key) { //jdk1.8 & jdk1.7 3 int h; 4 // h = key.hashCode() 為第一步 取hashCode值 5 // h ^ (h >>> 16) 為第二步 高位參與運算 6 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 7 } 8 //方法二: 9 static int indexFor(int h, int length) { //jdk1.7的源碼,jdk1.8沒有這個方法,但是實現原理一樣的 10 return h & (length-1); //第三步 取模運算 11 }
HashMap的put方法實現
思路如下:
1.table[]是否為空
2.判斷table[i]處是否插入過值
3.判斷鏈表長度是否大於8,如果大於就轉換為紅黑二叉樹,並插入樹中
4.判斷key是否和原有key相同,如果相同就覆蓋原有key的value,並返回原有value
5.如果key不相同,就插入一個key,記錄結構變化一次

1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 //判斷table是否為空,如果是空的就創建一個table,並獲取他的長度 4 Node<K,V>[] tab; Node<K,V> p; int n, i; 5 if ((tab = table) == null || (n = tab.length) == 0) 6 n = (tab = resize()).length; 7 //如果計算出來的索引位置之前沒有放過數據,就直接放入 8 if ((p = tab[i = (n - 1) & hash]) == null) 9 tab[i] = newNode(hash, key, value, null); 10 else { 11 //進入這里說明索引位置已經放入過數據了 12 Node<K,V> e; K k; 13 //判斷put的數據和之前的數據是否重復 14 if (p.hash == hash && 15 ((k = p.key) == key || (key != null && key.equals(k)))) //key的地址或key的equals()只要有一個相等就認為key重復了,就直接覆蓋原來key的value 16 e = p; 17 //判斷是否是紅黑樹,如果是紅黑樹就直接插入樹中 18 else if (p instanceof TreeNode) 19 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 20 else { 21 //如果不是紅黑樹,就遍歷每個節點,判斷鏈表長度是否大於8,如果大於就轉換為紅黑樹 22 for (int binCount = 0; ; ++binCount) { 23 if ((e = p.next) == null) { 24 p.next = newNode(hash, key, value, null); 25 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 26 treeifyBin(tab, hash); 27 break; 28 } 29 //判斷索引每個元素的key是否可要插入的key相同,如果相同就直接覆蓋 30 if (e.hash == hash && 31 ((k = e.key) == key || (key != null && key.equals(k)))) 32 break; 33 p = e; 34 } 35 } 36 //如果e不是null,說明沒有迭代到最后就跳出了循環,說明鏈表中有相同的key,因此只需要將value覆蓋,並將oldValue返回即可 37 if (e != null) { // existing mapping for key 38 V oldValue = e.value; 39 if (!onlyIfAbsent || oldValue == null) 40 e.value = value; 41 afterNodeAccess(e); 42 return oldValue; 43 } 44 } 45 //說明沒有key相同,因此要插入一個key-value,並記錄內部結構變化次數 46 ++modCount; 47 if (++size > threshold) 48 resize(); 49 afterNodeInsertion(evict); 50 return null; 51 }
HashMap的get方法實現
實現思路:
1.判斷表或key是否是null,如果是直接返回null
2.判斷索引處第一個key與傳入key是否相等,如果相等直接返回
3.如果不相等,判斷鏈表是否是紅黑二叉樹,如果是,直接從樹中取值
4.如果不是樹,就遍歷鏈表查找
1 final Node<K,V> getNode(int hash, Object key) { 2 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; 3 //如果表不是空的,並且要查找索引處有值,就判斷位於第一個的key是否是要查找的key 4 if ((tab = table) != null && (n = tab.length) > 0 && 5 (first = tab[(n - 1) & hash]) != null) { 6 if (first.hash == hash && // always check first node 7 ((k = first.key) == key || (key != null && key.equals(k)))) 8 //如果是,就直接返回 9 return first; 10 //如果不是就判斷鏈表是否是紅黑二叉樹,如果是,就從樹中取值 11 if ((e = first.next) != null) { 12 if (first instanceof TreeNode) 13 return ((TreeNode<K,V>)first).getTreeNode(hash, key); 14 //如果不是樹,就遍歷鏈表 15 do { 16 if (e.hash == hash && 17 ((k = e.key) == key || (key != null && key.equals(k)))) 18 return e; 19 } while ((e = e.next) != null); 20 } 21 } 22 return null; 23 }
擴容機制
我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。看下圖可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容后key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
元素在重新計算hash之后,因為n變為2倍,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”,可以看看下圖為16擴充為32的resize示意圖:
這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的沖突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。