併發程式設計學習筆記(二十六、ConcurrentHashMap,Java8 HashMap簡述)
目錄:
- 學習準備
- 類核心屬性、內部類、建構函式介紹
- 雜湊衝突(雜湊碰撞)
- put()方法原始碼分析
- resize()方法原始碼分析
學習準備
在閱讀Java8 HashMap前你需要掌握陣列、連結串列、二叉樹、雜湊表等知識。
我這裡來簡單的介紹一下它們:
- 陣列:是通過一組連續的儲存單元來儲存資料的一種結構,通過下標隨機訪問的時間複雜度為O(1),修改操作涉及到元素的移動,複雜度為O(n)。
- 連結串列:連結串列的增刪改操作僅處理節點的引用關係,時間複雜度為O(1);而查詢操作則需要遍歷整個連結串列複雜度為O(n)。
- 二叉樹:對一棵相對平衡的有序二叉樹來說,增改查等操作複雜度均為O(logn)。
- 雜湊表:相比上面的幾種結構雜湊表的效能就比較高了,在不考慮雜湊衝突的情況下,增刪查操作複雜度能達到O(1)。其實現通過雜湊函式(f(value))來定位陣列下標。
屬性、內部類、建構函式
1、核心屬性介紹:
雜湊桶陣列:transient Node<K,V>[] table;
table中每個元素可能是連結串列或紅黑樹。
2、核心內部類:
1 static class Node<K,V> implements Map.Entry<K,V> { 2 final int hash; 3 final K key; 4 V value;5 Node<K,V> next; 6 7 Node(int hash, K key, V value, Node<K,V> next) { 8 this.hash = hash; 9 this.key = key; 10 this.value = value; 11 this.next = next; 12 } 13 14 public final K getKey() { return key; } 15 public final V getValue() { returnvalue; } 16 public final String toString() { return key + "=" + value; } 17 18 public final int hashCode() { 19 return Objects.hashCode(key) ^ Objects.hashCode(value); 20 } 21 22 public final V setValue(V newValue) { 23 V oldValue = value; 24 value = newValue; 25 return oldValue; 26 } 27 28 /** 29 * key和value都相等才判斷為相同物件 30 */ 31 public final boolean equals(Object o) { 32 if (o == this) 33 return true; 34 if (o instanceof Map.Entry) { 35 Map.Entry<?,?> e = (Map.Entry<?,?>)o; 36 if (Objects.equals(key, e.getKey()) && 37 Objects.equals(value, e.getValue())) 38 return true; 39 } 40 return false; 41 } 42 }
3、建構函式:
1 /** 2 * 指定初始容量及負載因子 3 */ 4 public HashMap(int initialCapacity, float loadFactor) { 5 if (initialCapacity < 0) 6 throw new IllegalArgumentException("Illegal initial capacity: " + 7 initialCapacity); 8 if (initialCapacity > MAXIMUM_CAPACITY) 9 initialCapacity = MAXIMUM_CAPACITY; 10 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 11 throw new IllegalArgumentException("Illegal load factor: " + 12 loadFactor); 13 this.loadFactor = loadFactor; 14 this.threshold = tableSizeFor(initialCapacity); 15 } 16 17 /** 18 * 指定初始容量,使用預設負載因子0.75 19 */ 20 public HashMap(int initialCapacity) { 21 this(initialCapacity, DEFAULT_LOAD_FACTOR); 22 } 23 24 /** 25 * 無參構造,預設容量大小為16,負載因子0.75 26 */ 27 public HashMap() { 28 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 29 } 30 31 /** 32 * 將指定元素新增到HashMap中,負載因子為0.75 33 */ 34 public HashMap(Map<? extends K, ? extends V> m) { 35 this.loadFactor = DEFAULT_LOAD_FACTOR; 36 putMapEntries(m, false); 37 }
雜湊衝突(雜湊碰撞)
雜湊衝突,也叫雜湊碰撞,是兩個元素在經過hash演算法計算後得到相同的下標,這樣兩個元素儲存的位置就衝突了。
也就是說我們在設計hash函式的時候要儘量讓每個元素都均勻的分散在各個下標裡,但我們知道陣列的下標畢竟是有限的,所以肯定是會發生hash衝突的。
那發生衝突後我們應該如何解決呢,目前有三種常見的解決方案:
- 開放定址法:發生衝突後,繼續找下一處未被佔用的地址。
- 連結串列法:發生衝突後,儲存到連結串列中。
- 再雜湊函式法:通過其它的hash函式再進行計算一次hash值。
值得注意的是Java8的HashMap採用的是連結串列法的方式解決hash衝突,當連結串列長度大於8是會轉換為紅黑樹。
那為什麼要這樣做呢,肯定是事出有因的,因為遍歷連結串列的複雜度為O(1),當衝突過多連結串列長度就會變得很長,導致查詢資料時會嚴重影響效率。所以會在長度大於8時,轉換成紅黑樹,將遍歷的時間優化為O(logn)。
put()方法原始碼分析
1 public V put(K key, V value) { 2 return putVal(hash(key), key, value, false, true); 3 }
hash方法解析
首先我們來看下HashMap中新增元素的方法put(),可以看出其呼叫了pulVal方法,這個函式我們後續再分析,我們先來看下它的第一個入參,也就是hash(key)。
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 }
在閱讀hash程式碼時首先你需要了解位運算,我把上面用到的位運算貼在下面。
- ^:轉換為二進位制後進行比較,若相同則為0,不相同則為1。
- <<:左移,<< 2就是左移兩位,轉換成10進位制來說就是* 2 ^ 2,也就是乘4。
- >>:右移,與左移相反>> 2就是除4。
- >>>:右移,左側補位0;>>> 3就是右移3位,左側位數補3個0。
- &:轉換為二進位制,若兩個數都為1則為1,否則為0。
上述hash方法總共分為3步,加上解析出下標共計為4步:
- h = key.hashCode()
- h >>> 16
- (h = key.hashCode()) ^ (h >>> 16)
- (n - 1) & hash
現在我們根據上述位運算的描述分別來解析每個步驟後得到的值。
- 首先我們要知道hashCode是int型別,也就是4個位元組,每個位元組是8位的二進位制;所以我們假設我們第一步h得到的hash值為1111 1111 1111 1111 1111 0000 1010 1100。
- 其次n為陣列長度,為16。
經過計算後每步值就會如下:
h = key.hashCode():1111 1111 1111 1111 1111 0000 1010 1100
h >>> 16: 00000000000000001111 111111111111
hash = h ^ (h >>> 16): 1111 1111 1111 1111 0000 1111 0101 0011
n - 1: 0000 0000 0000 0000 0000 0000 0000 1111
hash:1111 1111 1111 1111 0000 1111 0101 0011
(n - 1) & hash: 0000 0000 0000 0000 0000 0000 0000 0011
轉換為十進位制後: 3
根據上面的解析你可能會有一個疑問,為啥HashMap中為計算下標是(n - 1) & hash呢?
首先在說明前,我需要提下n是HashMap的容量,而且它一定是2的n次冪。
所以n - 1最終的出的二進位制值一定都會是1111,11111這種全是1的二進位制;如16 - 1 = 1111,32 - 1 = 11111。
然後&是都為1則為1,否則為0,所以不管&的hash值是多少,只要是2的n次冪去&,一定只能得到後四位(以n=16為例)。
所以計算出來的值肯定不會超過15,數值一定在大小為16的HashMap中。
故HashMap的容量一定要是2的n次冪的原因就是,一,位運算速度高於取膜,二,2的n次冪與任何hash值進行&運算都不會大於它本身。
這也就是&的妙用之處啊。
putVal方法解析
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 // tab: HashMap資料來源 4 // p: 根據hash計算出的陣列下標的值 5 // n: tab的長度 6 // i: 根據hash計算出的陣列下標 7 Node<K,V>[] tab; Node<K,V> p; int n, i; 8 if ((tab = table) == null || (n = tab.length) == 0) 9 // 若是空陣列則呼叫resize(),有初始化、擴容等功效 10 // 且HashMap是延遲初始化的 11 n = (tab = resize()).length; 12 if ((p = tab[i = (n - 1) & hash]) == null) 13 // 若根據hash值得到的下標沒有資料,則插入一條新的Node 14 tab[i] = newNode(hash, key, value, null); 15 else { 16 Node<K,V> e; K k; 17 // 若index位置有值,且key一致則覆蓋value 18 if (p.hash == hash && 19 ((k = p.key) == key || (key != null && key.equals(k)))) 20 e = p; 21 // 若index位置有值,則判斷是否為紅黑樹 22 else if (p instanceof TreeNode) 23 // 將當前節點插入到紅黑樹上 24 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 25 else { 26 // 此種情況則為連結串列 27 for (int binCount = 0; ; ++binCount) { 28 // 連結串列最後一個結點才會為null,故e=最後一個連結串列節點 29 if ((e = p.next) == null) { 30 // 將原有節點的next指標指向新的節點 31 p.next = newNode(hash, key, value, null); 32 // 連結串列長度大於8轉換為紅黑樹處理 33 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 34 treeifyBin(tab, hash); 35 break; 36 } 37 // 若key存在,則直接覆蓋value 38 if (e.hash == hash && 39 ((k = e.key) == key || (key != null && key.equals(k)))) 40 break; 41 p = e; 42 } 43 } 44 // 若e非空,即為存在一個key相等的鍵值對 45 if (e != null) { // existing mapping for key 46 V oldValue = e.value; 47 // 控制新的value值是否覆蓋舊的value值 48 if (!onlyIfAbsent || oldValue == null) 49 e.value = value; 50 // 模板方法,給LinkedHashMap用 51 afterNodeAccess(e); 52 return oldValue; 53 } 54 } 55 // 增加修改次數,此欄位用於在迭代器修改值時快速失敗 56 ++modCount; 57 // 大於閥值時擴容 58 if (++size > threshold) 59 resize(); 60 // 模板方法,給LinkedHashMap用 61 afterNodeInsertion(evict); 62 return null; 63 }
resize()方法原始碼分析
resize()擴容與初始化
1 final Node<K,V>[] resize() { 2 Node<K,V>[] oldTab = table; 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold; 5 int newCap, newThr = 0; 6 // table不為空時 7 if (oldCap > 0) { 8 // 容量已經是最大值了,不能擴容了 9 if (oldCap >= MAXIMUM_CAPACITY) { 10 threshold = Integer.MAX_VALUE; 11 return oldTab; 12 } 13 // newCap = oldCap * 2 14 // 如果newCap增大兩倍後任小於最大容量 && oldCap大於預設的16容量 15 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 16 oldCap >= DEFAULT_INITIAL_CAPACITY) 17 // 那麼擴容閥值threshold就會增加兩倍 18 newThr = oldThr << 1; // double threshold 19 } 20 else if (oldThr > 0) // initial capacity was placed in threshold 21 newCap = oldThr; 22 else { // zero initial threshold signifies using defaults 23 // 其它情況,也就是使用無參構造時第一次呼叫put方法會初始化table 24 newCap = DEFAULT_INITIAL_CAPACITY; 25 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 26 } 27 if (newThr == 0) { 28 float ft = (float)newCap * loadFactor; 29 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 30 (int)ft : Integer.MAX_VALUE); 31 } 32 threshold = newThr; 33 @SuppressWarnings({"rawtypes","unchecked"}) 34 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 35 table = newTab; 36 // oldTab != null:table有資料才將擴容前的陣列已到新陣列中,否則不需要遷移 37 if (oldTab != null) { 38 // 遍歷陣列 39 for (int j = 0; j < oldCap; ++j) { 40 // 拿到每個節點的物件 41 Node<K,V> e; 42 // 節點物件不為空時 43 if ((e = oldTab[j]) != null) { 44 // 將老陣列的元素刪除,也就是置為空,方便gc 45 oldTab[j] = null; 46 // e的後繼節點為null,也即是此位置的陣列桶還只有一個元素,沒有發生hash衝突 47 if (e.next == null) 48 // 此時直接重新計算在新陣列的下標就可以了 49 newTab[e.hash & (newCap - 1)] = e; 50 // 如果是紅黑樹,那麼直接新增到紅黑樹中 51 else if (e instanceof TreeNode) 52 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 53 // 如果是連結串列的話,它對於1.7的處理做了優化 54 else { // preserve order 55 Node<K,V> loHead = null, loTail = null; 56 Node<K,V> hiHead = null, hiTail = null; 57 Node<K,V> next; 58 do { 59 next = e.next; 60 if ((e.hash & oldCap) == 0) { 61 if (loTail == null) 62 loHead = e; 63 else 64 loTail.next = e; 65 loTail = e; 66 } 67 else { 68 if (hiTail == null) 69 hiHead = e; 70 else 71 hiTail.next = e; 72 hiTail = e; 73 } 74 } while ((e = next) != null); 75 if (loTail != null) { 76 loTail.next = null; 77 newTab[j] = loHead; 78 } 79 if (hiTail != null) { 80 hiTail.next = null; 81 newTab[j + oldCap] = hiHead; 82 } 83 } 84 } 85 } 86 } 87 return newTab; 88 }
Java8 HashMap連結串列擴容優化
emmmmm,上面連結串列的擴容程式碼辣麼長,我就不細說了,因為要看懂它其實也不難,只要把我先說的弄懂了就可以很輕鬆的看懂。
首先上面說到了HashMap擴容是將原來容量左移1位,也就是擴容兩倍,原來16的長度會變成32。
既然擴容了,那原先會發生hash衝突的key再此操作後就不一定會衝突了,所以在擴容元素遷移的時候肯定也不是要遍歷整個連結串列後將其移到新的位置去了,是不。
HashMap的編寫者就是根據這一特性優化了HashMap連結串列的擴容。
現在我來根據擴容前16長度,32長度來作說明,來看看HashMap是如何優化的:
首先16擴容到32的時候最後運算出的下標我們可以發現經過&運算後,唯一不同的就是低位的第五位一個是0一個是1。
而它們的十進位制分別為5和21,也就是5和5 + n,其中n = HashMap原容量大小16。
也就是說我們在擴容的時候不需要向Java7那樣遍歷整個連結串列了,只需要看新增的那位bit是0還是1。
0的話索引還是沒有變,保持原位,1的話索引右邊,放在原位置 + 原容量大小的下標即可。
這個設計確實非常的巧妙,既省去了重新計算nash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。
這一塊就是Java8新增的優化點。有一點注意區別,Java7中rehash的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置,但是按照Java8的邏輯是不會倒置的。